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:
2
dist/fabric/index.js
vendored
2
dist/fabric/index.js
vendored
@@ -10,6 +10,7 @@ import { listEnabledFabricAccounts } from './src/accounts.js';
|
||||
import { registerFabricTools } from './src/tools.js';
|
||||
import { FabricClient } from './src/fabric-client.js';
|
||||
import { IdentityRegistry } from './src/identity.js';
|
||||
import { syncFabricCommands } from './src/command-sync.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
let runtimeRef = null;
|
||||
@@ -57,6 +58,7 @@ export default defineChannelPluginEntry({
|
||||
inbound = new FabricInbound(runtimeRef, api.config, client, identity, api.logger, accounts);
|
||||
void inbound.start();
|
||||
api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`);
|
||||
void syncFabricCommands(client, cfg, accounts, api.logger);
|
||||
});
|
||||
api.on('gateway_stop', () => {
|
||||
inbound?.stop();
|
||||
|
||||
100
dist/fabric/src/command-sync.js
vendored
Normal file
100
dist/fabric/src/command-sync.js
vendored
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
dist/fabric/src/fabric-client.js
vendored
6
dist/fabric/src/fabric-client.js
vendored
@@ -71,6 +71,12 @@ export class FabricClient {
|
||||
leaveChannel(guildEndpoint, guildToken, channelId) {
|
||||
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
|
||||
channelMembers(guildEndpoint, guildToken, channelId) {
|
||||
return this.req('GET', `${guildEndpoint}/api/channels/${channelId}/members`, guildToken);
|
||||
|
||||
Reference in New Issue
Block a user