feat(plugin): sync OpenClaw slash-command catalog to Fabric

- command-sync.ts: buildFabricCommandSpecs(cfg) reads OpenClaw native
  command specs via openclaw/plugin-sdk/native-command-registry
  (listNativeCommandSpecsForConfig + findCommandByNativeName), resolves
  dynamic arg choices to a static snapshot (resolveCommandArgChoices) —
  same data Discord registers as slash commands.
- syncFabricCommands(): on gateway_start, after inbound starts, PUT the
  catalog to each connected guild (FabricClient.syncCommands ->
  PUT /api/commands; idempotent, one per guild).
- Fabric stays a TEXT-command surface (no nativeCommands capability):
  execution still flows as a /<cmd> message into OpenClaw's command
  system; this catalog only drives frontend autocomplete.

Verified: 41 specs built (args/choices incl. dynamic), synced to
test-guild1, GET /api/commands round-trips count=41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 16:06:22 +01:00
parent fac6debfa5
commit c03562046d
6 changed files with 258 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ import { listEnabledFabricAccounts } from './src/accounts.js';
import { registerFabricTools } from './src/tools.js'; import { registerFabricTools } from './src/tools.js';
import { FabricClient } from './src/fabric-client.js'; import { FabricClient } from './src/fabric-client.js';
import { IdentityRegistry } from './src/identity.js'; import { IdentityRegistry } from './src/identity.js';
import { syncFabricCommands } from './src/command-sync.js';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
let runtimeRef = null; let runtimeRef = null;
@@ -57,6 +58,7 @@ export default defineChannelPluginEntry({
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts); inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
void inbound.start(); void inbound.start();
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`); api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
void syncFabricCommands(client, cfg, accounts, api.logger);
}); });
api.on('gateway_stop', () => { api.on('gateway_stop', () => {
inbound?.stop(); inbound?.stop();

100
dist/fabric/src/command-sync.js vendored Normal file
View File

@@ -0,0 +1,100 @@
// Build the Fabric slash-command catalog from OpenClaw's native-command
// specs (the same source Discord uses to register slash commands) and push
// it to each connected guild. Fabric is a TEXT-command surface: a /<cmd>
// message is delivered normally and OpenClaw's command system executes it —
// this catalog only drives the frontend `/` autocomplete, so we resolve any
// dynamic arg `choices` to a static snapshot here (like Discord does at
// registration time).
import { listNativeCommandSpecsForConfig, findCommandByNativeName, resolveCommandArgChoices, } from 'openclaw/plugin-sdk/native-command-registry';
function normChoice(c) {
if (typeof c === 'string')
return { value: c, label: c };
const o = c;
return { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') };
}
export function buildFabricCommandSpecs(cfg) {
const specs = listNativeCommandSpecsForConfig(cfg, {
provider: 'fabric',
});
return specs.map((s) => {
// ChatCommandDefinition (for argsParsing + dynamic choices provider)
const def = findCommandByNativeName(s.name, 'fabric');
const args = (s.args ?? []).map((a) => {
const raw = a.choices;
let choices = null;
if (Array.isArray(raw)) {
choices = raw.map(normChoice);
}
else if (typeof raw === 'function' && def) {
try {
const r = resolveCommandArgChoices({
command: def,
arg: a,
cfg: cfg,
provider: 'fabric',
});
choices = r.map((x) => ({ value: x.value, label: x.label }));
}
catch {
choices = null;
}
}
return {
name: String(a.name ?? ''),
description: String(a.description ?? ''),
type: String(a.type ?? 'string'),
required: !!a.required,
captureRemaining: !!a.captureRemaining,
preferAutocomplete: !!a.preferAutocomplete,
choices,
};
});
return {
name: s.name,
nativeName: s.name,
description: s.description,
acceptsArgs: !!s.acceptsArgs,
args,
argsParsing: def?.argsParsing ?? 'positional',
};
});
}
// Push the catalog to every guild the known agents belong to (idempotent;
// the catalog is OpenClaw-global, so one PUT per guild is enough).
export async function syncFabricCommands(client, cfg, accounts, log) {
let specs;
try {
specs = buildFabricCommandSpecs(cfg);
}
catch (err) {
log.warn(`fabric: build command specs failed: ${String(err)}`);
return;
}
if (!specs.length)
return;
const done = new Set();
for (const a of accounts) {
let session;
try {
session = await client.agentLogin(a.fabricApiKey);
}
catch {
continue;
}
for (const g of session.guilds) {
if (done.has(g.nodeId))
continue;
const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token;
if (!tok)
continue;
try {
await client.syncCommands(g.endpoint, tok, specs);
done.add(g.nodeId);
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
}
catch (err) {
log.warn(`fabric: command sync failed ${g.nodeId}: ${String(err)}`);
}
}
}
}

View File

@@ -71,6 +71,12 @@ export class FabricClient {
leaveChannel(guildEndpoint, guildToken, channelId) { leaveChannel(guildEndpoint, guildToken, channelId) {
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken); return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
} }
// Register the OpenClaw slash-command catalog with this guild (idempotent
// full replace). The frontend GETs it for `/` autocomplete; execution
// still flows as a normal /<cmd> message into OpenClaw's command system.
syncCommands(guildEndpoint, guildToken, commands) {
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands });
}
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
channelMembers(guildEndpoint, guildToken, channelId) { channelMembers(guildEndpoint, guildToken, channelId) {
return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken); return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken);

View File

@@ -11,6 +11,7 @@ import { listEnabledFabricAccounts } from './src/accounts.js';
import { registerFabricTools } from './src/tools.js'; import { registerFabricTools } from './src/tools.js';
import { FabricClient } from './src/fabric-client.js'; import { FabricClient } from './src/fabric-client.js';
import { IdentityRegistry } from './src/identity.js'; import { IdentityRegistry } from './src/identity.js';
import { syncFabricCommands } from './src/command-sync.js';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
@@ -82,6 +83,7 @@ export default defineChannelPluginEntry({
); );
void inbound.start(); void inbound.start();
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`); api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
void syncFabricCommands(client, cfg, accounts, api.logger);
}); });
api.on('gateway_stop', () => { api.on('gateway_stop', () => {

137
src/command-sync.ts Normal file
View File

@@ -0,0 +1,137 @@
// Build the Fabric slash-command catalog from OpenClaw's native-command
// specs (the same source Discord uses to register slash commands) and push
// it to each connected guild. Fabric is a TEXT-command surface: a /<cmd>
// message is delivered normally and OpenClaw's command system executes it —
// this catalog only drives the frontend `/` autocomplete, so we resolve any
// dynamic arg `choices` to a static snapshot here (like Discord does at
// registration time).
import {
listNativeCommandSpecsForConfig,
findCommandByNativeName,
resolveCommandArgChoices,
} from 'openclaw/plugin-sdk/native-command-registry';
import type { FabricClient } from './fabric-client.js';
type Logger = { info: (m: string) => void; warn: (m: string) => void };
type FabricArg = {
name: string;
description: string;
type: string;
required: boolean;
captureRemaining: boolean;
preferAutocomplete: boolean;
choices: Array<{ value: string; label: string }> | null;
};
type FabricCommand = {
name: string;
nativeName: string;
description: string;
acceptsArgs: boolean;
args: FabricArg[];
argsParsing: string;
};
function normChoice(c: unknown): { value: string; label: string } {
if (typeof c === 'string') return { value: c, label: c };
const o = c as { value?: string; label?: string };
return { value: String(o.value ?? ''), label: String(o.label ?? o.value ?? '') };
}
export function buildFabricCommandSpecs(cfg: unknown): FabricCommand[] {
const specs = listNativeCommandSpecsForConfig(cfg as never, {
provider: 'fabric',
}) as Array<{
name: string;
description: string;
acceptsArgs?: boolean;
args?: Array<Record<string, unknown>>;
}>;
return specs.map((s) => {
// ChatCommandDefinition (for argsParsing + dynamic choices provider)
const def = findCommandByNativeName(s.name, 'fabric') as
| { argsParsing?: string; args?: Array<Record<string, unknown>> }
| undefined;
const args: FabricArg[] = (s.args ?? []).map((a) => {
const raw = a.choices;
let choices: Array<{ value: string; label: string }> | null = null;
if (Array.isArray(raw)) {
choices = raw.map(normChoice);
} else if (typeof raw === 'function' && def) {
try {
const r = resolveCommandArgChoices({
command: def as never,
arg: a as never,
cfg: cfg as never,
provider: 'fabric',
}) as Array<{ value: string; label: string }>;
choices = r.map((x) => ({ value: x.value, label: x.label }));
} catch {
choices = null;
}
}
return {
name: String(a.name ?? ''),
description: String(a.description ?? ''),
type: String(a.type ?? 'string'),
required: !!a.required,
captureRemaining: !!a.captureRemaining,
preferAutocomplete: !!a.preferAutocomplete,
choices,
};
});
return {
name: s.name,
nativeName: s.name,
description: s.description,
acceptsArgs: !!s.acceptsArgs,
args,
argsParsing: def?.argsParsing ?? 'positional',
};
});
}
// Push the catalog to every guild the known agents belong to (idempotent;
// the catalog is OpenClaw-global, so one PUT per guild is enough).
export async function syncFabricCommands(
client: FabricClient,
cfg: unknown,
accounts: Array<{ agentId: string; fabricApiKey: string }>,
log: Logger,
): Promise<void> {
let specs: FabricCommand[];
try {
specs = buildFabricCommandSpecs(cfg);
} catch (err) {
log.warn(`fabric: build command specs failed: ${String(err)}`);
return;
}
if (!specs.length) return;
const done = new Set<string>();
for (const a of accounts) {
let session;
try {
session = await client.agentLogin(a.fabricApiKey);
} catch {
continue;
}
for (const g of session.guilds) {
if (done.has(g.nodeId)) continue;
const tok = session.guildAccessTokens.find(
(t) => t.guildNodeId === g.nodeId,
)?.token;
if (!tok) continue;
try {
await client.syncCommands(g.endpoint, tok, specs);
done.add(g.nodeId);
log.info(`fabric: synced ${specs.length} slash command(s) -> ${g.nodeId}`);
} catch (err) {
log.warn(`fabric: command sync failed ${g.nodeId}: ${String(err)}`);
}
}
}
}

View File

@@ -115,6 +115,17 @@ export class FabricClient {
return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken); return this.post(`${guildEndpoint}/api/channels/${channelId}/leave`, {}, guildToken);
} }
// Register the OpenClaw slash-command catalog with this guild (idempotent
// full replace). The frontend GETs it for `/` autocomplete; execution
// still flows as a normal /<cmd> message into OpenClaw's command system.
syncCommands(
guildEndpoint: string,
guildToken: string,
commands: unknown[],
): Promise<unknown> {
return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands });
}
// [{ userId, bypass }] — bypass is true only for discuss/work bypass-list // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list
channelMembers( channelMembers(
guildEndpoint: string, guildEndpoint: string,