diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 37028026cc..bbdd86b7d0 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -35,6 +35,7 @@ import type { } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; +import type { Packed } from '@/misc/json-schema.js'; @Injectable() export class QueueService { @@ -471,7 +472,7 @@ export class QueueService { public userWebhookDeliver( webhook: MiWebhook, type: typeof webhookEventTypes[number], - content: unknown, + content: Packed<'UserWebhookBody'>, opts?: { attempts?: number }, ) { const data: UserWebhookDeliverJobData = { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 6f9fe53937..936f26a0b5 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -30,6 +30,9 @@ import { trackPromise } from '@/misc/promise-tracker.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; +import { UserWebhookService } from './UserWebhookService.js'; +import { QueueService } from './QueueService.js'; +import { Packed } from '@/misc/json-schema.js'; const FALLBACK = '\u2764'; @@ -94,6 +97,8 @@ export class ReactionService { private reactionsBufferingService: ReactionsBufferingService, private idService: IdService, private featuredService: FeaturedService, + private queueService: QueueService, + private webhookService: UserWebhookService, private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, @@ -254,12 +259,33 @@ export class ReactionService { userId: user.id, }); - // リアクションされたユーザーがローカルユーザーなら通知を作成 + // リアクションされたユーザーがローカルユーザーなら通知を作成してWebhookを送信 if (note.userHost === null) { this.notificationService.createNotification(note.userId, 'reaction', { noteId: note.id, reaction: reaction, }, user.id); + + this.webhookService.getActiveWebhooks().then(async webhooks => { + webhooks = webhooks.filter(x => x.userId === note.userId && x.on.includes('reaction')); + if (webhooks.length === 0) return; + + const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); + const userObj = await this.userEntityService.pack(user.id, null, { schema: 'UserLite' }); + + const payload: Packed<'UserWebhookReactionBody'> = { + note: noteObj, + reaction: { + id: record.id, + user: userObj, + reaction: reaction, + }, + }; + + for (const webhook of webhooks) { + this.queueService.userWebhookDeliver(webhook, 'reaction', payload); + } + }); } //#region 配信 diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index c826a28963..c4210fbcf5 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -259,6 +259,20 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed }; } +function generateDummyReactionPayload(note_override?: Partial): Packed<'UserWebhookReactionBody'> { + const dummyNote = generateDummyNote(note_override); + const dummyReaction = { + id: 'dummy-reaction-1', + user: toPackedUserLite(generateDummyUser()), + reaction: 'test_reaction', + }; + + return { + note: toPackedNote(dummyNote), + reaction: dummyReaction, + }; +} + const dummyUser1 = generateDummyUser(); const dummyUser2 = generateDummyUser({ id: 'dummy-user-2', @@ -285,6 +299,10 @@ const dummyUser3 = generateDummyUser({ notesCount: 15900, }); +function wrapBodyEnum(tag: T, body: U): { [K in T]: U } { + return { [tag]: body } as { [K in T]: U }; +} + @Injectable() export class WebhookTestService { public static NoSuchWebhookError = class extends Error { @@ -321,7 +339,11 @@ export class WebhookTestService { } const webhook = webhooks[0]; - const send = (contents: unknown) => { + const send = (contents: + Packed<'UserWebhookNoteBody'> | + Packed<'UserWebhookUserBody'> | + Packed<'UserWebhookReactionBody'>, + ) => { const merged = { ...webhook, ...params.override, @@ -361,33 +383,42 @@ export class WebhookTestService { switch (params.type) { case 'note': { - send(toPackedNote(dummyNote1)); + send(wrapBodyEnum('note', toPackedNote(dummyNote1))); break; } case 'reply': { - send(toPackedNote(dummyReply1)); + send(wrapBodyEnum('note', toPackedNote(dummyReply1))); break; } case 'renote': { - send(toPackedNote(dummyRenote1)); + send(wrapBodyEnum('note', toPackedNote(dummyRenote1))); break; } case 'mention': { - send(toPackedNote(dummyMention1)); + send(wrapBodyEnum('note', toPackedNote(dummyMention1))); break; } case 'follow': { - send(toPackedUserDetailedNotMe(dummyUser1)); + send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser1))); break; } case 'followed': { - send(toPackedUserLite(dummyUser2)); + send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser2))); break; } case 'unfollow': { - send(toPackedUserDetailedNotMe(dummyUser3)); + send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser3))); break; } + case 'reaction': { + send(generateDummyReactionPayload()); + break; + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustiveAssertion: never = params.type; + return; + } } } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 040e36228c..b123879f8d 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -59,6 +59,7 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; +import { packedUserWebhookBodySchema, packedUserWebhookNoteBodySchema, packedUserWebhookReactionBodySchema, packedUserWebhookUserBodySchema } from '@/models/json-schema/user-webhook.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -68,6 +69,10 @@ export const refs = { MeDetailed: packedMeDetailedSchema, UserDetailed: packedUserDetailedSchema, User: packedUserSchema, + UserWebhookBody: packedUserWebhookBodySchema, + UserWebhookNoteBody: packedUserWebhookNoteBodySchema, + UserWebhookUserBody: packedUserWebhookUserBodySchema, + UserWebhookReactionBody: packedUserWebhookReactionBodySchema, UserList: packedUserListSchema, Ad: packedAdSchema, diff --git a/packages/backend/src/models/json-schema/user-webhook.ts b/packages/backend/src/models/json-schema/user-webhook.ts new file mode 100644 index 0000000000..aaf8a6afc8 --- /dev/null +++ b/packages/backend/src/models/json-schema/user-webhook.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedUserWebhookNoteBodySchema = { + type: 'object', + properties: { + note: { + type: 'object', + ref: 'Note', + optional: false, + nullable: false, + }, + }, + nullable: false, + optional: false, +} as const; + +export const packedUserWebhookUserBodySchema = { + type: 'object', + properties: { + user: { + type: 'object', + ref: 'UserLite', + optional: false, + nullable: false, + }, + }, + nullable: false, + optional: false, +} as const; + +export const packedUserWebhookReactionBodySchema = { + type: 'object', + properties: { + note: { + type: 'object', + ref: 'Note', + optional: false, + nullable: false, + }, + reaction: { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, + nullable: false, + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, + nullable: false, + }, + reaction: { + type: 'string', + optional: false, + nullable: false, + }, + }, + optional: false, + nullable: false, + }, + }, + nullable: false, + optional: false, +} as const; + +export const packedUserWebhookBodySchema = { + type: 'object', + oneOf: [ + packedUserWebhookNoteBodySchema, + packedUserWebhookUserBodySchema, + packedUserWebhookReactionBodySchema, + ], + nullable: false, + optional: false, +} as const; diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts index 5e63b86f8f..29867af409 100644 --- a/packages/backend/test/unit/WebhookTestService.ts +++ b/packages/backend/test/unit/WebhookTestService.ts @@ -14,6 +14,7 @@ import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersReposi import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { Packed } from '@/misc/json-schema.js'; describe('WebhookTestService', () => { let app: TestingModule; @@ -122,7 +123,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('note'); - expect((calls[2] as any).id).toBe('dummy-note-1'); + expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-note-1'); }); test('reply', async () => { @@ -131,7 +132,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('reply'); - expect((calls[2] as any).id).toBe('dummy-reply-1'); + expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-reply-1'); }); test('renote', async () => { @@ -140,7 +141,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('renote'); - expect((calls[2] as any).id).toBe('dummy-renote-1'); + expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-renote-1'); }); test('mention', async () => { @@ -149,7 +150,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('mention'); - expect((calls[2] as any).id).toBe('dummy-mention-1'); + expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-mention-1'); }); test('follow', async () => { @@ -158,7 +159,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('follow'); - expect((calls[2] as any).id).toBe('dummy-user-1'); + expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-1'); }); test('followed', async () => { @@ -167,7 +168,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('followed'); - expect((calls[2] as any).id).toBe('dummy-user-2'); + expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-2'); }); test('unfollow', async () => { @@ -176,7 +177,16 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('unfollow'); - expect((calls[2] as any).id).toBe('dummy-user-3'); + expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-3'); + }); + + test('reaction', async () => { + await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reaction' }, alice); + + const calls = queueService.userWebhookDeliver.mock.calls[0]; + expect((calls[0] as any).id).toBe('dummy-webhook'); + expect(calls[1]).toBe('reaction'); + expect((calls[2] as Packed<'UserWebhookReactionBody'>).reaction.id).toBe('dummy-reaction-1'); }); describe('NoSuchWebhookError', () => { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 061b533b72..8ac48678ed 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1810,6 +1810,10 @@ declare namespace entities { MeDetailed, UserDetailed, User, + UserWebhookBody, + UserWebhookNoteBody, + UserWebhookUserBody, + UserWebhookReactionBody, UserList, Ad, Announcement, @@ -3402,6 +3406,18 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content // @public (undocumented) type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json']; +// @public (undocumented) +type UserWebhookBody = components['schemas']['UserWebhookBody']; + +// @public (undocumented) +type UserWebhookNoteBody = components['schemas']['UserWebhookNoteBody']; + +// @public (undocumented) +type UserWebhookReactionBody = components['schemas']['UserWebhookReactionBody']; + +// @public (undocumented) +type UserWebhookUserBody = components['schemas']['UserWebhookUserBody']; + // Warnings were encountered during analysis: // // src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 04574849d4..8b1711015c 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -7,6 +7,10 @@ export type UserDetailedNotMe = components['schemas']['UserDetailedNotMe']; export type MeDetailed = components['schemas']['MeDetailed']; export type UserDetailed = components['schemas']['UserDetailed']; export type User = components['schemas']['User']; +export type UserWebhookBody = components['schemas']['UserWebhookBody']; +export type UserWebhookNoteBody = components['schemas']['UserWebhookNoteBody']; +export type UserWebhookUserBody = components['schemas']['UserWebhookUserBody']; +export type UserWebhookReactionBody = components['schemas']['UserWebhookReactionBody']; export type UserList = components['schemas']['UserList']; export type Ad = components['schemas']['Ad']; export type Announcement = components['schemas']['Announcement']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index a5333d4f93..5f9b4316f3 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4013,6 +4013,32 @@ export type components = { MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; UserDetailed: components['schemas']['UserDetailedNotMe'] | components['schemas']['MeDetailed']; User: components['schemas']['UserLite'] | components['schemas']['UserDetailed']; + UserWebhookBody: OneOf<[{ + note: components['schemas']['Note']; + }, { + user: components['schemas']['UserLite']; + }, { + note: components['schemas']['Note']; + reaction: { + id: string; + user: components['schemas']['UserLite']; + reaction: string; + }; + }]>; + UserWebhookNoteBody: { + note: components['schemas']['Note']; + }; + UserWebhookUserBody: { + user: components['schemas']['UserLite']; + }; + UserWebhookReactionBody: { + note: components['schemas']['Note']; + reaction: { + id: string; + user: components['schemas']['UserLite']; + reaction: string; + }; + }; UserList: { /** * Format: id