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>
150 lines
4.7 KiB
TypeScript
150 lines
4.7 KiB
TypeScript
// 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';
|
|
import { resolveCommandsSyncKey } from './accounts.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> {
|
|
// 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 as never);
|
|
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: 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, 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)}`);
|
|
}
|
|
}
|
|
}
|
|
}
|