summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authorおさむのひと <46447427+samunohito@users.noreply.github.com>2024-09-19 17:20:50 +0900
committerGitHub <noreply@github.com>2024-09-19 17:20:50 +0900
commit4ac8aad50a1a1ef2ac2a13a04baca445294397ed (patch)
tree37a81a7ca6a760dc0be88b61f409c4d24354d3ca /packages/backend/src/core
parentfix(frontend): viteの一時ファイルがgitの変更に含まれないよ... (diff)
downloadsharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.tar.gz
sharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.tar.bz2
sharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.zip
feat: UserWebhook/SystemWebhookのテスト送信機能を追加 (#14489)
* feat: UserWebhook/SystemWebhookのテスト送信機能を追加 * fix CHANGELOG.md * 一部設定をパラメータから上書き出来るように修正 * remove async * regenerate autogen
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/QueueService.ts22
-rw-r--r--packages/backend/src/core/SystemWebhookService.ts13
-rw-r--r--packages/backend/src/core/UserWebhookService.ts29
-rw-r--r--packages/backend/src/core/WebhookTestService.ts434
5 files changed, 492 insertions, 12 deletions
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index c9427bbeb7..674241ac12 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -13,6 +13,7 @@ import {
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
@@ -211,6 +212,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
+const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
@@ -359,6 +361,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
+ WebhookTestService,
UtilityService,
FileInfoService,
SearchService,
@@ -503,6 +506,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,
+ $WebhookTestService,
$UtilityService,
$FileInfoService,
$SearchService,
@@ -648,6 +652,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
+ WebhookTestService,
UtilityService,
FileInfoService,
SearchService,
@@ -791,6 +796,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,
+ $WebhookTestService,
$UtilityService,
$FileInfoService,
$SearchService,
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 80827a500b..ddb90a051f 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -452,10 +452,15 @@ export class QueueService {
/**
* @see UserWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see UserWebhookDeliverProcessorService
*/
@bindThis
- public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
+ public userWebhookDeliver(
+ webhook: MiWebhook,
+ type: typeof webhookEventTypes[number],
+ content: unknown,
+ opts?: { attempts?: number },
+ ) {
const data: UserWebhookDeliverJobData = {
type,
content,
@@ -468,7 +473,7 @@ export class QueueService {
};
return this.userWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
@@ -479,10 +484,15 @@ export class QueueService {
/**
* @see SystemWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see SystemWebhookDeliverProcessorService
*/
@bindThis
- public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
+ public systemWebhookDeliver(
+ webhook: MiSystemWebhook,
+ type: SystemWebhookEventType,
+ content: unknown,
+ opts?: { attempts?: number },
+ ) {
const data: SystemWebhookDeliverJobData = {
type,
content,
@@ -494,7 +504,7 @@ export class QueueService {
};
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts
index bc6851f788..bb7c6b8c0e 100644
--- a/packages/backend/src/core/SystemWebhookService.ts
+++ b/packages/backend/src/core/SystemWebhookService.ts
@@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
* SystemWebhook の一覧を取得する.
*/
@bindThis
- public async fetchSystemWebhooks(params?: {
+ public fetchSystemWebhooks(params?: {
ids?: MiSystemWebhook['id'][];
isActive?: MiSystemWebhook['isActive'];
on?: MiSystemWebhook['on'];
@@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown {
/**
* SystemWebhook をWebhook配送キューに追加する
* @see QueueService.systemWebhookDeliver
+ * // TODO: contentの型を厳格化する
*/
@bindThis
- public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) {
+ public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
+ webhook: MiSystemWebhook | MiSystemWebhook['id'],
+ type: T,
+ content: unknown,
+ ) {
const webhookEntity = typeof webhook === 'string'
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
: webhook;
if (!webhookEntity || !webhookEntity.isActive) {
- this.logger.info(`Webhook is not active or not found : ${webhook}`);
+ this.logger.info(`SystemWebhook is not active or not found : ${webhook}`);
return;
}
if (!webhookEntity.on.includes(type)) {
- this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`);
+ this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`);
return;
}
diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts
index e96bfeea95..8a40a53688 100644
--- a/packages/backend/src/core/UserWebhookService.ts
+++ b/packages/backend/src/core/UserWebhookService.ts
@@ -5,8 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import type { WebhooksRepository } from '@/models/_.js';
-import type { MiWebhook } from '@/models/Webhook.js';
+import { type WebhooksRepository } from '@/models/_.js';
+import { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents } from '@/core/GlobalEventService.js';
@@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown {
return this.activeWebhooks;
}
+ /**
+ * UserWebhook の一覧を取得する.
+ */
+ @bindThis
+ public fetchWebhooks(params?: {
+ ids?: MiWebhook['id'][];
+ isActive?: MiWebhook['active'];
+ on?: MiWebhook['on'];
+ }): Promise<MiWebhook[]> {
+ const query = this.webhooksRepository.createQueryBuilder('webhook');
+ if (params) {
+ if (params.ids && params.ids.length > 0) {
+ query.andWhere('webhook.id IN (:...ids)', { ids: params.ids });
+ }
+ if (params.isActive !== undefined) {
+ query.andWhere('webhook.active = :isActive', { isActive: params.isActive });
+ }
+ if (params.on && params.on.length > 0) {
+ query.andWhere(':on <@ webhook.on', { on: params.on });
+ }
+ }
+
+ return query.getMany();
+ }
+
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
new file mode 100644
index 0000000000..0b4e107d21
--- /dev/null
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -0,0 +1,434 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { Packed } from '@/misc/json-schema.js';
+import { type WebhookEventTypes } from '@/models/Webhook.js';
+import { UserWebhookService } from '@/core/UserWebhookService.js';
+import { QueueService } from '@/core/QueueService.js';
+
+const oneDayMillis = 24 * 60 * 60 * 1000;
+
+function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
+ return {
+ id: 'dummy-abuse-report1',
+ targetUserId: 'dummy-target-user',
+ targetUser: null,
+ reporterId: 'dummy-reporter-user',
+ reporter: null,
+ assigneeId: null,
+ assignee: null,
+ resolved: false,
+ forwarded: false,
+ comment: 'This is a dummy report for testing purposes.',
+ targetUserHost: null,
+ reporterHost: null,
+ ...override,
+ };
+}
+
+function generateDummyUser(override?: Partial<MiUser>): MiUser {
+ return {
+ id: 'dummy-user-1',
+ updatedAt: new Date(Date.now() - oneDayMillis * 7),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
+ lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
+ hideOnlineStatus: false,
+ username: 'dummy1',
+ usernameLower: 'dummy1',
+ name: 'DummyUser1',
+ followersCount: 10,
+ followingCount: 5,
+ movedToUri: null,
+ movedAt: null,
+ alsoKnownAs: null,
+ notesCount: 30,
+ avatarId: null,
+ avatar: null,
+ bannerId: null,
+ banner: null,
+ avatarUrl: null,
+ bannerUrl: null,
+ avatarBlurhash: null,
+ bannerBlurhash: null,
+ avatarDecorations: [],
+ tags: [],
+ isSuspended: false,
+ isLocked: false,
+ isBot: false,
+ isCat: true,
+ isRoot: false,
+ isExplorable: true,
+ isHibernated: false,
+ isDeleted: false,
+ emojis: [],
+ host: null,
+ inbox: null,
+ sharedInbox: null,
+ featured: null,
+ uri: null,
+ followersUri: null,
+ token: null,
+ ...override,
+ };
+}
+
+function generateDummyNote(override?: Partial<MiNote>): MiNote {
+ return {
+ id: 'dummy-note-1',
+ replyId: null,
+ reply: null,
+ renoteId: null,
+ renote: null,
+ threadId: null,
+ text: 'This is a dummy note for testing purposes.',
+ name: null,
+ cw: null,
+ userId: 'dummy-user-1',
+ user: null,
+ localOnly: true,
+ reactionAcceptance: 'likeOnly',
+ renoteCount: 10,
+ repliesCount: 5,
+ clippedCount: 0,
+ reactions: {},
+ visibility: 'public',
+ uri: null,
+ url: null,
+ fileIds: [],
+ attachedFileTypes: [],
+ visibleUserIds: [],
+ mentions: [],
+ mentionedRemoteUsers: '[]',
+ reactionAndUserPairCache: [],
+ emojis: [],
+ tags: [],
+ hasPoll: false,
+ channelId: null,
+ channel: null,
+ userHost: null,
+ replyUserId: null,
+ replyUserHost: null,
+ renoteUserId: null,
+ renoteUserHost: null,
+ ...override,
+ };
+}
+
+function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
+ return {
+ id: note.id,
+ createdAt: new Date().toISOString(),
+ deletedAt: null,
+ text: note.text,
+ cw: note.cw,
+ userId: note.userId,
+ user: toPackedUserLite(note.user ?? generateDummyUser()),
+ replyId: note.replyId,
+ renoteId: note.renoteId,
+ isHidden: false,
+ visibility: note.visibility,
+ mentions: note.mentions,
+ visibleUserIds: note.visibleUserIds,
+ fileIds: note.fileIds,
+ files: [],
+ tags: note.tags,
+ poll: null,
+ emojis: note.emojis,
+ channelId: note.channelId,
+ channel: note.channel,
+ localOnly: note.localOnly,
+ reactionAcceptance: note.reactionAcceptance,
+ reactionEmojis: {},
+ reactions: {},
+ reactionCount: 0,
+ renoteCount: note.renoteCount,
+ repliesCount: note.repliesCount,
+ uri: note.uri ?? undefined,
+ url: note.url ?? undefined,
+ reactionAndUserPairCache: note.reactionAndUserPairCache,
+ ...(detail ? {
+ clippedCount: note.clippedCount,
+ reply: note.reply ? toPackedNote(note.reply, false) : null,
+ renote: note.renote ? toPackedNote(note.renote, true) : null,
+ myReaction: null,
+ } : {}),
+ ...override,
+ };
+}
+
+function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
+ return {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ host: user.host,
+ avatarUrl: user.avatarUrl,
+ avatarBlurhash: user.avatarBlurhash,
+ avatarDecorations: user.avatarDecorations.map(it => ({
+ id: it.id,
+ angle: it.angle,
+ flipH: it.flipH,
+ url: 'https://example.com/dummy-image001.png',
+ offsetX: it.offsetX,
+ offsetY: it.offsetY,
+ })),
+ isBot: user.isBot,
+ isCat: user.isCat,
+ emojis: user.emojis,
+ onlineStatus: 'active',
+ badgeRoles: [],
+ ...override,
+ };
+}
+
+function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
+ return {
+ ...toPackedUserLite(user),
+ url: null,
+ uri: null,
+ movedTo: null,
+ alsoKnownAs: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: user.updatedAt?.toISOString() ?? null,
+ lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
+ bannerUrl: user.bannerUrl,
+ bannerBlurhash: user.bannerBlurhash,
+ isLocked: user.isLocked,
+ isSilenced: false,
+ isSuspended: user.isSuspended,
+ description: null,
+ location: null,
+ birthday: null,
+ lang: null,
+ fields: [],
+ verifiedLinks: [],
+ followersCount: user.followersCount,
+ followingCount: user.followingCount,
+ notesCount: user.notesCount,
+ pinnedNoteIds: [],
+ pinnedNotes: [],
+ pinnedPageId: null,
+ pinnedPage: null,
+ publicReactions: true,
+ followersVisibility: 'public',
+ followingVisibility: 'public',
+ twoFactorEnabled: false,
+ usePasswordLessLogin: false,
+ securityKeys: false,
+ roles: [],
+ memo: null,
+ moderationNote: undefined,
+ isFollowing: false,
+ isFollowed: false,
+ hasPendingFollowRequestFromYou: false,
+ hasPendingFollowRequestToYou: false,
+ isBlocking: false,
+ isBlocked: false,
+ isMuted: false,
+ isRenoteMuted: false,
+ notify: 'none',
+ withReplies: true,
+ ...override,
+ };
+}
+
+const dummyUser1 = generateDummyUser();
+const dummyUser2 = generateDummyUser({
+ id: 'dummy-user-2',
+ updatedAt: new Date(Date.now() - oneDayMillis * 30),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis),
+ lastActiveDate: new Date(Date.now() - oneDayMillis),
+ username: 'dummy2',
+ usernameLower: 'dummy2',
+ name: 'DummyUser2',
+ followersCount: 40,
+ followingCount: 50,
+ notesCount: 900,
+});
+const dummyUser3 = generateDummyUser({
+ id: 'dummy-user-3',
+ updatedAt: new Date(Date.now() - oneDayMillis * 15),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
+ lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
+ username: 'dummy3',
+ usernameLower: 'dummy3',
+ name: 'DummyUser3',
+ followersCount: 60,
+ followingCount: 70,
+ notesCount: 15900,
+});
+
+@Injectable()
+export class WebhookTestService {
+ public static NoSuchWebhookError = class extends Error {};
+
+ constructor(
+ private userWebhookService: UserWebhookService,
+ private systemWebhookService: SystemWebhookService,
+ private queueService: QueueService,
+ ) {
+ }
+
+ /**
+ * UserWebhookのテスト送信を行う.
+ * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
+ *
+ * また、この関数経由で送信されるWebhookは以下の設定を無視する.
+ * - Webhookそのものの有効・無効設定(active)
+ * - 送信対象イベント(on)に関する設定
+ */
+ @bindThis
+ public async testUserWebhook(
+ params: {
+ webhookId: MiWebhook['id'],
+ type: WebhookEventTypes,
+ override?: Partial<Omit<MiWebhook, 'id'>>,
+ },
+ sender: MiUser | null,
+ ) {
+ const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
+ .then(it => it.filter(it => it.userId === sender?.id));
+ if (webhooks.length === 0) {
+ throw new WebhookTestService.NoSuchWebhookError();
+ }
+
+ const webhook = webhooks[0];
+ const send = (contents: unknown) => {
+ const merged = {
+ ...webhook,
+ ...params.override,
+ };
+
+ // テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
+ // また、Jobの試行回数も1回だけ.
+ this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
+ };
+
+ const dummyNote1 = generateDummyNote({
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ });
+ const dummyReply1 = generateDummyNote({
+ id: 'dummy-reply-1',
+ replyId: dummyNote1.id,
+ reply: dummyNote1,
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ });
+ const dummyRenote1 = generateDummyNote({
+ id: 'dummy-renote-1',
+ renoteId: dummyNote1.id,
+ renote: dummyNote1,
+ userId: dummyUser2.id,
+ user: dummyUser2,
+ text: null,
+ });
+ const dummyMention1 = generateDummyNote({
+ id: 'dummy-mention-1',
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ text: `@${dummyUser2.username} This is a mention to you.`,
+ mentions: [dummyUser2.id],
+ });
+
+ switch (params.type) {
+ case 'note': {
+ send(toPackedNote(dummyNote1));
+ break;
+ }
+ case 'reply': {
+ send(toPackedNote(dummyReply1));
+ break;
+ }
+ case 'renote': {
+ send(toPackedNote(dummyRenote1));
+ break;
+ }
+ case 'mention': {
+ send(toPackedNote(dummyMention1));
+ break;
+ }
+ case 'follow': {
+ send(toPackedUserDetailedNotMe(dummyUser1));
+ break;
+ }
+ case 'followed': {
+ send(toPackedUserLite(dummyUser2));
+ break;
+ }
+ case 'unfollow': {
+ send(toPackedUserDetailedNotMe(dummyUser3));
+ break;
+ }
+ }
+ }
+
+ /**
+ * SystemWebhookのテスト送信を行う.
+ * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
+ *
+ * また、この関数経由で送信されるWebhookは以下の設定を無視する.
+ * - Webhookそのものの有効・無効設定(isActive)
+ * - 送信対象イベント(on)に関する設定
+ */
+ @bindThis
+ public async testSystemWebhook(
+ params: {
+ webhookId: MiSystemWebhook['id'],
+ type: SystemWebhookEventType,
+ override?: Partial<Omit<MiSystemWebhook, 'id'>>,
+ },
+ ) {
+ const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
+ if (webhooks.length === 0) {
+ throw new WebhookTestService.NoSuchWebhookError();
+ }
+
+ const webhook = webhooks[0];
+ const send = (contents: unknown) => {
+ const merged = {
+ ...webhook,
+ ...params.override,
+ };
+
+ // テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
+ // また、Jobの試行回数も1回だけ.
+ this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
+ };
+
+ switch (params.type) {
+ case 'abuseReport': {
+ send(generateAbuseReport({
+ targetUserId: dummyUser1.id,
+ targetUser: dummyUser1,
+ reporterId: dummyUser2.id,
+ reporter: dummyUser2,
+ }));
+ break;
+ }
+ case 'abuseReportResolved': {
+ send(generateAbuseReport({
+ targetUserId: dummyUser1.id,
+ targetUser: dummyUser1,
+ reporterId: dummyUser2.id,
+ reporter: dummyUser2,
+ assigneeId: dummyUser3.id,
+ assignee: dummyUser3,
+ resolved: true,
+ }));
+ break;
+ }
+ case 'userCreated': {
+ send(toPackedUserLite(dummyUser1));
+ break;
+ }
+ }
+ }
+}