diff --git a/dist/fabric/index.js b/dist/fabric/index.js index 3cfd5fe..d99db3b 100644 --- a/dist/fabric/index.js +++ b/dist/fabric/index.js @@ -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(); diff --git a/dist/fabric/src/command-sync.js b/dist/fabric/src/command-sync.js new file mode 100644 index 0000000..54b5b6c --- /dev/null +++ b/dist/fabric/src/command-sync.js @@ -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 / +// 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)}`); + } + } + } +} diff --git a/dist/fabric/src/fabric-client.js b/dist/fabric/src/fabric-client.js index 46c74a2..1cde727 100644 --- a/dist/fabric/src/fabric-client.js +++ b/dist/fabric/src/fabric-client.js @@ -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 / 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); diff --git a/index.ts b/index.ts index f46b21c..4b15dbd 100644 --- a/index.ts +++ b/index.ts @@ -11,6 +11,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'; @@ -82,6 +83,7 @@ export default defineChannelPluginEntry({ ); 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', () => { diff --git a/src/command-sync.ts b/src/command-sync.ts new file mode 100644 index 0000000..8017a63 --- /dev/null +++ b/src/command-sync.ts @@ -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 / +// 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>; + }>; + + return specs.map((s) => { + // ChatCommandDefinition (for argsParsing + dynamic choices provider) + const def = findCommandByNativeName(s.name, 'fabric') as + | { argsParsing?: string; args?: Array> } + | 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 { + 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(); + 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)}`); + } + } + } +} diff --git a/src/fabric-client.ts b/src/fabric-client.ts index 0a92bff..aa1518f 100644 --- a/src/fabric-client.ts +++ b/src/fabric-client.ts @@ -115,6 +115,17 @@ export class FabricClient { 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 / message into OpenClaw's command system. + syncCommands( + guildEndpoint: string, + guildToken: string, + commands: unknown[], + ): Promise { + return this.req('PUT', `${guildEndpoint}/api/commands`, guildToken, { commands }); + } + // [{ userId, bypass }] — bypass is true only for discuss/work bypass-list channelMembers( guildEndpoint: string,