import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; import { IdService } from '@/core/IdService.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import * as Acct from '@/misc/acct.js'; import { Cache } from '@/misc/cache.js'; import type { Packed } from '@/misc/schema.js'; import { DI } from '@/di-symbols.js'; import { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from './UtilityService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() export class AntennaService implements OnApplicationShutdown { #antennasFetched: boolean; #antennas: Antenna[]; #blockingCache: Cache; constructor( @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.antennaNotesRepository) private antennaNotesRepository: AntennaNotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @Inject(DI.userGroupJoiningsRepository) private userGroupJoiningsRepository: UserGroupJoiningsRepository, @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, private utilityService: UtilityService, private idService: IdService, private globalEventServie: GlobalEventService, ) { this.#antennasFetched = false; this.#antennas = []; this.#blockingCache = new Cache(1000 * 60 * 5); this.redisSubscriber.on('message', this.onRedisMessage); } public onApplicationShutdown(signal?: string | undefined) { this.redisSubscriber.off('message', this.onRedisMessage); } private async onRedisMessage(_, data) { const obj = JSON.parse(data); if (obj.channel === 'internal') { const { type, body } = obj.message; switch (type) { case 'antennaCreated': this.#antennas.push(body); break; case 'antennaUpdated': this.#antennas[this.#antennas.findIndex(a => a.id === body.id)] = body; break; case 'antennaDeleted': this.#antennas = this.#antennas.filter(a => a.id !== body.id); break; default: break; } } } public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { // 通知しない設定になっているか、自分自身の投稿なら既読にする const read = !antenna.notify || (antenna.userId === noteUser.id); this.antennaNotesRepository.insert({ id: this.idService.genId(), antennaId: antenna.id, noteId: note.id, read: read, }); this.globalEventServie.publishAntennaStream(antenna.id, 'note', note); if (!read) { const mutings = await this.mutingsRepository.find({ where: { muterId: antenna.userId, }, select: ['muteeId'], }); // Copy const _note: Note = { ...note, }; if (note.replyId != null) { _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); } if (note.renoteId != null) { _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); } if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { return; } // 2秒経っても既読にならなかったら通知 setTimeout(async () => { const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); if (unread) { this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna); } }, 2000); } } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている /** * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい */ public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise { if (note.visibility === 'specified') return false; // アンテナ作成者がノート作成者にブロックされていたらスキップ const blockings = await this.#blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId))); if (blockings.some(blocking => blocking === antenna.userId)) return false; if (note.visibility === 'followers') { if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; } if (!antenna.withReplies && note.replyId != null) return false; if (antenna.src === 'home') { if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; } else if (antenna.src === 'list') { const listUsers = (await this.userListJoiningsRepository.findBy({ userListId: antenna.userListId!, })).map(x => x.userId); if (!listUsers.includes(note.userId)) return false; } else if (antenna.src === 'group') { const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! }); const groupUsers = (await this.userGroupJoiningsRepository.findBy({ userGroupId: joining.userGroupId, })).map(x => x.userId); if (!groupUsers.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(); }); if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) 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) return false; const matched = keywords.some(and => and.every(keyword => antenna.caseSensitive ? note.text!.includes(keyword) : note.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) return false; const matched = excludeKeywords.some(and => and.every(keyword => antenna.caseSensitive ? note.text!.includes(keyword) : note.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; } public async getAntennas() { if (!this.#antennasFetched) { this.#antennas = await this.antennasRepository.find(); this.#antennasFetched = true; } return this.#antennas; } }