/* * 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 { 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 { 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 { 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(); } }