From 2b520b61724ef8d58e34cb8093694bab262b321e Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 02:08:30 +0100 Subject: [PATCH] feat: sync ClickClack command menus at startup --- extensions/clickclack/src/gateway.test.ts | 95 +++++++++++++++++++++++ extensions/clickclack/src/gateway.ts | 4 + 2 files changed, 99 insertions(+) diff --git a/extensions/clickclack/src/gateway.test.ts b/extensions/clickclack/src/gateway.test.ts index f2ef0ef8e040..aa63aca54214 100644 --- a/extensions/clickclack/src/gateway.test.ts +++ b/extensions/clickclack/src/gateway.test.ts @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ channelMessages: vi.fn(), directMessages: vi.fn(), thread: vi.fn(), + setBotCommands: vi.fn(), }, handleClickClackInbound: vi.fn(), resolveClickClackInboundAccess: vi.fn(), @@ -45,6 +46,10 @@ vi.mock("./inbound.js", () => ({ handleClickClackInbound: mocks.handleClickClackInbound, })); +vi.mock("openclaw/plugin-sdk/native-command-registry", () => ({ + listNativeCommandSpecsForConfig: () => [], +})); + vi.mock("./resolve.js", () => ({ resolveWorkspaceId: mocks.resolveWorkspaceId, })); @@ -53,6 +58,9 @@ import { startClickClackGatewayAccount } from "./gateway.js"; function createGatewayContext( abortSignal: AbortSignal, + options: { + commandMenu?: boolean; + } = {}, ): ChannelGatewayContext { const setStatus = vi.fn(); const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; @@ -64,6 +72,7 @@ function createGatewayContext( token: "test-token", workspace: "main", reconnectMs: 1, + ...(options.commandMenu === undefined ? {} : { commandMenu: options.commandMenu }), }, }, } as ChannelGatewayContext["cfg"], @@ -105,6 +114,7 @@ describe("ClickClack gateway", () => { created_at: "2026-01-01T00:00:00.000Z", }); mocks.client.eventPage.mockResolvedValue({ events: [] }); + mocks.client.setBotCommands.mockResolvedValue([]); mocks.resolveClickClackInboundAccess.mockResolvedValue({ shouldDispatch: true, commandAuthorized: true, @@ -132,6 +142,91 @@ describe("ClickClack gateway", () => { ]); }); + it.each([ + { label: "unset", commandMenu: undefined }, + { label: "enabled", commandMenu: true }, + ])("syncs the native command menu before startup when $label", async ({ commandMenu }) => { + const socket = new FakeSocket(); + mocks.client.websocket.mockReturnValue(socket); + const abort = new AbortController(); + const ctx = createGatewayContext(abort.signal, { commandMenu }); + const run = startClickClackGatewayAccount(ctx); + + await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1)); + + expect(mocks.client.setBotCommands).toHaveBeenCalledTimes(1); + expect(mocks.client.me.mock.invocationCallOrder[0]).toBeLessThan( + mocks.client.setBotCommands.mock.invocationCallOrder[0] ?? 0, + ); + expect(mocks.client.setBotCommands.mock.invocationCallOrder[0]).toBeLessThan( + mocks.client.eventPage.mock.invocationCallOrder[0] ?? 0, + ); + expect(mocks.client.setBotCommands.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(ctx.setStatus).mock.invocationCallOrder[0] ?? 0, + ); + + abort.abort(); + await run; + }); + + it("skips command menu sync when explicitly disabled", async () => { + const socket = new FakeSocket(); + mocks.client.websocket.mockReturnValue(socket); + const abort = new AbortController(); + const ctx = createGatewayContext(abort.signal, { commandMenu: false }); + const run = startClickClackGatewayAccount(ctx); + + await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1)); + + expect(mocks.client.setBotCommands).not.toHaveBeenCalled(); + + abort.abort(); + await run; + }); + + it.each([ + { + label: "missing command scope", + error: { status: 403 }, + level: "warn" as const, + message: "ClickClack command menu sync skipped: bot token lacks commands:write", + }, + { + label: "older server", + error: { status: 404 }, + level: "debug" as const, + message: + "ClickClack command menu sync skipped: server does not support /api/bots/self/commands", + }, + { + label: "network failure", + error: new Error("network unavailable"), + level: "warn" as const, + message: "ClickClack command menu sync failed: network unavailable", + }, + ])("continues startup after $label", async ({ error, level, message }) => { + mocks.client.setBotCommands.mockRejectedValueOnce(error); + const socket = new FakeSocket(); + mocks.client.websocket.mockReturnValue(socket); + const abort = new AbortController(); + const ctx = createGatewayContext(abort.signal); + const run = startClickClackGatewayAccount(ctx); + + await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1)); + + expect(ctx.log?.[level]).toHaveBeenCalledWith(message); + expect(ctx.setStatus).toHaveBeenCalledWith({ + accountId: "default", + running: true, + configured: true, + enabled: true, + baseUrl: "https://clickclack.example", + }); + + abort.abort(); + await run; + }); + it("opens realtime from the server startup tail without dispatching the returned page", async () => { mocks.client.eventPage.mockResolvedValueOnce({ events: [createBacklogEvent(1)], diff --git a/extensions/clickclack/src/gateway.ts b/extensions/clickclack/src/gateway.ts index ed5d7ecebafa..1567baf18cdf 100644 --- a/extensions/clickclack/src/gateway.ts +++ b/extensions/clickclack/src/gateway.ts @@ -7,6 +7,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { RawData } from "ws"; import { resolveClickClackInboundAccess } from "./access.js"; import { resolveClickClackAccount } from "./accounts.js"; +import { syncClickClackCommandMenu } from "./command-menu.js"; import { createClickClackClient, normalizeClickClackCorrelationId } from "./http-client.js"; import { handleClickClackInbound } from "./inbound.js"; import { resolveWorkspaceId } from "./resolve.js"; @@ -186,6 +187,9 @@ export async function startClickClackGatewayAccount( workspace: workspaceId, botUserId: configuredAccount.botUserId ?? me.id, }; + if (account.commandMenu !== false) { + await syncClickClackCommandMenu({ cfg: ctx.cfg, client, log: ctx.log }); + } ctx.setStatus({ accountId: account.accountId, running: true,