feat: add ClickClack setup wizard

This commit is contained in:
Shakker
2026-07-15 06:50:58 +01:00
committed by Shakker
parent c3ab3133d8
commit 8dec391216
7 changed files with 513 additions and 19 deletions

View File

@@ -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",
},
});

View File

@@ -0,0 +1,2 @@
// Keep ClickClack setup loads isolated from the full channel runtime surface.
export { clickClackSetupPlugin } from "./src/channel.setup.js";

View File

@@ -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<ResolvedClickClackAccount>["config"];
export { DEFAULT_ACCOUNT_ID };

View File

@@ -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<ResolvedClickClackAccount> = {
id: "clickclack",
meta: clickClackMeta,
capabilities: {
chatTypes: ["direct", "group"],
threads: true,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.clickclack"] },
configSchema: clickClackConfigSchema,
config: clickClackConfigAdapter,
setup: clickClackSetupAdapter,
setupWizard: clickClackSetupWizard,
};

View File

@@ -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<ResolvedClickClackAccount> = createChatChannelPlugin({
base: {
id: CHANNEL_ID,
meta,
meta: clickClackMeta,
capabilities: {
chatTypes: ["direct", "group"],
threads: true,
@@ -125,17 +126,9 @@ export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = 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,

View File

@@ -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",
);
});
});

View File

@@ -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),
};