egirlskey/packages/backend/src/core/AntennaService.ts

214 lines
7.3 KiB
TypeScript
Raw Normal View History

/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
2022-09-17 18:27:08 +00:00
import { Inject, Injectable } from '@nestjs/common';
2023-04-14 04:50:05 +00:00
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';
2022-09-17 18:27:08 +00:00
import { GlobalEventService } from '@/core/GlobalEventService.js';
import * as Acct from '@/misc/acct.js';
2023-03-10 05:22:37 +00:00
import type { Packed } from '@/misc/json-schema.js';
2022-09-17 18:27:08 +00:00
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
2022-12-04 01:16:03 +00:00
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
2023-09-29 02:29:54 +00:00
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
2022-09-17 18:27:08 +00:00
@Injectable()
export class AntennaService implements OnApplicationShutdown {
2022-09-18 18:11:50 +00:00
private antennasFetched: boolean;
private antennas: MiAntenna[];
2022-09-17 18:27:08 +00:00
constructor(
@Inject(DI.redisForTimelines)
private redisForTimelines: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
2022-09-17 18:27:08 +00:00
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
2022-09-17 18:27:08 +00:00
private utilityService: UtilityService,
2023-02-04 01:02:03 +00:00
private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService,
2022-09-17 18:27:08 +00:00
) {
2022-09-18 18:11:50 +00:00
this.antennasFetched = false;
this.antennas = [];
2022-09-17 18:27:08 +00:00
this.redisForSub.on('message', this.onRedisMessage);
2022-09-17 18:27:08 +00:00
}
@bindThis
2022-09-23 21:45:44 +00:00
private async onRedisMessage(_: string, data: string): Promise<void> {
2022-09-17 18:27:08 +00:00
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
2023-09-29 02:29:54 +00:00
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
2022-09-17 18:27:08 +00:00
switch (type) {
case 'antennaCreated':
2024-01-22 08:44:03 +00:00
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
2023-01-25 02:18:16 +00:00
...body,
lastUsedAt: new Date(body.lastUsedAt),
2024-01-22 08:44:03 +00:00
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
2023-01-25 02:18:16 +00:00
});
2022-09-17 18:27:08 +00:00
break;
case 'antennaUpdated': {
const idx = this.antennas.findIndex(a => a.id === body.id);
if (idx >= 0) {
2024-01-22 08:44:03 +00:00
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
2024-01-22 08:44:03 +00:00
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
};
} else {
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
2024-01-22 08:44:03 +00:00
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
2024-01-22 08:44:03 +00:00
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
}
}
2022-09-17 18:27:08 +00:00
break;
case 'antennaDeleted':
2022-09-18 18:11:50 +00:00
this.antennas = this.antennas.filter(a => a.id !== body.id);
2022-09-17 18:27:08 +00:00
break;
default:
break;
}
}
}
@bindThis
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): 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();
2022-09-17 18:27:08 +00:00
}
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> {
2022-09-17 18:27:08 +00:00
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (antenna.localOnly && noteUser.host != null) return false;
2022-09-17 18:27:08 +00:00
if (!antenna.withReplies && note.replyId != null) return false;
2022-09-17 18:27:08 +00:00
if (antenna.src === 'home') {
// TODO
2022-09-17 18:27:08 +00:00
} else if (antenna.src === 'list') {
const listUsers = (await this.userListMembershipsRepository.findBy({
2022-09-17 18:27:08 +00:00
userListId: antenna.userListId!,
})).map(x => x.userId);
2022-09-17 18:27:08 +00:00
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();
});
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) 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();
});
if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
2022-09-17 18:27:08 +00:00
}
2022-09-17 18:27:08 +00:00
const keywords = antenna.keywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
2022-09-17 18:27:08 +00:00
if (keywords.length > 0) {
if (note.text == null && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
2022-09-17 18:27:08 +00:00
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? _text.includes(keyword)
: _text.toLowerCase().includes(keyword.toLowerCase()),
2022-09-17 18:27:08 +00:00
));
2022-09-17 18:27:08 +00:00
if (!matched) return false;
}
2022-09-17 18:27:08 +00:00
const excludeKeywords = antenna.excludeKeywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
2022-09-17 18:27:08 +00:00
if (excludeKeywords.length > 0) {
if (note.text == null && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
2022-09-17 18:27:08 +00:00
const matched = excludeKeywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? _text.includes(keyword)
: _text.toLowerCase().includes(keyword.toLowerCase()),
2022-09-17 18:27:08 +00:00
));
2022-09-17 18:27:08 +00:00
if (matched) return false;
}
2022-09-17 18:27:08 +00:00
if (antenna.withFile) {
if (note.fileIds && note.fileIds.length === 0) return false;
}
2022-09-17 18:27:08 +00:00
// TODO: eval expression
2022-09-17 18:27:08 +00:00
return true;
}
@bindThis
2022-09-17 18:27:08 +00:00
public async getAntennas() {
2022-09-18 18:11:50 +00:00
if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.findBy({
isActive: true,
});
2022-09-18 18:11:50 +00:00
this.antennasFetched = true;
2022-09-17 18:27:08 +00:00
}
2022-09-18 18:11:50 +00:00
return this.antennas;
2022-09-17 18:27:08 +00:00
}
2023-05-29 04:21:26 +00:00
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onRedisMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
2022-09-17 18:27:08 +00:00
}