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:
h z
2026-05-16 16:02:49 +01:00
parent 8de5736a59
commit f54ed6abb5
6 changed files with 115 additions and 0 deletions

View 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();
}
}

View 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 {}

View 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,
};
}
}