feat(guild-events): add webhook event envelope and message lifecycle emits
This commit is contained in:
@@ -5,9 +5,16 @@ import { HealthController } from './common/health.controller';
|
|||||||
import { GuildsModule } from './guilds/guilds.module';
|
import { GuildsModule } from './guilds/guilds.module';
|
||||||
import { ChannelsModule } from './channels/channels.module';
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
import { MessagingModule } from './messaging/messaging.module';
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forRoot(buildTypeOrmConfig()), GuildsModule, ChannelsModule, MessagingModule],
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
EventsModule,
|
||||||
|
GuildsModule,
|
||||||
|
ChannelsModule,
|
||||||
|
MessagingModule,
|
||||||
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
9
Fabric.Backend.Guild/src/events/event-envelope.ts
Normal file
9
Fabric.Backend.Guild/src/events/event-envelope.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
9
Fabric.Backend.Guild/src/events/events.module.ts
Normal file
9
Fabric.Backend.Guild/src/events/events.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { EventsService } from './events.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EventsService],
|
||||||
|
exports: [EventsService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
57
Fabric.Backend.Guild/src/events/events.service.ts
Normal file
57
Fabric.Backend.Guild/src/events/events.service.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}): 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<string, unknown>;
|
||||||
|
}): Promise<FabricEventEnvelope> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { CreateMessageDto } from './dto.create-message.dto';
|
|||||||
import { Channel } from '../entities/channel.entity';
|
import { Channel } from '../entities/channel.entity';
|
||||||
import { Message } from '../entities/message.entity';
|
import { Message } from '../entities/message.entity';
|
||||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
|
import { EventsService } from '../events/events.service';
|
||||||
|
|
||||||
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const DEFAULT_PAGE_LIMIT = 50;
|
const DEFAULT_PAGE_LIMIT = 50;
|
||||||
@@ -31,6 +32,7 @@ export class MessagingController {
|
|||||||
private readonly messageRepo: Repository<Message>,
|
private readonly messageRepo: Repository<Message>,
|
||||||
@InjectRepository(IdempotencyRecord)
|
@InjectRepository(IdempotencyRecord)
|
||||||
private readonly idemRepo: Repository<IdempotencyRecord>,
|
private readonly idemRepo: Repository<IdempotencyRecord>,
|
||||||
|
private readonly events: EventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async getIdempotentResponse(
|
private async getIdempotentResponse(
|
||||||
@@ -115,6 +117,14 @@ export class MessagingController {
|
|||||||
|
|
||||||
const responseBody = this.toView(message) as Record<string, unknown>;
|
const responseBody = this.toView(message) as Record<string, unknown>;
|
||||||
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
||||||
|
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.created',
|
||||||
|
channelId,
|
||||||
|
actorId: body.authorUserId ?? 'anonymous',
|
||||||
|
data: responseBody,
|
||||||
|
});
|
||||||
|
|
||||||
return responseBody;
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +153,14 @@ export class MessagingController {
|
|||||||
const saved = await this.messageRepo.save(item);
|
const saved = await this.messageRepo.save(item);
|
||||||
const responseBody = this.toView(saved) as Record<string, unknown>;
|
const responseBody = this.toView(saved) as Record<string, unknown>;
|
||||||
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
||||||
|
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.updated',
|
||||||
|
channelId,
|
||||||
|
actorId: saved.authorUserId,
|
||||||
|
data: responseBody,
|
||||||
|
});
|
||||||
|
|
||||||
return responseBody;
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +190,18 @@ export class MessagingController {
|
|||||||
messageId,
|
messageId,
|
||||||
} as Record<string, unknown>;
|
} as Record<string, unknown>;
|
||||||
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
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;
|
return responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
## 4. 插件与扩展面(为 OpenclawPlugin 准备)
|
||||||
- [ ] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
- [x] Webhook 事件信封落地(event_id/event_type/occurred_at/data)
|
||||||
- [ ] HMAC 签名与重放防护
|
- [ ] HMAC 签名与重放防护
|
||||||
- [ ] 出站重试队列(指数退避)
|
- [ ] 出站重试队列(指数退避)
|
||||||
- [ ] Bot Token 入站调用鉴权
|
- [ ] Bot Token 入站调用鉴权
|
||||||
|
|||||||
Reference in New Issue
Block a user