impl service

This commit is contained in:
おさむのひと 2025-02-28 19:56:14 +09:00
parent c63c3462dd
commit 7a8ab424d4
2 changed files with 198 additions and 110 deletions

View File

@ -5,24 +5,142 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { MiAntenna } from '@/models/Antenna.js';
import RE2 from 're2';
import { 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 type { AntennasRepository } 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';
type AntennaFilter = {
antennaId: string;
src: MiAntenna;
testKeywords(target: string): boolean;
};
function matchAntennaKeywords(target: string, keywords: string[][]): boolean {
return keywords.some(and => and.every(keyword => target.includes(keyword)));
}
function matchAntennaKeywordsCaseInsensitive(target: string, keywords: string[][]): boolean {
const _target = target.toLowerCase();
return keywords.some(and => and.every(keyword => _target.includes(keyword)));
}
function matchAntennaKeywordsRegex(target: string, patterns: RE2[]): boolean {
return patterns.every(re => re.test(target));
}
export const antennaFilters = {
regex: {
includeAndExclude: (target: string, keywords: RE2[], excludeKeywords: RE2[]): boolean => {
return matchAntennaKeywordsRegex(target, keywords) && !matchAntennaKeywordsRegex(target, excludeKeywords);
},
includeOnly: (target: string, keywords: RE2[]): boolean => {
return matchAntennaKeywordsRegex(target, keywords);
},
excludeOnly: (target: string, excludeKeywords: RE2[]): boolean => {
return !matchAntennaKeywordsRegex(target, excludeKeywords);
},
},
noRegex: {
caseSensitive: {
includeAndExclude: (target: string, keywords: string[][], excludeKeywords: string[][]): boolean => {
return matchAntennaKeywords(target, keywords) && !matchAntennaKeywords(target, excludeKeywords);
},
includeOnly: (target: string, keywords: string[][]): boolean => {
return matchAntennaKeywords(target, keywords);
},
excludeOnly: (target: string, excludeKeywords: string[][]): boolean => {
return !matchAntennaKeywords(target, excludeKeywords);
},
},
caseInsensitive: {
includeAndExclude: (target: string, keywords: string[][], excludeKeywords: string[][]): boolean => {
return matchAntennaKeywordsCaseInsensitive(target, keywords) && !matchAntennaKeywordsCaseInsensitive(target, excludeKeywords);
},
includeOnly: (target: string, keywords: string[][]): boolean => {
return matchAntennaKeywordsCaseInsensitive(target, keywords);
},
excludeOnly: (target: string, excludeKeywords: string[][]): boolean => {
return !matchAntennaKeywordsCaseInsensitive(target, excludeKeywords);
},
},
},
};
function alwaysTrue(): boolean {
return true;
}
function createAntennaFilter(antenna: MiAntenna): AntennaFilter {
function createTestKeywordsFunction(antenna: MiAntenna): AntennaFilter['testKeywords'] {
// Clean up
const keywords = antenna.keywords
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
const excludeKeywords = antenna.excludeKeywords
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (antenna.useRegex) {
// 元々はAND検索を行うために2次元配列としてもっていた歴史的経緯がある.
// 正規表現の時は1行に付き1パターンとするため、[n][0]にパターンの内容すべてが格納されているものとして扱う.
const keywordsPatterns = keywords.map(line => new RE2(line[0]));
const excludeKeywordsPatterns = excludeKeywords.map(line => new RE2(line[0]));
const regex = antennaFilters.regex;
if (keywords.length > 0 && excludeKeywords.length > 0) {
return (target: string) => regex.includeAndExclude(target, keywordsPatterns, excludeKeywordsPatterns);
} else if (keywords.length > 0) {
return (target: string) => regex.includeOnly(target, keywordsPatterns);
} else if (excludeKeywords.length > 0) {
return (target: string) => regex.excludeOnly(target, excludeKeywordsPatterns);
} else {
return alwaysTrue;
}
} else if (antenna.caseSensitive) {
if (keywords.length > 0 && excludeKeywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseSensitive.includeAndExclude(target, keywords, excludeKeywords);
} else if (keywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseSensitive.includeOnly(target, keywords);
} else if (excludeKeywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseSensitive.excludeOnly(target, excludeKeywords);
} else {
return alwaysTrue;
}
} else {
if (keywords.length > 0 && excludeKeywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseInsensitive.includeAndExclude(target, keywords, excludeKeywords);
} else if (keywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseInsensitive.includeOnly(target, keywords);
} else if (excludeKeywords.length > 0) {
return (target: string) => antennaFilters.noRegex.caseInsensitive.excludeOnly(target, excludeKeywords);
} else {
return alwaysTrue;
}
}
}
return {
antennaId: antenna.id,
src: antenna,
testKeywords: createTestKeywordsFunction(antenna),
};
}
@Injectable()
export class AntennaService implements OnApplicationShutdown {
private antennasFetched: boolean;
private antennas: MiAntenna[];
private filtersFetched: boolean;
private filters: AntennaFilter[];
constructor(
@Inject(DI.redisForTimelines)
@ -34,15 +152,12 @@ export class AntennaService implements OnApplicationShutdown {
@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.filtersFetched = false;
this.filters = [];
this.redisForSub.on('message', this.onRedisMessage);
}
@ -54,37 +169,24 @@ export class AntennaService implements OnApplicationShutdown {
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なカラムは通常取ってこないので
});
case 'antennaCreated': {
this.filters.push(createAntennaFilter(MiAntenna.deserialize(body)));
break;
}
case 'antennaUpdated': {
const idx = this.antennas.findIndex(a => a.id === body.id);
const idx = this.filters.findIndex(a => a.antennaId === body.id);
if (idx >= 0) {
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
};
this.filters[idx] = createAntennaFilter(MiAntenna.deserialize(body));
} else {
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
lastUsedAt: new Date(body.lastUsedAt),
user: null, // joinなカラムは通常取ってこないので
userList: null, // joinなカラムは通常取ってこないので
});
this.filters.push(createAntennaFilter(MiAntenna.deserialize(body)));
}
break;
}
case 'antennaDeleted': {
this.filters = this.filters.filter(a => a.antennaId !== body.id);
break;
case 'antennaDeleted':
this.antennas = this.antennas.filter(a => a.id !== body.id);
break;
}
default:
break;
}
@ -93,15 +195,17 @@ export class AntennaService implements OnApplicationShutdown {
@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 filters = await this.getFilters();
const checkResults = await Promise.all(
filters.map(filter => this.checkHitAntenna(filter, note, noteUser).then(hit => [filter, hit] as const)),
);
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);
for (const [filter, hit] of checkResults) {
if (hit) {
this.fanoutTimelineService.push(`antennaTimeline:${filter.antennaId}`, note.id, 200, redisPipeline);
this.globalEventService.publishAntennaStream(filter.antennaId, 'note', note);
}
}
redisPipeline.exec();
@ -110,100 +214,67 @@ export class AntennaService implements OnApplicationShutdown {
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
private async checkHitAntenna(
filter: AntennaFilter,
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;
const antenna = filter.src;
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') {
if (antenna.userListId == null) return false;
const exists = await this.userListMembershipsRepository.exists({
where: {
userListId: antenna.userListId,
userId: note.userId,
},
});
if (!exists) return false;
// フロントエンドは塞がれているのでコメントアウト
// if (antenna.userListId == null) return false;
// const exists = await this.userListMembershipsRepository.exists({
// where: {
// userListId: antenna.userListId,
// userId: note.userId,
// },
// });
// if (!exists) 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;
if (!this.isIncludeUser(antenna, noteUser)) 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;
}
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 (this.isIncludeUser(antenna, noteUser)) return false;
}
if (antenna.withFile) {
if (note.fileIds && note.fileIds.length === 0) return false;
}
// TODO: eval expression
return true;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
return filter.testKeywords(_text);
}
@bindThis
public async getAntennas() {
if (!this.antennasFetched) {
this.antennas = await this.antennasRepository.findBy({
private async getFilters() {
if (!this.filtersFetched) {
const antennas = await this.antennasRepository.findBy({
isActive: true,
});
this.antennasFetched = true;
this.filters = antennas.map(createAntennaFilter);
this.filtersFetched = true;
}
return this.antennas;
return this.filters;
}
@bindThis
private isIncludeUser(antenna: MiAntenna, noteUser: { username: string; host: string | null; }): boolean {
const antennaUserAccounts = antenna.users.map(x => {
const { username, host } = Acct.parse(x);
return this.utilityService.getFullApAccount(username, host).toLowerCase();
});
const noteUserAccount = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
return antennaUserAccounts.includes(noteUserAccount);
}
@bindThis

View File

@ -3,10 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
import { Serialized } from '@/types.js';
import { MiUser } from './User.js';
import { MiUserList } from './UserList.js';
import { id } from './util/id.js';
@Entity('antenna')
export class MiAntenna {
@ -100,4 +101,20 @@ export class MiAntenna {
default: false,
})
public localOnly: boolean;
@Column('boolean', {
default: false,
})
public useRegex: boolean;
public static deserialize(data: Serialized<MiAntenna>): MiAntenna {
return {
...data,
lastUsedAt: new Date(data.lastUsedAt),
// クエリビルダで明示的にSELECT/JOINしないかぎり設定されない値なのでnullにしておく
user: null,
// クエリビルダで明示的にSELECT/JOINしないかぎり設定されない値なのでnullにしておく
userList: null,
};
}
}