From f54ed6abb5e220352ade5797d8213561e2887db1 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 16:02:49 +0100 Subject: [PATCH] feat(guild): slash-command registry (sync + list API) Guild-global slash-command catalog (one row per node guild). The OpenClaw plugin PUTs the native-command specs (same data Discord registers as slash commands); the frontend GETs it for / autocomplete. - GuildCommand entity (guild_id unique, commands json, updatedAt) - PUT /api/commands -> idempotent full replace (any authed agent/user) - GET /api/commands -> { commands, updatedAt } (authed) - stored verbatim (NativeCommandSpec-shaped); execution path unchanged: a / message is delivered as a normal message -> plugin -> OpenClaw command system (only /no-reply, /force-proceed stay server-intercepted). Verified: PUT->{ok,count}, GET round-trips args/choices, no-auth->401. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app.module.ts | 2 ++ src/commands/commands.controller.ts | 35 +++++++++++++++++++++++++ src/commands/commands.module.ts | 12 +++++++++ src/commands/commands.service.ts | 39 ++++++++++++++++++++++++++++ src/database.config.ts | 2 ++ src/entities/guild-command.entity.ts | 25 ++++++++++++++++++ 6 files changed, 115 insertions(+) create mode 100644 src/commands/commands.controller.ts create mode 100644 src/commands/commands.module.ts create mode 100644 src/commands/commands.service.ts create mode 100644 src/entities/guild-command.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 20edcc6..a75b4dd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { RealtimeModule } from './realtime/realtime.module.js'; import { MembersModule } from './members/members.module.js'; import { FilesModule } from './files/files.module.js'; import { CanvasModule } from './canvas/canvas.module.js'; +import { CommandsModule } from './commands/commands.module.js'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { CanvasModule } from './canvas/canvas.module.js'; MessagingModule, FilesModule, CanvasModule, + CommandsModule, ], controllers: [HealthController, MetricsController], providers: [ diff --git a/src/commands/commands.controller.ts b/src/commands/commands.controller.ts new file mode 100644 index 0000000..0c72d90 --- /dev/null +++ b/src/commands/commands.controller.ts @@ -0,0 +1,35 @@ +import { + Body, + Controller, + Get, + Put, + Req, + UnauthorizedException, +} from '@nestjs/common'; +import { CommandsService } from './commands.service.js'; + +type AuthedRequest = { userId?: string }; + +@Controller('commands') +export class CommandsController { + constructor(private readonly commands: CommandsService) {} + + // Plugin syncs the OpenClaw native-command catalog here (any authenticated + // agent/user; idempotent full replace). + @Put() + sync( + @Req() req: AuthedRequest, + @Body() body: { commands?: unknown[] }, + ) { + if (!req.userId) throw new UnauthorizedException('missing user'); + const commands = Array.isArray(body?.commands) ? body.commands : []; + return this.commands.sync(commands); + } + + // Frontend reads the catalog to drive `/` autocomplete. + @Get() + list(@Req() req: AuthedRequest) { + if (!req.userId) throw new UnauthorizedException('missing user'); + return this.commands.list(); + } +} diff --git a/src/commands/commands.module.ts b/src/commands/commands.module.ts new file mode 100644 index 0000000..fc7f157 --- /dev/null +++ b/src/commands/commands.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GuildCommand } from '../entities/guild-command.entity.js'; +import { CommandsController } from './commands.controller.js'; +import { CommandsService } from './commands.service.js'; + +@Module({ + imports: [TypeOrmModule.forFeature([GuildCommand])], + controllers: [CommandsController], + providers: [CommandsService], +}) +export class CommandsModule {} diff --git a/src/commands/commands.service.ts b/src/commands/commands.service.ts new file mode 100644 index 0000000..14e9bce --- /dev/null +++ b/src/commands/commands.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GuildCommand } from '../entities/guild-command.entity.js'; + +// This node's guild id (one guild per node). +function guildId(): string { + return process.env.FABRIC_BACKEND_GUILD_NODE_ID ?? 'guild'; +} + +@Injectable() +export class CommandsService { + constructor( + @InjectRepository(GuildCommand) + private readonly repo: Repository, + ) {} + + // Replace the whole guild-global slash-command catalog (idempotent; + // the plugin re-PUTs the full set on every gateway start). + async sync(commands: unknown[]): Promise<{ status: string; count: number }> { + const gid = guildId(); + let row = await this.repo.findOne({ where: { guildId: gid } }); + if (row) { + row.commands = commands; + } else { + row = this.repo.create({ guildId: gid, commands }); + } + await this.repo.save(row); + return { status: 'ok', count: commands.length }; + } + + async list(): Promise<{ commands: unknown[]; updatedAt: string | null }> { + const row = await this.repo.findOne({ where: { guildId: guildId() } }); + return { + commands: row?.commands ?? [], + updatedAt: row?.updatedAt ? row.updatedAt.toISOString() : null, + }; + } +} diff --git a/src/database.config.ts b/src/database.config.ts index b3659d3..a779bb4 100644 --- a/src/database.config.ts +++ b/src/database.config.ts @@ -13,6 +13,7 @@ import { GuildMemberRole } from './entities/guild-member-role.entity.js'; import { IdempotencyRecord } from './entities/idempotency-record.entity.js'; import { StoredFile } from './entities/stored-file.entity.js'; import { ChannelCanvas } from './entities/channel-canvas.entity.js'; +import { GuildCommand } from './entities/guild-command.entity.js'; export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ type: 'mysql', @@ -36,6 +37,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ IdempotencyRecord, StoredFile, ChannelCanvas, + GuildCommand, ], synchronize: (process.env.FABRIC_BACKEND_GUILD_DB_SYNC ?? 'true') === 'true', logging: (process.env.FABRIC_BACKEND_GUILD_DB_LOGGING ?? 'false') === 'true', diff --git a/src/entities/guild-command.entity.ts b/src/entities/guild-command.entity.ts new file mode 100644 index 0000000..6c2cd14 --- /dev/null +++ b/src/entities/guild-command.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +// Guild-global slash-command catalog. One row per guild (this node's +// FABRIC_BACKEND_GUILD_NODE_ID). The OpenClaw plugin PUTs the OpenClaw +// native-command specs here (the same data Discord registers as slash +// commands); the frontend GETs it to drive `/` autocomplete. The guild +// node stores the catalog opaquely — it does not interpret command bodies. +@Entity('guild_commands') +export class GuildCommand { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index({ unique: true }) + @Column({ name: 'guild_id', type: 'varchar', length: 80 }) + guildId!: string; + + // NativeCommandSpec[]-shaped (name, nativeName, description, acceptsArgs, + // args[{name,description,type,required,choices:[{value,label}], + // captureRemaining,preferAutocomplete}], argsParsing). Stored verbatim. + @Column({ type: 'json' }) + commands!: unknown[]; + + @UpdateDateColumn() + updatedAt!: Date; +}