From 8dec3912160d12daadc08403df066176ffdbe7c5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 06:50:58 +0100 Subject: [PATCH] feat: add ClickClack setup wizard --- extensions/clickclack/setup-entry.ts | 10 + extensions/clickclack/setup-plugin-api.ts | 2 + extensions/clickclack/src/channel-config.ts | 27 ++ extensions/clickclack/src/channel.setup.ts | 22 ++ extensions/clickclack/src/channel.ts | 31 +-- .../clickclack/src/setup-surface.test.ts | 256 ++++++++++++++++++ extensions/clickclack/src/setup-surface.ts | 184 +++++++++++++ 7 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 extensions/clickclack/setup-entry.ts create mode 100644 extensions/clickclack/setup-plugin-api.ts create mode 100644 extensions/clickclack/src/channel-config.ts create mode 100644 extensions/clickclack/src/channel.setup.ts create mode 100644 extensions/clickclack/src/setup-surface.test.ts create mode 100644 extensions/clickclack/src/setup-surface.ts diff --git a/extensions/clickclack/setup-entry.ts b/extensions/clickclack/setup-entry.ts new file mode 100644 index 000000000000..e5b38461fc29 --- /dev/null +++ b/extensions/clickclack/setup-entry.ts @@ -0,0 +1,10 @@ +// ClickClack plugin module implements its setup-only bundled entry. +import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; + +export default defineBundledChannelSetupEntry({ + importMetaUrl: import.meta.url, + plugin: { + specifier: "./setup-plugin-api.js", + exportName: "clickClackSetupPlugin", + }, +}); diff --git a/extensions/clickclack/setup-plugin-api.ts b/extensions/clickclack/setup-plugin-api.ts new file mode 100644 index 000000000000..552ab218257d --- /dev/null +++ b/extensions/clickclack/setup-plugin-api.ts @@ -0,0 +1,2 @@ +// Keep ClickClack setup loads isolated from the full channel runtime surface. +export { clickClackSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/clickclack/src/channel-config.ts b/extensions/clickclack/src/channel-config.ts new file mode 100644 index 000000000000..9dcd6c738270 --- /dev/null +++ b/extensions/clickclack/src/channel-config.ts @@ -0,0 +1,27 @@ +// ClickClack plugin module shares channel metadata and account config behavior. +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; +import { + DEFAULT_ACCOUNT_ID, + listClickClackAccountIds, + resolveClickClackAccount, + resolveDefaultClickClackAccountId, +} from "./accounts.js"; +import type { CoreConfig, ResolvedClickClackAccount } from "./types.js"; + +export const CLICKCLACK_CHANNEL_ID = "clickclack" as const; +export const clickClackMeta = { ...getChatChannelMeta(CLICKCLACK_CHANNEL_ID) }; + +export const clickClackConfigAdapter = { + listAccountIds: (cfg) => listClickClackAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultClickClackAccountId(cfg as CoreConfig), + isConfigured: (account) => account.configured, + resolveAllowFrom: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).allowFrom, + resolveDefaultTo: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).defaultTo, +} satisfies ChannelPlugin["config"]; + +export { DEFAULT_ACCOUNT_ID }; diff --git a/extensions/clickclack/src/channel.setup.ts b/extensions/clickclack/src/channel.setup.ts new file mode 100644 index 000000000000..c8f0c70ea35d --- /dev/null +++ b/extensions/clickclack/src/channel.setup.ts @@ -0,0 +1,22 @@ +// ClickClack plugin module exposes a setup-only channel surface. +import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import { clickClackConfigAdapter, clickClackMeta } from "./channel-config.js"; +import { clickClackConfigSchema } from "./config-schema.js"; +import { clickClackSetupAdapter } from "./setup-core.js"; +import { clickClackSetupWizard } from "./setup-surface.js"; +import type { ResolvedClickClackAccount } from "./types.js"; + +export const clickClackSetupPlugin: ChannelPlugin = { + id: "clickclack", + meta: clickClackMeta, + capabilities: { + chatTypes: ["direct", "group"], + threads: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.clickclack"] }, + configSchema: clickClackConfigSchema, + config: clickClackConfigAdapter, + setup: clickClackSetupAdapter, + setupWizard: clickClackSetupWizard, +}; diff --git a/extensions/clickclack/src/channel.ts b/extensions/clickclack/src/channel.ts index 8c2731de4824..b7b8cd9a084b 100644 --- a/extensions/clickclack/src/channel.ts +++ b/extensions/clickclack/src/channel.ts @@ -12,17 +12,17 @@ import { createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, } from "openclaw/plugin-sdk/channel-outbound"; -import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; +import { resolveClickClackAccount } from "./accounts.js"; import { + CLICKCLACK_CHANNEL_ID, + clickClackConfigAdapter, + clickClackMeta, DEFAULT_ACCOUNT_ID, - listClickClackAccountIds, - resolveClickClackAccount, - resolveDefaultClickClackAccountId, -} from "./accounts.js"; +} from "./channel-config.js"; import { clickClackConfigSchema } from "./config-schema.js"; import { startClickClackGatewayAccount } from "./gateway.js"; import { @@ -30,6 +30,8 @@ import { sendClickClackMedia, sendClickClackText, } from "./outbound.js"; +import { clickClackSetupAdapter } from "./setup-core.js"; +import { clickClackSetupWizard } from "./setup-surface.js"; import { buildClickClackTarget, looksLikeClickClackTarget, @@ -38,8 +40,7 @@ import { } from "./target.js"; import type { CoreConfig, ResolvedClickClackAccount } from "./types.js"; -const CHANNEL_ID = "clickclack" as const; -const meta = { ...getChatChannelMeta(CHANNEL_ID) }; +const CHANNEL_ID = CLICKCLACK_CHANNEL_ID; const clickClackMessageAdapter = defineChannelMessageAdapter({ id: CHANNEL_ID, @@ -117,7 +118,7 @@ const clickClackMessageAdapter = defineChannelMessageAdapter({ export const clickClackPlugin: ChannelPlugin = createChatChannelPlugin({ base: { id: CHANNEL_ID, - meta, + meta: clickClackMeta, capabilities: { chatTypes: ["direct", "group"], threads: true, @@ -125,17 +126,9 @@ export const clickClackPlugin: ChannelPlugin = create }, reload: { configPrefixes: ["channels.clickclack"] }, configSchema: clickClackConfigSchema, - config: { - listAccountIds: (cfg) => listClickClackAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => - resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultClickClackAccountId(cfg as CoreConfig), - isConfigured: (account) => account.configured, - resolveAllowFrom: ({ cfg, accountId }) => - resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).allowFrom, - resolveDefaultTo: ({ cfg, accountId }) => - resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).defaultTo, - }, + config: clickClackConfigAdapter, + setup: clickClackSetupAdapter, + setupWizard: clickClackSetupWizard, messaging: { targetPrefixes: ["clickclack", "cc"], normalizeTarget: normalizeClickClackTarget, diff --git a/extensions/clickclack/src/setup-surface.test.ts b/extensions/clickclack/src/setup-surface.test.ts new file mode 100644 index 000000000000..5d2b6bb0c57c --- /dev/null +++ b/extensions/clickclack/src/setup-surface.test.ts @@ -0,0 +1,256 @@ +// ClickClack tests cover guided setup prompts and nonfatal live validation. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createPluginSetupWizardConfigure, + createQueuedWizardPrompter, + createTestWizardPrompter, + runSetupWizardConfigure, + runSetupWizardFinalize, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + me: vi.fn(), + resolveWorkspaceId: vi.fn(), + workspaces: vi.fn(), +})); + +vi.mock("./http-client.js", () => ({ + createClickClackClient: () => ({ + me: mocks.me, + workspaces: mocks.workspaces, + }), +})); + +vi.mock("./resolve.js", () => ({ + resolveWorkspaceId: mocks.resolveWorkspaceId, +})); + +import { clickClackSetupPlugin } from "./channel.setup.js"; +import { clickClackSetupWizard } from "./setup-surface.js"; +import type { CoreConfig } from "./types.js"; + +const configuredAccount = { + channels: { + clickclack: { + baseUrl: "https://clickclack.example", + token: "ccb_test", + workspace: "default", + }, + }, +} satisfies CoreConfig; + +function tokenCredential() { + const credential = clickClackSetupWizard.credentials[0]; + if (!credential) { + throw new Error("expected ClickClack token credential"); + } + return credential; +} + +describe("ClickClack setup wizard", () => { + beforeEach(() => { + mocks.me.mockReset(); + mocks.resolveWorkspaceId.mockReset(); + mocks.workspaces.mockReset(); + mocks.me.mockResolvedValue({ id: "usr_bot", handle: "openclaw" }); + mocks.resolveWorkspaceId.mockResolvedValue("wsp_default"); + mocks.workspaces.mockResolvedValue([ + { id: "wsp_default", name: "Default", slug: "default", created_at: "2026-01-01" }, + ]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("detects configured accounts and inspects plain, env, and file credentials", async () => { + expect(await clickClackSetupWizard.status.resolveConfigured({ cfg: configuredAccount })).toBe( + true, + ); + + const credential = tokenCredential(); + expect( + credential.inspect({ + cfg: configuredAccount, + accountId: "default", + }), + ).toMatchObject({ + accountConfigured: true, + hasConfiguredValue: true, + resolvedValue: "ccb_test", + }); + + vi.stubEnv("CLICKCLACK_BOT_TOKEN", "ccb_env"); + expect( + credential.inspect({ + cfg: { + channels: { + clickclack: { + baseUrl: "https://clickclack.example", + workspace: "default", + }, + }, + } as CoreConfig, + accountId: "default", + }), + ).toMatchObject({ + accountConfigured: true, + hasConfiguredValue: false, + resolvedValue: "ccb_env", + envValue: "ccb_env", + }); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clickclack-setup-token-")); + const tokenFile = path.join(tempDir, "token"); + fs.writeFileSync(tokenFile, "ccb_file\n", "utf8"); + try { + expect( + credential.inspect({ + cfg: { + channels: { + clickclack: { + baseUrl: "https://clickclack.example", + tokenFile, + workspace: "default", + }, + }, + } as CoreConfig, + accountId: "default", + }), + ).toMatchObject({ + accountConfigured: true, + hasConfiguredValue: true, + resolvedValue: "ccb_file", + }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("switches the default account to env auth before URL and workspace prompts", async () => { + const credential = tokenCredential(); + const next = await credential.applyUseEnv?.({ + cfg: { + channels: { + clickclack: { + baseUrl: "https://clickclack.example", + token: "ccb_stale", + tokenFile: "/run/secrets/stale", + workspace: "default", + }, + }, + } as CoreConfig, + accountId: "default", + }); + + expect(next?.channels?.clickclack).not.toHaveProperty("token"); + expect(next?.channels?.clickclack).not.toHaveProperty("tokenFile"); + expect(next?.channels?.clickclack).toMatchObject({ + baseUrl: "https://clickclack.example", + workspace: "default", + }); + }); + + it("saves URL, token, and workspace through the guided flow", async () => { + const queued = createQueuedWizardPrompter({ + textValues: ["ccb_guided", "https://clickclack.example/", "default"], + }); + + const result = await runSetupWizardConfigure({ + configure: createPluginSetupWizardConfigure(clickClackSetupPlugin), + cfg: {} as CoreConfig, + prompter: queued.prompter, + options: { secretInputMode: "plaintext" as const }, + }); + + expect(result.cfg.channels?.clickclack).toMatchObject({ + enabled: true, + token: "ccb_guided", + baseUrl: "https://clickclack.example", + workspace: "default", + }); + expect(mocks.me).toHaveBeenCalledTimes(1); + expect(mocks.resolveWorkspaceId).toHaveBeenCalledTimes(1); + }); + + it("reports the resolved bot and workspace after live validation", async () => { + const note = vi.fn(async () => undefined); + + await runSetupWizardFinalize({ + finalize: clickClackSetupWizard.finalize, + cfg: configuredAccount, + prompter: createTestWizardPrompter({ note }), + }); + + expect(mocks.me.mock.invocationCallOrder[0]).toBeLessThan( + mocks.resolveWorkspaceId.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(note).toHaveBeenCalledWith( + "Connected as @openclaw — workspace Default resolved.", + "ClickClack connection", + ); + }); + + it("keeps setup saved when the token is rejected", async () => { + mocks.me.mockRejectedValue({ status: 401 }); + const note = vi.fn(async () => undefined); + + await expect( + runSetupWizardFinalize({ + finalize: clickClackSetupWizard.finalize, + cfg: configuredAccount, + prompter: createTestWizardPrompter({ note }), + }), + ).resolves.toBeUndefined(); + expect(note).toHaveBeenCalledWith( + "ClickClack rejected the bot token (401). Copy a current token and rerun setup.", + "ClickClack connection check", + ); + }); + + it("keeps setup saved when workspace resolution fails", async () => { + mocks.resolveWorkspaceId.mockRejectedValue( + new Error("ClickClack workspace not found: missing"), + ); + const note = vi.fn(async () => undefined); + + await expect( + runSetupWizardFinalize({ + finalize: clickClackSetupWizard.finalize, + cfg: { + channels: { + clickclack: { + ...configuredAccount.channels.clickclack, + workspace: "missing", + }, + }, + }, + prompter: createTestWizardPrompter({ note }), + }), + ).resolves.toBeUndefined(); + expect(note).toHaveBeenCalledWith( + 'Workspace "missing" was not found. Check the id, slug, or name, list available workspaces, and rerun setup.', + "ClickClack connection check", + ); + }); + + it("keeps setup saved when the server is unreachable", async () => { + mocks.me.mockRejectedValue(new Error("network unavailable")); + const note = vi.fn(async () => undefined); + + await expect( + runSetupWizardFinalize({ + finalize: clickClackSetupWizard.finalize, + cfg: configuredAccount, + prompter: createTestWizardPrompter({ note }), + }), + ).resolves.toBeUndefined(); + expect(note).toHaveBeenCalledWith( + "Connection check failed: network unavailable. Setup was saved; fix the connection and rerun setup.", + "ClickClack connection check", + ); + }); +}); diff --git a/extensions/clickclack/src/setup-surface.ts b/extensions/clickclack/src/setup-surface.ts new file mode 100644 index 000000000000..1625670dccca --- /dev/null +++ b/extensions/clickclack/src/setup-surface.ts @@ -0,0 +1,184 @@ +// ClickClack plugin module implements guided setup behavior. +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + createStandardChannelSetupStatus, + createSetupTranslator, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, + setSetupChannelEnabled, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; +import { listClickClackAccountIds, resolveClickClackAccount } from "./accounts.js"; +import { createClickClackClient } from "./http-client.js"; +import { resolveWorkspaceId } from "./resolve.js"; +import { + applyClickClackCredentialConfig, + applyClickClackSetupConfigPatch, + normalizeClickClackBaseUrl, +} from "./setup-core.js"; +import type { CoreConfig } from "./types.js"; + +const t = createSetupTranslator(); +const channel = "clickclack" as const; + +function isHttpStatus(error: unknown, status: number): boolean { + return ( + typeof error === "object" && + error !== null && + "status" in error && + (error as { status?: unknown }).status === status + ); +} + +function isWorkspaceNotFound(error: unknown): boolean { + return error instanceof Error && error.message.startsWith("ClickClack workspace not found:"); +} + +export const clickClackSetupWizard: ChannelSetupWizard = { + channel, + status: createStandardChannelSetupStatus({ + channelLabel: "ClickClack", + configuredLabel: t("wizard.channels.statusConfigured"), + unconfiguredLabel: t("wizard.channels.statusNeedsSetup"), + configuredHint: t("wizard.channels.statusSelfHostedChat"), + unconfiguredHint: t("wizard.channels.statusNeedsSetup"), + configuredScore: 2, + unconfiguredScore: 1, + resolveConfigured: ({ cfg, accountId }) => + (accountId ? [accountId] : listClickClackAccountIds(cfg as CoreConfig)).some( + (resolvedAccountId) => + resolveClickClackAccount({ + cfg: cfg as CoreConfig, + accountId: resolvedAccountId, + }).configured, + ), + }), + introNote: { + title: t("wizard.clickclack.botTokenTitle"), + lines: [ + t("wizard.clickclack.helpCreateToken"), + t("wizard.channels.docs", { + link: formatDocsLink("/channels/clickclack", "clickclack"), + }), + ], + shouldShow: ({ cfg, accountId }) => + !resolveClickClackAccount({ + cfg: cfg as CoreConfig, + accountId, + }).configured, + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: t("wizard.clickclack.botToken"), + preferredEnvVar: "CLICKCLACK_BOT_TOKEN", + envPrompt: t("wizard.clickclack.envPrompt"), + keepPrompt: t("wizard.clickclack.botTokenKeep"), + inputPrompt: t("wizard.clickclack.botTokenInput"), + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveClickClackAccount({ + cfg: cfg as CoreConfig, + accountId, + }); + return { + accountConfigured: resolved.configured, + hasConfiguredValue: + hasConfiguredSecretInput(resolved.config.token) || + Boolean(resolved.config.tokenFile?.trim()), + resolvedValue: resolved.token || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.CLICKCLACK_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async ({ cfg, accountId }) => + applyClickClackCredentialConfig({ + cfg, + accountId, + useEnv: true, + }), + applySet: async ({ cfg, accountId, value }) => + applyClickClackCredentialConfig({ + cfg, + accountId, + token: value, + }), + }, + ], + textInputs: [ + { + inputKey: "baseUrl", + message: t("wizard.clickclack.baseUrlPrompt"), + currentValue: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined, + initialValue: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined, + validate: ({ value }) => + normalizeClickClackBaseUrl(value) + ? undefined + : "ClickClack server URL must be a valid http(s) URL.", + normalizeValue: ({ value }) => normalizeClickClackBaseUrl(value) ?? value.trim(), + applySet: async ({ cfg, accountId, value }) => + applyClickClackSetupConfigPatch({ + cfg, + accountId, + patch: { baseUrl: value }, + }), + }, + { + inputKey: "workspace", + message: t("wizard.clickclack.workspacePrompt"), + helpTitle: t("wizard.clickclack.workspacePrompt"), + helpLines: [t("wizard.clickclack.workspaceHelp")], + currentValue: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).workspace || undefined, + initialValue: ({ cfg, accountId }) => + resolveClickClackAccount({ cfg: cfg as CoreConfig, accountId }).workspace || undefined, + validate: ({ value }) => (value.trim() ? undefined : "Required"), + normalizeValue: ({ value }) => value.trim(), + applySet: async ({ cfg, accountId, value }) => + applyClickClackSetupConfigPatch({ + cfg, + accountId, + patch: { workspace: value }, + }), + }, + ], + finalize: async ({ cfg, accountId, prompter }) => { + const account = resolveClickClackAccount({ + cfg: cfg as CoreConfig, + accountId, + }); + try { + const client = createClickClackClient({ + baseUrl: account.baseUrl, + token: account.token, + }); + const me = await client.me(); + const workspaceId = await resolveWorkspaceId(client, account.workspace); + const workspaces = await client.workspaces(); + const workspace = workspaces.find((candidate) => candidate.id === workspaceId); + await prompter.note( + t("wizard.clickclack.connected", { + handle: me.handle, + workspace: workspace?.name ?? workspaceId, + }), + t("wizard.clickclack.connectionTitle"), + ); + } catch (error) { + const message = isHttpStatus(error, 401) + ? t("wizard.clickclack.invalidToken") + : isWorkspaceNotFound(error) + ? t("wizard.clickclack.workspaceNotFound", { workspace: account.workspace }) + : t("wizard.clickclack.connectionFailed", { + error: formatErrorMessage(error), + }); + await prompter.note(message, t("wizard.clickclack.validationWarningTitle")); + } + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +};