Merge 5b6ace0b8a
into d522d1bf26
This commit is contained in:
commit
a67795a195
|
@ -1706,6 +1706,14 @@ export interface Locale extends ILocale {
|
||||||
* Botアカウントを除外
|
* Botアカウントを除外
|
||||||
*/
|
*/
|
||||||
"antennaExcludeBots": string;
|
"antennaExcludeBots": string;
|
||||||
|
/**
|
||||||
|
* 正規表現を使用する
|
||||||
|
*/
|
||||||
|
"antennaUseRegex": string;
|
||||||
|
/**
|
||||||
|
* {src}の{line}行目にエラーがあります。
|
||||||
|
*/
|
||||||
|
"antennaUseRegexError": ParameterizedString<"src" | "line">;
|
||||||
/**
|
/**
|
||||||
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります
|
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -422,6 +422,8 @@ antennaSource: "受信ソース"
|
||||||
antennaKeywords: "受信キーワード"
|
antennaKeywords: "受信キーワード"
|
||||||
antennaExcludeKeywords: "除外キーワード"
|
antennaExcludeKeywords: "除外キーワード"
|
||||||
antennaExcludeBots: "Botアカウントを除外"
|
antennaExcludeBots: "Botアカウントを除外"
|
||||||
|
antennaUseRegex: "正規表現を使用する"
|
||||||
|
antennaUseRegexError: "{src}の{line}行目にエラーがあります。"
|
||||||
antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
|
antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
|
||||||
notifyAntenna: "新しいノートを通知する"
|
notifyAntenna: "新しいノートを通知する"
|
||||||
withFileAntenna: "ファイルが添付されたノートのみ"
|
withFileAntenna: "ファイルが添付されたノートのみ"
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AntennaRegexSupport1740781670204 {
|
||||||
|
name = 'AntennaRegexSupport1740781670204'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "antenna" ADD "useRegex" boolean NOT NULL DEFAULT false`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "useRegex"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,26 +5,161 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
|
import RE2 from 're2';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import * as Acct from '@/misc/acct.js';
|
import * as Acct from '@/misc/acct.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
import type { AntennasRepository } from '@/models/_.js';
|
||||||
import type { MiAntenna } from '@/models/Antenna.js';
|
import { AntennaSource, MiAntenna } from '@/models/Antenna.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import { CacheService } from './CacheService.js';
|
import { RoleService } from './RoleService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 createRE2 = (pattern: string): RE2 => {
|
||||||
|
const regexp = pattern.match(/^\/(.+)\/(.*)$/) ?? [];
|
||||||
|
return new RE2(regexp[1], regexp[2]);
|
||||||
|
};
|
||||||
|
const keywordsPatterns = keywords.map(line => createRE2(line[0]));
|
||||||
|
const excludeKeywordsPatterns = excludeKeywords.map(line => createRE2(line[0]));
|
||||||
|
|
||||||
|
if (keywords.length > 0 && excludeKeywords.length > 0) {
|
||||||
|
return (target: string) => antennaFilters.regex.includeAndExclude(target, keywordsPatterns, excludeKeywordsPatterns);
|
||||||
|
} else if (keywords.length > 0) {
|
||||||
|
return (target: string) => antennaFilters.regex.includeOnly(target, keywordsPatterns);
|
||||||
|
} else if (excludeKeywords.length > 0) {
|
||||||
|
return (target: string) => antennaFilters.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()
|
@Injectable()
|
||||||
export class AntennaService implements OnApplicationShutdown {
|
export class AntennaService implements OnApplicationShutdown {
|
||||||
private antennasFetched: boolean;
|
public static AntennaNotFoundError = class extends Error {
|
||||||
private antennas: MiAntenna[];
|
};
|
||||||
|
public static EmptyKeyWordError = class extends Error {
|
||||||
|
};
|
||||||
|
public static TooManyAntennasError = class extends Error {
|
||||||
|
};
|
||||||
|
public static InvalidRegexPatternError = class extends Error {
|
||||||
|
constructor(err?: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : undefined;
|
||||||
|
super(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private filtersFetched: boolean;
|
||||||
|
private filters: AntennaFilter[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redisForTimelines)
|
@Inject(DI.redisForTimelines)
|
||||||
|
@ -36,16 +171,14 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
@Inject(DI.antennasRepository)
|
@Inject(DI.antennasRepository)
|
||||||
private antennasRepository: AntennasRepository,
|
private antennasRepository: AntennasRepository,
|
||||||
|
|
||||||
@Inject(DI.userListMembershipsRepository)
|
private roleService: RoleService,
|
||||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
|
||||||
|
|
||||||
private cacheService: CacheService,
|
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private fanoutTimelineService: FanoutTimelineService,
|
private fanoutTimelineService: FanoutTimelineService,
|
||||||
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
this.antennasFetched = false;
|
this.filtersFetched = false;
|
||||||
this.antennas = [];
|
this.filters = [];
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onRedisMessage);
|
this.redisForSub.on('message', this.onRedisMessage);
|
||||||
}
|
}
|
||||||
|
@ -57,37 +190,24 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
if (obj.channel === 'internal') {
|
if (obj.channel === 'internal') {
|
||||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'antennaCreated':
|
case 'antennaCreated': {
|
||||||
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
this.filters.push(createAntennaFilter(MiAntenna.deserialize(body)));
|
||||||
...body,
|
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'antennaUpdated': {
|
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) {
|
if (idx >= 0) {
|
||||||
this.antennas[idx] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
this.filters[idx] = createAntennaFilter(MiAntenna.deserialize(body));
|
||||||
...body,
|
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
|
// サーバ起動時にactiveじゃなかった場合、リストに持っていないので追加する必要あり
|
||||||
this.antennas.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
this.filters.push(createAntennaFilter(MiAntenna.deserialize(body)));
|
||||||
...body,
|
|
||||||
lastUsedAt: new Date(body.lastUsedAt),
|
|
||||||
user: null, // joinなカラムは通常取ってこないので
|
|
||||||
userList: null, // joinなカラムは通常取ってこないので
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
case 'antennaDeleted': {
|
||||||
|
this.filters = this.filters.filter(a => a.antennaId !== body.id);
|
||||||
break;
|
break;
|
||||||
case 'antennaDeleted':
|
}
|
||||||
this.antennas = this.antennas.filter(a => a.id !== body.id);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -96,128 +216,224 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
|
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
|
||||||
const antennas = await this.getAntennas();
|
const filters = await this.getFilters();
|
||||||
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();
|
const redisPipeline = this.redisForTimelines.pipeline();
|
||||||
|
|
||||||
for (const antenna of matchedAntennas) {
|
for (const filter of filters) {
|
||||||
this.fanoutTimelineService.push(`antennaTimeline:${antenna.id}`, note.id, 200, redisPipeline);
|
if (this.checkHitAntenna(filter, note, noteUser)) {
|
||||||
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
|
this.fanoutTimelineService.push(`antennaTimeline:${filter.antennaId}`, note.id, 200, redisPipeline);
|
||||||
|
this.globalEventService.publishAntennaStream(filter.antennaId, 'note', note);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redisPipeline.exec();
|
redisPipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
@bindThis
|
||||||
|
public async create(
|
||||||
|
ps: {
|
||||||
|
name: string;
|
||||||
|
src: AntennaSource;
|
||||||
|
keywords: string[][];
|
||||||
|
excludeKeywords: string[][];
|
||||||
|
users: string[];
|
||||||
|
caseSensitive: boolean;
|
||||||
|
localOnly?: boolean;
|
||||||
|
excludeBots?: boolean;
|
||||||
|
excludeNotesInSensitiveChannel?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
|
withReplies: boolean;
|
||||||
|
withFile: boolean;
|
||||||
|
},
|
||||||
|
me: MiUser,
|
||||||
|
): Promise<MiAntenna> {
|
||||||
|
this.validateEmptyKeyWord(ps);
|
||||||
|
this.validateRegexPattern(ps);
|
||||||
|
|
||||||
|
const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id });
|
||||||
|
if (currentAntennasCount >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
|
||||||
|
throw new AntennaService.TooManyAntennasError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const antenna = await this.antennasRepository.insertOne({
|
||||||
|
id: this.idService.gen(now.getTime()),
|
||||||
|
lastUsedAt: now,
|
||||||
|
userId: me.id,
|
||||||
|
name: ps.name,
|
||||||
|
src: ps.src,
|
||||||
|
keywords: ps.keywords,
|
||||||
|
excludeKeywords: ps.excludeKeywords,
|
||||||
|
users: ps.users,
|
||||||
|
caseSensitive: ps.caseSensitive,
|
||||||
|
localOnly: ps.localOnly,
|
||||||
|
excludeBots: ps.excludeBots,
|
||||||
|
excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
|
||||||
|
useRegex: ps.useRegex,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
withFile: ps.withFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.globalEventService.publishInternalEvent('antennaCreated', antenna);
|
||||||
|
|
||||||
|
return antenna;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
|
public async update(
|
||||||
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
|
ps: {
|
||||||
|
antennaId: string;
|
||||||
|
name?: string;
|
||||||
|
src?: AntennaSource;
|
||||||
|
keywords?: string[][];
|
||||||
|
excludeKeywords?: string[][];
|
||||||
|
users?: string[];
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
localOnly?: boolean;
|
||||||
|
excludeBots?: boolean;
|
||||||
|
excludeNotesInSensitiveChannel?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
|
withReplies?: boolean;
|
||||||
|
withFile?: boolean;
|
||||||
|
},
|
||||||
|
me: MiUser,
|
||||||
|
): Promise<MiAntenna> {
|
||||||
|
this.validateEmptyKeyWord({
|
||||||
|
keywords: ps.keywords ?? [],
|
||||||
|
excludeKeywords: ps.excludeKeywords ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
if (antenna.excludeBots && noteUser.isBot) return false;
|
this.validateRegexPattern({
|
||||||
|
keywords: ps.keywords ?? [],
|
||||||
|
excludeKeywords: ps.excludeKeywords ?? [],
|
||||||
|
useRegex: ps.useRegex,
|
||||||
|
});
|
||||||
|
|
||||||
if (antenna.localOnly && noteUser.host != null) return false;
|
const antenna = await this.antennasRepository.findOneBy({
|
||||||
|
id: ps.antennaId,
|
||||||
|
userId: me.id,
|
||||||
|
});
|
||||||
|
if (antenna == null) {
|
||||||
|
throw new AntennaService.AntennaNotFoundError;
|
||||||
|
}
|
||||||
|
|
||||||
if (!antenna.withReplies && note.replyId != null) return false;
|
await this.antennasRepository.update(antenna.id, {
|
||||||
|
name: ps.name,
|
||||||
|
src: ps.src,
|
||||||
|
keywords: ps.keywords,
|
||||||
|
excludeKeywords: ps.excludeKeywords,
|
||||||
|
users: ps.users,
|
||||||
|
caseSensitive: ps.caseSensitive,
|
||||||
|
localOnly: ps.localOnly,
|
||||||
|
excludeBots: ps.excludeBots,
|
||||||
|
excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
|
||||||
|
useRegex: ps.useRegex,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
withFile: ps.withFile,
|
||||||
|
isActive: true,
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
if (note.visibility === 'specified') {
|
this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id }));
|
||||||
if (note.userId !== antenna.userId) {
|
|
||||||
if (note.visibleUserIds == null) return false;
|
return antenna;
|
||||||
if (!note.visibleUserIds.includes(antenna.userId)) return false;
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private validateEmptyKeyWord(
|
||||||
|
ps: {
|
||||||
|
keywords: string[][];
|
||||||
|
excludeKeywords: string[][];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
|
||||||
|
throw new AntennaService.EmptyKeyWordError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private validateRegexPattern(
|
||||||
|
ps: {
|
||||||
|
keywords: string[][];
|
||||||
|
excludeKeywords: string[][];
|
||||||
|
useRegex?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!ps.useRegex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: 正規表現パターンの場合は2次元配列の2次元目0番地にパターンが格納されていることを前提にする
|
||||||
|
|
||||||
|
if (ps.keywords.length > 0 && ps.keywords.some(x => x.length !== 1)) {
|
||||||
|
throw new AntennaService.InvalidRegexPatternError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.excludeKeywords.length > 0 && ps.excludeKeywords.some(x => x.length !== 1)) {
|
||||||
|
throw new AntennaService.InvalidRegexPatternError();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const keywords of [ps.keywords, ps.excludeKeywords]) {
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
const regexp = keyword[0].match(/^\/(.+)\/(.*)$/);
|
||||||
|
if (!regexp) {
|
||||||
|
throw new AntennaService.InvalidRegexPatternError();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new RE2(regexp[1], regexp[2]);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AntennaService.InvalidRegexPatternError(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (note.visibility === 'followers') {
|
@bindThis
|
||||||
const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
|
private isIncludeUser(antenna: MiAntenna, noteUser: { username: string; host: string | null; }): boolean {
|
||||||
if (!isFollowing && antenna.userId !== note.userId) return false;
|
const antennaUserAccounts = antenna.users.map(x => {
|
||||||
}
|
const { username, host } = Acct.parse(x);
|
||||||
|
return this.utilityService.getFullApAccount(username, host).toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
if (antenna.src === 'home') {
|
const noteUserAccount = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
|
||||||
// TODO
|
return antennaUserAccounts.includes(noteUserAccount);
|
||||||
} 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;
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public checkHitAntenna(
|
||||||
|
filter: AntennaFilter,
|
||||||
|
note: (MiNote | Packed<'Note'>),
|
||||||
|
noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; },
|
||||||
|
): boolean {
|
||||||
|
const antenna = filter.src;
|
||||||
|
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) 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.withFile) {
|
if (antenna.withFile) {
|
||||||
if (note.fileIds && note.fileIds.length === 0) return false;
|
if (note.fileIds && note.fileIds.length === 0) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: eval expression
|
if (antenna.src === 'users') {
|
||||||
|
if (!this.isIncludeUser(antenna, noteUser)) return false;
|
||||||
|
} else if (antenna.src === 'users_blacklist') {
|
||||||
|
if (this.isIncludeUser(antenna, noteUser)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
|
||||||
|
return filter.testKeywords(_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getAntennas() {
|
private async getFilters() {
|
||||||
if (!this.antennasFetched) {
|
if (!this.filtersFetched) {
|
||||||
this.antennas = await this.antennasRepository.findBy({
|
const antennas = await this.antennasRepository.findBy({ isActive: true });
|
||||||
isActive: true,
|
this.filters = antennas.map(createAntennaFilter);
|
||||||
});
|
this.filtersFetched = true;
|
||||||
this.antennasFetched = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.antennas;
|
return this.filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
@ -226,7 +442,7 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
|
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
|
||||||
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
|
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
|
||||||
const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
|
const antennasToMigrate = (await this.getFilters()).map(it => it.src).filter(antenna => {
|
||||||
return antenna.users.some(user => {
|
return antenna.users.some(user => {
|
||||||
const { username, host } = Acct.parse(user);
|
const { username, host } = Acct.parse(user);
|
||||||
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
|
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
|
||||||
|
|
|
@ -39,6 +39,7 @@ export class AntennaEntityService {
|
||||||
caseSensitive: antenna.caseSensitive,
|
caseSensitive: antenna.caseSensitive,
|
||||||
localOnly: antenna.localOnly,
|
localOnly: antenna.localOnly,
|
||||||
excludeBots: antenna.excludeBots,
|
excludeBots: antenna.excludeBots,
|
||||||
|
useRegex: antenna.useRegex,
|
||||||
withReplies: antenna.withReplies,
|
withReplies: antenna.withReplies,
|
||||||
withFile: antenna.withFile,
|
withFile: antenna.withFile,
|
||||||
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
|
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
|
||||||
|
|
|
@ -3,10 +3,20 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||||
import { id } from './util/id.js';
|
import { Serialized } from '@/types.js';
|
||||||
import { MiUser } from './User.js';
|
import { MiUser } from './User.js';
|
||||||
import { MiUserList } from './UserList.js';
|
import { MiUserList } from './UserList.js';
|
||||||
|
import { id } from './util/id.js';
|
||||||
|
|
||||||
|
export const antennaSources = [
|
||||||
|
'all',
|
||||||
|
'users',
|
||||||
|
'users_blacklist',
|
||||||
|
// 'home', // TODO
|
||||||
|
// 'list', // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||||
|
] as const;
|
||||||
|
export type AntennaSource = typeof antennaSources[number];
|
||||||
|
|
||||||
@Entity('antenna')
|
@Entity('antenna')
|
||||||
export class MiAntenna {
|
export class MiAntenna {
|
||||||
|
@ -36,8 +46,8 @@ export class MiAntenna {
|
||||||
})
|
})
|
||||||
public name: string;
|
public name: string;
|
||||||
|
|
||||||
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'users_blacklist'] })
|
@Column('enum', { enum: antennaSources })
|
||||||
public src: 'home' | 'all' | 'users' | 'list' | 'users_blacklist';
|
public src: AntennaSource;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
...id(),
|
...id(),
|
||||||
|
@ -105,4 +115,20 @@ export class MiAntenna {
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
public excludeNotesInSensitiveChannel: boolean;
|
public excludeNotesInSensitiveChannel: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { antennaSources } from '@/models/Antenna.js';
|
||||||
|
|
||||||
export const packedAntennaSchema = {
|
export const packedAntennaSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -47,7 +49,7 @@ export const packedAntennaSchema = {
|
||||||
src: {
|
src: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
enum: ['home', 'all', 'users', 'list', 'users_blacklist'],
|
enum: antennaSources,
|
||||||
},
|
},
|
||||||
userListId: {
|
userListId: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@ -72,6 +74,10 @@ export const packedAntennaSchema = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
useRegex: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
excludeBots: {
|
excludeBots: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
|
|
@ -3,14 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { AntennaService } from '@/core/AntennaService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
|
||||||
import type { UserListsRepository, AntennasRepository } from '@/models/_.js';
|
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|
||||||
import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js';
|
import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { antennaSources } from '@/models/Antenna.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -40,6 +37,13 @@ export const meta = {
|
||||||
code: 'EMPTY_KEYWORD',
|
code: 'EMPTY_KEYWORD',
|
||||||
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
|
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invalidRegexPattern: {
|
||||||
|
message: 'Invalid regex pattern.',
|
||||||
|
code: 'INVALID_REGEX_PATTERN',
|
||||||
|
id: 'b06d08f4-6434-5faa-0fdd-a2aaf85e9de7',
|
||||||
|
httpStatusCode: 400,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
@ -53,93 +57,66 @@ export const paramDef = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] },
|
src: { type: 'string', enum: antennaSources },
|
||||||
userListId: { type: 'string', format: 'misskey:id', nullable: true },
|
keywords: {
|
||||||
keywords: { type: 'array', items: {
|
type: 'array',
|
||||||
type: 'array', items: {
|
items: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
} },
|
},
|
||||||
excludeKeywords: { type: 'array', items: {
|
excludeKeywords: {
|
||||||
type: 'array', items: {
|
type: 'array',
|
||||||
type: 'string',
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
} },
|
},
|
||||||
users: { type: 'array', items: {
|
users: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
} },
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
caseSensitive: { type: 'boolean' },
|
caseSensitive: { type: 'boolean' },
|
||||||
localOnly: { type: 'boolean' },
|
localOnly: { type: 'boolean' },
|
||||||
excludeBots: { type: 'boolean' },
|
excludeBots: { type: 'boolean' },
|
||||||
|
useRegex: { type: 'boolean' },
|
||||||
withReplies: { type: 'boolean' },
|
withReplies: { type: 'boolean' },
|
||||||
withFile: { type: 'boolean' },
|
withFile: { type: 'boolean' },
|
||||||
excludeNotesInSensitiveChannel: { type: 'boolean' },
|
excludeNotesInSensitiveChannel: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
|
required: [
|
||||||
|
'name',
|
||||||
|
'src',
|
||||||
|
'keywords',
|
||||||
|
'excludeKeywords',
|
||||||
|
'users',
|
||||||
|
'caseSensitive',
|
||||||
|
'withReplies',
|
||||||
|
'withFile',
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.antennasRepository)
|
private readonly antennaEntityService: AntennaEntityService,
|
||||||
private antennasRepository: AntennasRepository,
|
private readonly antennaService: AntennaService,
|
||||||
|
|
||||||
@Inject(DI.userListsRepository)
|
|
||||||
private userListsRepository: UserListsRepository,
|
|
||||||
|
|
||||||
private antennaEntityService: AntennaEntityService,
|
|
||||||
private roleService: RoleService,
|
|
||||||
private idService: IdService,
|
|
||||||
private globalEventService: GlobalEventService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
|
try {
|
||||||
throw new ApiError(meta.errors.emptyKeyword);
|
const antenna = await this.antennaService.create(ps, me);
|
||||||
}
|
return this.antennaEntityService.pack(antenna);
|
||||||
|
} catch (e) {
|
||||||
const currentAntennasCount = await this.antennasRepository.countBy({
|
if (e instanceof AntennaService.EmptyKeyWordError) {
|
||||||
userId: me.id,
|
throw new ApiError(meta.errors.emptyKeyword);
|
||||||
});
|
} else if (e instanceof AntennaService.TooManyAntennasError) {
|
||||||
if (currentAntennasCount >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
|
throw new ApiError(meta.errors.tooManyAntennas);
|
||||||
throw new ApiError(meta.errors.tooManyAntennas);
|
} else if (e instanceof AntennaService.InvalidRegexPatternError) {
|
||||||
}
|
throw new ApiError(meta.errors.invalidRegexPattern);
|
||||||
|
} else {
|
||||||
let userList;
|
throw e;
|
||||||
|
|
||||||
if (ps.src === 'list' && ps.userListId) {
|
|
||||||
userList = await this.userListsRepository.findOneBy({
|
|
||||||
id: ps.userListId,
|
|
||||||
userId: me.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userList == null) {
|
|
||||||
throw new ApiError(meta.errors.noSuchUserList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const antenna = await this.antennasRepository.insertOne({
|
|
||||||
id: this.idService.gen(now.getTime()),
|
|
||||||
lastUsedAt: now,
|
|
||||||
userId: me.id,
|
|
||||||
name: ps.name,
|
|
||||||
src: ps.src,
|
|
||||||
userListId: userList ? userList.id : null,
|
|
||||||
keywords: ps.keywords,
|
|
||||||
excludeKeywords: ps.excludeKeywords,
|
|
||||||
users: ps.users,
|
|
||||||
caseSensitive: ps.caseSensitive,
|
|
||||||
localOnly: ps.localOnly,
|
|
||||||
excludeBots: ps.excludeBots,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
withFile: ps.withFile,
|
|
||||||
excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('antennaCreated', antenna);
|
|
||||||
|
|
||||||
return await this.antennaEntityService.pack(antenna);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { AntennaService } from '@/core/AntennaService.js';
|
||||||
|
import { antennaSources } from '@/models/Antenna.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { AntennasRepository, UserListsRepository } from '@/models/_.js';
|
import type { AntennasRepository, UserListsRepository } from '@/models/_.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
@ -38,6 +40,13 @@ export const meta = {
|
||||||
code: 'EMPTY_KEYWORD',
|
code: 'EMPTY_KEYWORD',
|
||||||
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
|
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invalidRegexPattern: {
|
||||||
|
message: 'Invalid regex pattern.',
|
||||||
|
code: 'INVALID_REGEX_PATTERN',
|
||||||
|
id: 'dbb44ec3-5d15-508d-e6b2-71f3794c6a41',
|
||||||
|
httpStatusCode: 400,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
@ -52,24 +61,29 @@ export const paramDef = {
|
||||||
properties: {
|
properties: {
|
||||||
antennaId: { type: 'string', format: 'misskey:id' },
|
antennaId: { type: 'string', format: 'misskey:id' },
|
||||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||||
src: { type: 'string', enum: ['home', 'all', 'users', 'list', 'users_blacklist'] },
|
src: { type: 'string', enum: antennaSources },
|
||||||
userListId: { type: 'string', format: 'misskey:id', nullable: true },
|
keywords: {
|
||||||
keywords: { type: 'array', items: {
|
type: 'array',
|
||||||
type: 'array', items: {
|
items: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
} },
|
},
|
||||||
excludeKeywords: { type: 'array', items: {
|
excludeKeywords: {
|
||||||
type: 'array', items: {
|
type: 'array',
|
||||||
type: 'string',
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
},
|
},
|
||||||
} },
|
},
|
||||||
users: { type: 'array', items: {
|
users: {
|
||||||
type: 'string',
|
type: 'array',
|
||||||
} },
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
caseSensitive: { type: 'boolean' },
|
caseSensitive: { type: 'boolean' },
|
||||||
localOnly: { type: 'boolean' },
|
localOnly: { type: 'boolean' },
|
||||||
excludeBots: { type: 'boolean' },
|
excludeBots: { type: 'boolean' },
|
||||||
|
useRegex: { type: 'boolean' },
|
||||||
withReplies: { type: 'boolean' },
|
withReplies: { type: 'boolean' },
|
||||||
withFile: { type: 'boolean' },
|
withFile: { type: 'boolean' },
|
||||||
excludeNotesInSensitiveChannel: { type: 'boolean' },
|
excludeNotesInSensitiveChannel: { type: 'boolean' },
|
||||||
|
@ -80,64 +94,24 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.antennasRepository)
|
private readonly antennaEntityService: AntennaEntityService,
|
||||||
private antennasRepository: AntennasRepository,
|
private readonly antennaService: AntennaService,
|
||||||
|
|
||||||
@Inject(DI.userListsRepository)
|
|
||||||
private userListsRepository: UserListsRepository,
|
|
||||||
|
|
||||||
private antennaEntityService: AntennaEntityService,
|
|
||||||
private globalEventService: GlobalEventService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
if (ps.keywords && ps.excludeKeywords) {
|
try {
|
||||||
if (ps.keywords.flat().every(x => x === '') && ps.excludeKeywords.flat().every(x => x === '')) {
|
const antenna = await this.antennaService.update(ps, me);
|
||||||
|
return this.antennaEntityService.pack(antenna);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof AntennaService.EmptyKeyWordError) {
|
||||||
throw new ApiError(meta.errors.emptyKeyword);
|
throw new ApiError(meta.errors.emptyKeyword);
|
||||||
|
} else if (e instanceof AntennaService.AntennaNotFoundError) {
|
||||||
|
throw new ApiError(meta.errors.noSuchAntenna);
|
||||||
|
} else if (e instanceof AntennaService.InvalidRegexPatternError) {
|
||||||
|
throw new ApiError(meta.errors.invalidRegexPattern);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fetch the antenna
|
|
||||||
const antenna = await this.antennasRepository.findOneBy({
|
|
||||||
id: ps.antennaId,
|
|
||||||
userId: me.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (antenna == null) {
|
|
||||||
throw new ApiError(meta.errors.noSuchAntenna);
|
|
||||||
}
|
|
||||||
|
|
||||||
let userList;
|
|
||||||
|
|
||||||
if ((ps.src === 'list' || antenna.src === 'list') && ps.userListId) {
|
|
||||||
userList = await this.userListsRepository.findOneBy({
|
|
||||||
id: ps.userListId,
|
|
||||||
userId: me.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userList == null) {
|
|
||||||
throw new ApiError(meta.errors.noSuchUserList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.antennasRepository.update(antenna.id, {
|
|
||||||
name: ps.name,
|
|
||||||
src: ps.src,
|
|
||||||
userListId: ps.userListId !== undefined ? userList ? userList.id : null : undefined,
|
|
||||||
keywords: ps.keywords,
|
|
||||||
excludeKeywords: ps.excludeKeywords,
|
|
||||||
users: ps.users,
|
|
||||||
caseSensitive: ps.caseSensitive,
|
|
||||||
localOnly: ps.localOnly,
|
|
||||||
excludeBots: ps.excludeBots,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
withFile: ps.withFile,
|
|
||||||
excludeNotesInSensitiveChannel: ps.excludeNotesInSensitiveChannel,
|
|
||||||
isActive: true,
|
|
||||||
lastUsedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('antennaUpdated', await this.antennasRepository.findOneByOrFail({ id: antenna.id }));
|
|
||||||
|
|
||||||
return await this.antennaEntityService.pack(antenna.id);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,21 +13,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSelect v-model="src">
|
<MkSelect v-model="src">
|
||||||
<template #label>{{ i18n.ts.antennaSource }}</template>
|
<template #label>{{ i18n.ts.antennaSource }}</template>
|
||||||
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
|
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
|
||||||
<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
|
|
||||||
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
|
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
|
||||||
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
|
|
||||||
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
|
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
|
||||||
</MkSelect>
|
</MkSelect>
|
||||||
<MkSelect v-if="src === 'list'" v-model="userListId">
|
<MkTextarea v-if="src === 'users' || src === 'users_blacklist'" v-model="users">
|
||||||
<template #label>{{ i18n.ts.userList }}</template>
|
|
||||||
<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
|
|
||||||
</MkSelect>
|
|
||||||
<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
|
|
||||||
<template #label>{{ i18n.ts.users }}</template>
|
<template #label>{{ i18n.ts.users }}</template>
|
||||||
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
|
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
|
<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
|
||||||
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
|
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
|
||||||
|
<MkSwitch v-model="useRegex">{{ i18n.ts.antennaUseRegex }}</MkSwitch>
|
||||||
<MkTextarea v-model="keywords">
|
<MkTextarea v-model="keywords">
|
||||||
<template #label>{{ i18n.ts.antennaKeywords }}</template>
|
<template #label>{{ i18n.ts.antennaKeywords }}</template>
|
||||||
<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
|
<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
|
||||||
|
@ -88,6 +83,7 @@ const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
|
||||||
localOnly: false,
|
localOnly: false,
|
||||||
withFile: false,
|
withFile: false,
|
||||||
excludeNotesInSensitiveChannel: false,
|
excludeNotesInSensitiveChannel: false,
|
||||||
|
useRegex: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasUnreadNote: false,
|
hasUnreadNote: false,
|
||||||
notify: false,
|
notify: false,
|
||||||
|
@ -109,17 +105,51 @@ const caseSensitive = ref<boolean>(initialAntenna.caseSensitive);
|
||||||
const localOnly = ref<boolean>(initialAntenna.localOnly);
|
const localOnly = ref<boolean>(initialAntenna.localOnly);
|
||||||
const excludeBots = ref<boolean>(initialAntenna.excludeBots);
|
const excludeBots = ref<boolean>(initialAntenna.excludeBots);
|
||||||
const withReplies = ref<boolean>(initialAntenna.withReplies);
|
const withReplies = ref<boolean>(initialAntenna.withReplies);
|
||||||
|
const useRegex = ref<boolean>(initialAntenna.useRegex);
|
||||||
const withFile = ref<boolean>(initialAntenna.withFile);
|
const withFile = ref<boolean>(initialAntenna.withFile);
|
||||||
const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel);
|
const excludeNotesInSensitiveChannel = ref<boolean>(initialAntenna.excludeNotesInSensitiveChannel);
|
||||||
const userLists = ref<Misskey.entities.UserList[] | null>(null);
|
|
||||||
|
|
||||||
watch(() => src.value, async () => {
|
|
||||||
if (src.value === 'list' && userLists.value === null) {
|
|
||||||
userLists.value = await misskeyApi('users/lists/list');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function saveAntenna() {
|
async function saveAntenna() {
|
||||||
|
const _keywords: string[][] = [];
|
||||||
|
const _excludeKeywords: string[][] = [];
|
||||||
|
|
||||||
|
if (useRegex.value) {
|
||||||
|
function checkRegExError(words: string[], type: string) {
|
||||||
|
const errLineNumbers = words
|
||||||
|
.map((x, i) => {
|
||||||
|
try {
|
||||||
|
// RE2にしてバックエンドと揃える?
|
||||||
|
new RegExp(x);
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(x => x != null);
|
||||||
|
if (errLineNumbers.length > 0) {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: i18n.tsx.antennaUseRegexError({ src: type, line: errLineNumbers.join(', ') }),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordsDraft = keywords.value.trim().split('\n').map(x => x.trim()).filter(x => x.length > 0);
|
||||||
|
if (!checkRegExError(keywordsDraft, i18n.ts.antennaKeywords)) return;
|
||||||
|
|
||||||
|
const excludeKeywordsDraft = excludeKeywords.value.trim().split('\n').map(x => x.trim()).filter(x => x.length > 0);
|
||||||
|
if (!checkRegExError(excludeKeywordsDraft, i18n.ts.antennaExcludeKeywords)) return;
|
||||||
|
|
||||||
|
_keywords.push(...keywordsDraft.map(x => [x]));
|
||||||
|
_excludeKeywords.push(...excludeKeywordsDraft.map(x => [x]));
|
||||||
|
} else {
|
||||||
|
_keywords.push(...keywords.value.trim().split('\n').map(x => x.trim().split(' ')).filter(x => x.length > 0));
|
||||||
|
_excludeKeywords.push(...excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')).filter(x => x.length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
const antennaData = {
|
const antennaData = {
|
||||||
name: name.value,
|
name: name.value,
|
||||||
src: src.value,
|
src: src.value,
|
||||||
|
@ -130,9 +160,10 @@ async function saveAntenna() {
|
||||||
excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value,
|
excludeNotesInSensitiveChannel: excludeNotesInSensitiveChannel.value,
|
||||||
caseSensitive: caseSensitive.value,
|
caseSensitive: caseSensitive.value,
|
||||||
localOnly: localOnly.value,
|
localOnly: localOnly.value,
|
||||||
users: users.value.trim().split('\n').map(x => x.trim()),
|
useRegex: useRegex.value,
|
||||||
keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')),
|
users: users.value.trim().split('\n').map(x => x.trim()).filter(x => x.length > 0),
|
||||||
excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
|
keywords: _keywords,
|
||||||
|
excludeKeywords: _excludeKeywords,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initialAntenna.id == null) {
|
if (initialAntenna.id == null) {
|
||||||
|
|
|
@ -4934,7 +4934,7 @@ export type components = {
|
||||||
keywords: string[][];
|
keywords: string[][];
|
||||||
excludeKeywords: string[][];
|
excludeKeywords: string[][];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
src: 'home' | 'all' | 'users' | 'list' | 'users_blacklist';
|
src: 'all' | 'users' | 'users_blacklist';
|
||||||
/** Format: id */
|
/** Format: id */
|
||||||
userListId: string | null;
|
userListId: string | null;
|
||||||
users: string[];
|
users: string[];
|
||||||
|
@ -4942,6 +4942,7 @@ export type components = {
|
||||||
caseSensitive: boolean;
|
caseSensitive: boolean;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
localOnly: boolean;
|
localOnly: boolean;
|
||||||
|
useRegex: boolean;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
excludeBots: boolean;
|
excludeBots: boolean;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
|
@ -11711,15 +11712,14 @@ export type operations = {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
name: string;
|
name: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
src: 'home' | 'all' | 'users' | 'list' | 'users_blacklist';
|
src: 'all' | 'users' | 'users_blacklist';
|
||||||
/** Format: misskey:id */
|
|
||||||
userListId?: string | null;
|
|
||||||
keywords: string[][];
|
keywords: string[][];
|
||||||
excludeKeywords: string[][];
|
excludeKeywords: string[][];
|
||||||
users: string[];
|
users: string[];
|
||||||
caseSensitive: boolean;
|
caseSensitive: boolean;
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
excludeBots?: boolean;
|
excludeBots?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
withReplies: boolean;
|
withReplies: boolean;
|
||||||
withFile: boolean;
|
withFile: boolean;
|
||||||
excludeNotesInSensitiveChannel?: boolean;
|
excludeNotesInSensitiveChannel?: boolean;
|
||||||
|
@ -11993,15 +11993,14 @@ export type operations = {
|
||||||
antennaId: string;
|
antennaId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
src?: 'home' | 'all' | 'users' | 'list' | 'users_blacklist';
|
src?: 'all' | 'users' | 'users_blacklist';
|
||||||
/** Format: misskey:id */
|
|
||||||
userListId?: string | null;
|
|
||||||
keywords?: string[][];
|
keywords?: string[][];
|
||||||
excludeKeywords?: string[][];
|
excludeKeywords?: string[][];
|
||||||
users?: string[];
|
users?: string[];
|
||||||
caseSensitive?: boolean;
|
caseSensitive?: boolean;
|
||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
excludeBots?: boolean;
|
excludeBots?: boolean;
|
||||||
|
useRegex?: boolean;
|
||||||
withReplies?: boolean;
|
withReplies?: boolean;
|
||||||
withFile?: boolean;
|
withFile?: boolean;
|
||||||
excludeNotesInSensitiveChannel?: boolean;
|
excludeNotesInSensitiveChannel?: boolean;
|
||||||
|
|
Loading…
Reference in New Issue