From 37ec670280e4e2d80b6e7a7f183a16421ca2d922 Mon Sep 17 00:00:00 2001 From: nav Date: Tue, 12 May 2026 11:24:43 +0000 Subject: [PATCH] feat(guild-events): add webhook event envelope and message lifecycle emits --- Fabric.Backend.Guild/src/app.module.ts | 9 ++- .../src/events/event-envelope.ts | 9 +++ .../src/events/events.module.ts | 9 +++ .../src/events/events.service.ts | 57 +++++++++++++++++++ .../src/messaging/messaging.controller.ts | 30 ++++++++++ docs/TODO-backend-center-guild.md | 2 +- 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Fabric.Backend.Guild/src/events/event-envelope.ts create mode 100644 Fabric.Backend.Guild/src/events/events.module.ts create mode 100644 Fabric.Backend.Guild/src/events/events.service.ts diff --git a/Fabric.Backend.Guild/src/app.module.ts b/Fabric.Backend.Guild/src/app.module.ts index ec1f90b..e335d9d 100644 --- a/Fabric.Backend.Guild/src/app.module.ts +++ b/Fabric.Backend.Guild/src/app.module.ts @@ -5,9 +5,16 @@ import { HealthController } from './common/health.controller'; import { GuildsModule } from './guilds/guilds.module'; import { ChannelsModule } from './channels/channels.module'; import { MessagingModule } from './messaging/messaging.module'; +import { EventsModule } from './events/events.module'; @Module({ - imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), GuildsModule, ChannelsModule, MessagingModule], + imports: [ + TypeOrmModule.forRoot(buildTypeOrmConfig()), + EventsModule, + GuildsModule, + ChannelsModule, + MessagingModule, + ], controllers: [HealthController], }) export class AppModule {} diff --git a/Fabric.Backend.Guild/src/events/event-envelope.ts b/Fabric.Backend.Guild/src/events/event-envelope.ts new file mode 100644 index 0000000..bb51eb5 --- /dev/null +++ b/Fabric.Backend.Guild/src/events/event-envelope.ts @@ -0,0 +1,9 @@ +export type FabricEventEnvelope = { + event_id: string; + event_type: string; + occurred_at: string; + guild_id: string | null; + channel_id: string | null; + actor_id: string | null; + data: Record; +}; diff --git a/Fabric.Backend.Guild/src/events/events.module.ts b/Fabric.Backend.Guild/src/events/events.module.ts new file mode 100644 index 0000000..e9a657e --- /dev/null +++ b/Fabric.Backend.Guild/src/events/events.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { EventsService } from './events.service'; + +@Global() +@Module({ + providers: [EventsService], + exports: [EventsService], +}) +export class EventsModule {} diff --git a/Fabric.Backend.Guild/src/events/events.service.ts b/Fabric.Backend.Guild/src/events/events.service.ts new file mode 100644 index 0000000..a5ae968 --- /dev/null +++ b/Fabric.Backend.Guild/src/events/events.service.ts @@ -0,0 +1,57 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { FabricEventEnvelope } from './event-envelope'; + +@Injectable() +export class EventsService { + private readonly logger = new Logger(EventsService.name); + + buildEnvelope(input: { + eventType: string; + guildId?: string | null; + channelId?: string | null; + actorId?: string | null; + data: Record; + }): FabricEventEnvelope { + return { + event_id: randomUUID(), + event_type: input.eventType, + occurred_at: new Date().toISOString(), + guild_id: input.guildId ?? null, + channel_id: input.channelId ?? null, + actor_id: input.actorId ?? null, + data: input.data, + }; + } + + async emit(input: { + eventType: string; + guildId?: string | null; + channelId?: string | null; + actorId?: string | null; + data: Record; + }): Promise { + const envelope = this.buildEnvelope(input); + + const webhookUrl = process.env.FABRIC_WEBHOOK_URL; + if (!webhookUrl) { + this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`); + return envelope; + } + + try { + await fetch(webhookUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(envelope), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`event delivery failed: ${message}`); + } + + return envelope; + } +} diff --git a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts index 07a94c3..44f76ec 100644 --- a/Fabric.Backend.Guild/src/messaging/messaging.controller.ts +++ b/Fabric.Backend.Guild/src/messaging/messaging.controller.ts @@ -16,6 +16,7 @@ import { CreateMessageDto } from './dto.create-message.dto'; import { Channel } from '../entities/channel.entity'; import { Message } from '../entities/message.entity'; import { IdempotencyRecord } from '../entities/idempotency-record.entity'; +import { EventsService } from '../events/events.service'; const EDIT_WINDOW_MS = 15 * 60 * 1000; const DEFAULT_PAGE_LIMIT = 50; @@ -31,6 +32,7 @@ export class MessagingController { private readonly messageRepo: Repository, @InjectRepository(IdempotencyRecord) private readonly idemRepo: Repository, + private readonly events: EventsService, ) {} private async getIdempotentResponse( @@ -115,6 +117,14 @@ export class MessagingController { const responseBody = this.toView(message) as Record; await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + + await this.events.emit({ + eventType: 'message.created', + channelId, + actorId: body.authorUserId ?? 'anonymous', + data: responseBody, + }); + return responseBody; } @@ -143,6 +153,14 @@ export class MessagingController { const saved = await this.messageRepo.save(item); const responseBody = this.toView(saved) as Record; await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + + await this.events.emit({ + eventType: 'message.updated', + channelId, + actorId: saved.authorUserId, + data: responseBody, + }); + return responseBody; } @@ -172,6 +190,18 @@ export class MessagingController { messageId, } as Record; await this.saveIdempotentResponse(scope, idempotencyKey, responseBody); + + await this.events.emit({ + eventType: 'message.deleted', + channelId, + actorId: item.authorUserId, + data: { + messageId, + seq: item.seq, + deletedAt: item.deletedAt?.toISOString() ?? null, + }, + }); + return responseBody; } diff --git a/docs/TODO-backend-center-guild.md b/docs/TODO-backend-center-guild.md index dda3af3..b81ba6c 100644 --- a/docs/TODO-backend-center-guild.md +++ b/docs/TODO-backend-center-guild.md @@ -71,7 +71,7 @@ --- ## 4. 插件与扩展面(为 OpenclawPlugin 准备) -- [ ] Webhook 事件信封落地(event_id/event_type/occurred_at/data) +- [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data) - [ ] HMAC 签名与重放防护 - [ ] 出站重试队列(指数退避) - [ ] Bot Token 入站调用鉴权