219 lines
7.7 KiB
TypeScript
219 lines
7.7 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import * as Redis from 'ioredis';
|
|
import type { MiAntenna } from '@/models/Antenna.js';
|
|
import type { MiNote } from '@/models/Note.js';
|
|
import type { MiUser } from '@/models/User.js';
|
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
import * as Acct from '@/misc/acct.js';
|
|
import type { Packed } from '@/misc/json-schema.js';
|
|
import { DI } from '@/di-symbols.js';
|
|
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
|
import { UtilityService } from '@/core/UtilityService.js';
|
|
import { bindThis } from '@/decorators.js';
|
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
|
|
|
@Injectable()
|
|
export class AntennaService implements OnApplicationShutdown {
|
|
private antennasFetched: boolean;
|
|
private antennas: MiAntenna[];
|
|
|
|
constructor(
|
|
@Inject(DI.redisForTimelines)
|
|
private redisForTimelines: Redis.Redis,
|
|
|
|
@Inject(DI.redisForSub)
|
|
private redisForSub: Redis.Redis,
|
|
|
|
@Inject(DI.antennasRepository)
|
|
private antennasRepository: AntennasRepository,
|
|
|
|
@Inject(DI.userListMembershipsRepository)
|
|
private userListMembershipsRepository: UserListMembershipsRepository,
|
|
|
|
private utilityService: UtilityService,
|
|
private globalEventService: GlobalEventService,
|
|
private fanoutTimelineService: FanoutTimelineService,
|
|
) {
|
|
this.antennasFetched = false;
|
|
this.antennas = [];
|
|
|
|
this.redisForSub.on('message', this.onRedisMessage);
|
|
}
|
|
|
|
@bindThis
|
|
private async onRedisMessage(_: string, data: string): Promise<void> {
|
|
const obj = JSON.parse(data);
|
|
|
|
if (obj.channel === 'internal') {
|
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
|
switch (type) {
|
|
case 'antennaCreated':
|
|
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
|
...body,
|
|
lastUsedAt: new Date(body.lastUsedAt),
|
|
user: null, // joinなカラムは通常取ってこないので
|
|
userList: null, // joinなカラムは通常取ってこないので
|
|
});
|
|
break;
|
|
case 'antennaUpdated': {
|
|
const idx = this.antennas.findIndex(a => a.id === body.id);
|
|
if (idx >= 0) {
|
|
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
|
...body,
|
|
lastUsedAt: new Date(body.lastUsedAt),
|
|
user: null, // joinなカラムは通常取ってこないので
|
|
userList: null, // joinなカラムは通常取ってこないので
|
|
};
|
|
} else {
|
|
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
|
|
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
|
...body,
|
|
lastUsedAt: new Date(body.lastUsedAt),
|
|
user: null, // joinなカラムは通常取ってこないので
|
|
userList: null, // joinなカラムは通常取ってこないので
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
case 'antennaDeleted':
|
|
this.antennas = this.antennas.filter(a => a.id !== body.id);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
@bindThis
|
|
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
|
|
const antennas = await this.getAntennas();
|
|
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
|
|
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
|
|
|
|
const redisPipeline = this.redisForTimelines.pipeline();
|
|
|
|
for (const antenna of matchedAntennas) {
|
|
this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
|
|
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
|
}
|
|
|
|
redisPipeline.exec();
|
|
}
|
|
|
|
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
|
|
|
@bindThis
|
|
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
|
|
if (note.visibility === 'specified') return false;
|
|
if (note.visibility === 'followers') return false;
|
|
|
|
if (antenna.excludeBots && noteUser.isBot) return false;
|
|
|
|
if (antenna.localOnly && noteUser.host != null) return false;
|
|
|
|
if (!antenna.withReplies && note.replyId != null) return false;
|
|
|
|
if (antenna.src === 'home') {
|
|
// TODO
|
|
} else if (antenna.src === 'list') {
|
|
const listUsers = (await this.userListMembershipsRepository.findBy({
|
|
userListId: antenna.userListId!,
|
|
})).map(x => x.userId);
|
|
|
|
if (!listUsers.includes(note.userId)) return false;
|
|
} else if (antenna.src === 'users') {
|
|
const accts = antenna.users.map(x => {
|
|
const { username, host } = Acct.parse(x);
|
|
return this.utilityService.getFullApAccount(username, host).toLowerCase();
|
|
});
|
|
const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
|
|
const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase();
|
|
if (!accts.includes(matchUser) && !accts.includes(matchWildcard)) return false;
|
|
} else if (antenna.src === 'users_blacklist') {
|
|
const accts = antenna.users.map(x => {
|
|
const { username, host } = Acct.parse(x);
|
|
return this.utilityService.getFullApAccount(username, host).toLowerCase();
|
|
});
|
|
const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
|
|
const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase();
|
|
if (accts.includes(matchUser) || accts.includes(matchWildcard)) return false;
|
|
}
|
|
|
|
const keywords = antenna.keywords
|
|
// Clean up
|
|
.map(xs => xs.filter(x => x !== ''))
|
|
.filter(xs => xs.length > 0);
|
|
|
|
if (keywords.length > 0) {
|
|
if (note.text == null && note.cw == null) return false;
|
|
|
|
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
|
|
|
|
const matched = keywords.some(and =>
|
|
and.every(keyword =>
|
|
antenna.caseSensitive
|
|
? _text.includes(keyword)
|
|
: _text.toLowerCase().includes(keyword.toLowerCase()),
|
|
));
|
|
|
|
if (!matched) return false;
|
|
}
|
|
|
|
const excludeKeywords = antenna.excludeKeywords
|
|
// Clean up
|
|
.map(xs => xs.filter(x => x !== ''))
|
|
.filter(xs => xs.length > 0);
|
|
|
|
if (excludeKeywords.length > 0) {
|
|
if (note.text == null && note.cw == null) return false;
|
|
|
|
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
|
|
|
|
const matched = excludeKeywords.some(and =>
|
|
and.every(keyword =>
|
|
antenna.caseSensitive
|
|
? _text.includes(keyword)
|
|
: _text.toLowerCase().includes(keyword.toLowerCase()),
|
|
));
|
|
|
|
if (matched) return false;
|
|
}
|
|
|
|
if (antenna.withFile) {
|
|
if (note.fileIds && note.fileIds.length === 0) return false;
|
|
}
|
|
|
|
// TODO: eval expression
|
|
|
|
return true;
|
|
}
|
|
|
|
@bindThis
|
|
public async getAntennas() {
|
|
if (!this.antennasFetched) {
|
|
this.antennas = await this.antennasRepository.findBy({
|
|
isActive: true,
|
|
});
|
|
this.antennasFetched = true;
|
|
}
|
|
|
|
return this.antennas;
|
|
}
|
|
|
|
@bindThis
|
|
public dispose(): void {
|
|
this.redisForSub.off('message', this.onRedisMessage);
|
|
}
|
|
|
|
@bindThis
|
|
public onApplicationShutdown(signal?: string | undefined): void {
|
|
this.dispose();
|
|
}
|
|
}
|