Files
Fabric.OpenclawPlugin/dist/fabric/src/command-sync.js
hzhang ab126825ef feat(security): commandsSyncKey is a required channel-config field (Guild C-2)
The slash-command sync secret now comes from
channels.fabric.commandsSyncKey (configSchema marks it required) and
is no longer read from FABRIC_COMMANDS_SYNC_KEY env. command-sync
resolves it from config and threads it into client.syncCommands;
when absent, sync is skipped with a clear warning. README updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:44:25 +01:00

110 lines
4.1 KiB
JavaScript

// 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 { resolveCommandsSyncKey } from './accounts.js';
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) {
// Guild C-2: the sync key comes from the channel config only (schema
// marks it required). Without it the guild rejects the catalog write.
const syncKey = resolveCommandsSyncKey(cfg);
if (!syncKey) {
log.warn('fabric: channels.fabric.commandsSyncKey is not set — skipping ' +
'slash-command sync (set it to the guild FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY)');
return;
}
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, syncKey);
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)}`);
}
}
}
}