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 /<cmd> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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: [
|
||||
|
||||
35
src/commands/commands.controller.ts
Normal file
35
src/commands/commands.controller.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
12
src/commands/commands.module.ts
Normal file
12
src/commands/commands.module.ts
Normal file
@@ -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 {}
|
||||
39
src/commands/commands.service.ts
Normal file
39
src/commands/commands.service.ts
Normal file
@@ -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<GuildCommand>,
|
||||
) {}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
25
src/entities/guild-command.entity.ts
Normal file
25
src/entities/guild-command.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user