From 4ac8aad50a1a1ef2ac2a13a04baca445294397ed Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:20:50 +0900 Subject: feat: UserWebhook/SystemWebhookのテスト送信機能を追加 (#14489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: UserWebhook/SystemWebhookのテスト送信機能を追加 * fix CHANGELOG.md * 一部設定をパラメータから上書き出来るように修正 * remove async * regenerate autogen --- packages/backend/src/core/WebhookTestService.ts | 434 ++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 packages/backend/src/core/WebhookTestService.ts (limited to 'packages/backend/src/core/WebhookTestService.ts') 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 { + 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 { + 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 { + 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>, + }, + 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>, + }, + ) { + 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; + } + } + } +} -- cgit v1.2.3-freya From afbba1ff1c569bb3c1b25448c1e92235d0b3d626 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sat, 28 Sep 2024 16:45:57 +0900 Subject: Update WebhookTestService.ts --- packages/backend/src/core/WebhookTestService.ts | 1 + 1 file changed, 1 insertion(+) (limited to 'packages/backend/src/core/WebhookTestService.ts') diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 0b4e107d21..c2764f30e8 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -68,6 +68,7 @@ function generateDummyUser(override?: Partial): MiUser { isHibernated: false, isDeleted: false, emojis: [], + score: 0, host: null, inbox: null, sharedInbox: null, -- cgit v1.2.3-freya From fb9b6b120839b132def959662ff9248ecdd07d87 Mon Sep 17 00:00:00 2001 From: dakkar Date: Fri, 11 Oct 2024 12:20:08 +0100 Subject: thank you linters --- packages/backend/src/core/NoteCreateService.ts | 2 +- packages/backend/src/core/NoteEditService.ts | 2 +- packages/backend/src/core/WebhookTestService.ts | 19 +++++++++++++++++++ .../src/core/entities/NotificationEntityService.ts | 4 ++-- packages/backend/src/models/json-schema/user.ts | 4 ++++ .../src/server/api/endpoints/notes/search-by-tag.ts | 6 ++++-- .../src/server/api/stream/channels/bubble-timeline.ts | 9 ++++----- .../backend/src/server/web/ClientServerService.ts | 2 +- 8 files changed, 36 insertions(+), 12 deletions(-) (limited to 'packages/backend/src/core/WebhookTestService.ts') diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 17325d62b5..29f7dd917d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -576,7 +576,7 @@ export class NoteCreateService implements OnApplicationShutdown { // ハッシュタグ更新 if (data.visibility === 'public' || data.visibility === 'home') { - if (user.isBot && meta.enableBotTrending) { + if (user.isBot && this.meta.enableBotTrending) { this.hashtagService.updateHashtags(user, tags); } else if (!user.isBot) { this.hashtagService.updateHashtags(user, tags); diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 1f861fd868..48c68777e3 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; +import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index c2764f30e8..a41f41c44d 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -53,16 +53,22 @@ function generateDummyUser(override?: Partial): MiUser { avatar: null, bannerId: null, banner: null, + backgroundId: null, + background: null, avatarUrl: null, bannerUrl: null, + backgroundUrl: null, avatarBlurhash: null, bannerBlurhash: null, + backgroundBlurhash: null, avatarDecorations: [], tags: [], isSuspended: false, isLocked: false, + isSilenced: false, isBot: false, isCat: true, + speakAsCat: true, isRoot: false, isExplorable: true, isHibernated: false, @@ -76,6 +82,9 @@ function generateDummyUser(override?: Partial): MiUser { uri: null, followersUri: null, token: null, + approved: true, + signupReason: null, + noindex: false, ...override, }; } @@ -118,6 +127,7 @@ function generateDummyNote(override?: Partial): MiNote { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + updatedAt: null, ...override, }; } @@ -182,9 +192,15 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<' })), isBot: user.isBot, isCat: user.isCat, + speakAsCat: user.speakAsCat, emojis: user.emojis, onlineStatus: 'active', badgeRoles: [], + noindex: user.noindex, + isModerator: false, + isAdmin: false, + isSystem: false, + isSilenced: user.isSilenced, ...override, }; } @@ -201,6 +217,8 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, bannerUrl: user.bannerUrl, bannerBlurhash: user.bannerBlurhash, + backgroundUrl: user.backgroundUrl, + backgroundBlurhash: user.backgroundBlurhash, isLocked: user.isLocked, isSilenced: false, isSuspended: user.isSuspended, @@ -236,6 +254,7 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed isRenoteMuted: false, notify: 'none', withReplies: true, + ListenBrainz: null, ...override, }; } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 8bfa2cc623..bbaf0cb7c8 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, @@ -236,7 +236,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index fa93b019bd..9ed9ef828e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -142,6 +142,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: true, }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, instance: { type: 'object', nullable: false, optional: true, diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index e01b09fb57..f079ab608d 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -155,8 +155,10 @@ export default class extends Endpoint { // eslint- notes = notes.filter(note => { if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; if (note.user?.isSuspended) return false; - if (this.utilityService.isFederationAllowedHost(note.userHost)) return false; - if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; + if (note.userHost) { + if (this.utilityService.isFederationAllowedHost(note.userHost)) return false; + if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false; + } return true; }); diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 48c8535b83..8693f0c6ac 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -5,7 +5,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { MiMeta } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -23,11 +22,10 @@ class BubbleTimelineChannel extends Channel { private withRenotes: boolean; private withFiles: boolean; private withBots: boolean; + private instance: MiMeta; constructor( - @Inject(DI.meta) - private serverSettings: MiMeta, - + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, @@ -46,6 +44,7 @@ class BubbleTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); this.withBots = !!(params.withBots ?? true); + this.instance = await this.metaService.fetch(); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -56,7 +55,7 @@ class BubbleTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; - if (!(note.user.host != null && this.serverSettings.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; + if (!(note.user.host != null && this.instance.bubbleInstances.includes(note.user.host) && note.visibility === 'public' )) return; if (note.channelId != null) return; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 24ab8b23df..4b760db89d 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -160,7 +160,7 @@ export class ClientServerService { }, { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', + 'src': this.meta.app512IconUrl || '/static-assets/icons/512.png', 'sizes': '300x300', 'type': 'image/png', 'purpose': 'any', -- cgit v1.2.3-freya