Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/**
* Maps ClickClack senders and conversations onto the shared channel ingress
* allowlist/command authorization contract.
*/
import {
resolveStableChannelMessageIngress,
type StableChannelIngressIdentityParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getClickClackRuntime } from "./runtime.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const CHANNEL_ID = "clickclack" as const;
function normalizeClickClackUserId(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const withoutProvider = trimmed.replace(/^(clickclack|cc):/i, "").trim();
const directTarget = withoutProvider.match(/^dm:(.+)$/i);
return directTarget?.[1]?.trim() || withoutProvider || null;
}
const clickClackIngressIdentity = {
key: "user-id",
normalizeEntry: normalizeClickClackUserId,
normalizeSubject: normalizeClickClackUserId,
isWildcardEntry: (entry) => normalizeClickClackUserId(entry) === "*",
entryIdPrefix: "clickclack-user",
} satisfies StableChannelIngressIdentityParams;
/**
* Dispatch and command authorization decision for one inbound ClickClack
* message.
*/
export type ClickClackInboundAccess = {
shouldDispatch: boolean;
commandAuthorized: boolean;
};
/**
* Resolves whether a ClickClack message should enter the agent pipeline and
* whether its command-style body may run tools.
*/
export async function resolveClickClackInboundAccess(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
}): Promise<ClickClackInboundAccess> {
const runtime = getClickClackRuntime();
const isDirect = Boolean(params.message.direct_conversation_id);
const cfg = params.config as OpenClawConfig;
const shouldCheckCommand = runtime.channel.commands.shouldComputeCommandAuthorized(
params.message.body,
cfg,
);
const resolved = await resolveStableChannelMessageIngress({
channelId: CHANNEL_ID,
accountId: params.account.accountId,
identity: clickClackIngressIdentity,
cfg,
subject: { stableId: params.message.author_id },
conversation: {
kind: isDirect ? "direct" : "group",
id: isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ?? params.message.thread_root_id),
},
allowFrom: params.account.allowFrom,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
command: shouldCheckCommand
? {
cfg,
modeWhenAccessGroupsOff: "configured",
}
: false,
});
return {
shouldDispatch: resolved.ingress.admission === "dispatch",
commandAuthorized: resolved.commandAccess.requested
? resolved.commandAccess.authorized
: resolved.senderAccess.allowed,
};
}

View File

@@ -0,0 +1,183 @@
// Clickclack tests cover accounts plugin behavior.
import { describe, expect, it } from "vitest";
import {
listClickClackAccountIds,
resolveClickClackAccount,
resolveDefaultClickClackAccountId,
} from "./accounts.js";
import type { CoreConfig } from "./types.js";
describe("ClickClack account resolution", () => {
it("preserves top-level default account when named accounts are configured", () => {
const cfg = {
channels: {
clickclack: {
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
token: "ccb_default",
accounts: {
work: { enabled: false },
},
},
},
} satisfies CoreConfig;
expect(listClickClackAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultClickClackAccountId(cfg)).toBe("default");
expect(resolveClickClackAccount({ cfg }).token).toBe("ccb_default");
});
it("does not synthesize a partial top-level default account from inherited credentials", () => {
const cfg = {
channels: {
clickclack: {
token: "ccb_shared",
accounts: {
work: {
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
},
},
},
},
} satisfies CoreConfig;
expect(listClickClackAccountIds(cfg)).toEqual(["work"]);
expect(resolveDefaultClickClackAccountId(cfg)).toBe("work");
});
it("does not synthesize a default account from blank top-level credentials", () => {
const cfg = {
channels: {
clickclack: {
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_default",
token: " ",
accounts: {
work: {
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
token: "ccb_work",
},
},
},
},
} satisfies CoreConfig;
expect(listClickClackAccountIds(cfg)).toEqual(["work"]);
expect(resolveDefaultClickClackAccountId(cfg)).toBe("work");
});
it("resolves env SecretRefs at runtime", () => {
const cfg = {
channels: {
clickclack: {
enabled: true,
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
accounts: {
service: {
token: { source: "env", provider: "default", id: "CLICKCLACK_SERVICE_TOKEN" },
},
},
},
},
} satisfies CoreConfig;
expect(
resolveClickClackAccount({
cfg,
accountId: "service",
env: { CLICKCLACK_SERVICE_TOKEN: " ccb_live " },
}),
).toEqual({
allowFrom: ["*"],
accountId: "service",
baseUrl: "https://app.clickclack.chat",
config: {
allowFrom: ["*"],
baseUrl: "https://app.clickclack.chat",
enabled: true,
token: { source: "env", provider: "default", id: "CLICKCLACK_SERVICE_TOKEN" },
workspace: "wsp_1",
},
configured: true,
defaultTo: "channel:general",
enabled: true,
reconnectMs: 1_500,
replyMode: "agent",
token: "ccb_live",
workspace: "wsp_1",
});
});
it("resolves model-mode bot account policy", () => {
const cfg = {
channels: {
clickclack: {
enabled: true,
baseUrl: "https://app.clickclack.chat",
workspace: "wsp_1",
accounts: {
peter: {
token: "ccb_peter",
agentId: "peter-bot",
replyMode: "model",
model: "openai/gpt-5.4-mini",
toolsAllow: ["web_search"],
},
},
},
},
} satisfies CoreConfig;
expect(resolveClickClackAccount({ cfg, accountId: "peter" })).toEqual({
allowFrom: ["*"],
accountId: "peter",
agentId: "peter-bot",
baseUrl: "https://app.clickclack.chat",
config: {
agentId: "peter-bot",
allowFrom: ["*"],
baseUrl: "https://app.clickclack.chat",
enabled: true,
model: "openai/gpt-5.4-mini",
replyMode: "model",
token: "ccb_peter",
toolsAllow: ["web_search"],
workspace: "wsp_1",
},
configured: true,
defaultTo: "channel:general",
enabled: true,
model: "openai/gpt-5.4-mini",
reconnectMs: 1_500,
replyMode: "model",
token: "ccb_peter",
toolsAllow: ["web_search"],
workspace: "wsp_1",
});
});
it("normalizes reconnect intervals to the public config bounds", () => {
const cfg = {
channels: {
clickclack: {
enabled: true,
baseUrl: "https://app.clickclack.chat",
token: "ccb_global",
workspace: "wsp_1",
reconnectMs: 1,
accounts: {
slow: {
reconnectMs: 1_000_000,
},
},
},
},
} satisfies CoreConfig;
expect(resolveClickClackAccount({ cfg }).reconnectMs).toBe(100);
expect(resolveClickClackAccount({ cfg, accountId: "slow" }).reconnectMs).toBe(60_000);
});
});

View File

@@ -0,0 +1,161 @@
/**
* Resolves ClickClack account configuration from root channel config, named
* account overrides, and secret-provider references.
*/
import {
createAccountListHelpers,
hasConfiguredAccountValue,
} from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import {
normalizeSecretInputString,
normalizeResolvedSecretInputString,
resolveSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ClickClackAccountConfig, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const DEFAULT_RECONNECT_MS = 1_500;
const MIN_RECONNECT_MS = 100;
const MAX_RECONNECT_MS = 60_000;
const {
listAccountIds: listClickClackAccountIds,
resolveDefaultAccountId: resolveDefaultClickClackAccountId,
} = createAccountListHelpers("clickclack", {
normalizeAccountId,
hasImplicitDefaultAccount: (cfg) => {
const channel = cfg.channels?.clickclack;
return Boolean(
channel?.baseUrl?.trim() &&
hasConfiguredAccountValue(channel.token) &&
channel.workspace?.trim(),
);
},
});
export { DEFAULT_ACCOUNT_ID, listClickClackAccountIds, resolveDefaultClickClackAccountId };
function resolveMergedClickClackAccountConfig(
cfg: CoreConfig,
accountId: string,
): ClickClackAccountConfig {
return resolveMergedAccountConfig<ClickClackAccountConfig>({
channelConfig: cfg.channels?.clickclack as ClickClackAccountConfig | undefined,
accounts: cfg.channels?.clickclack?.accounts,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
function resolveClickClackToken(params: {
cfg: CoreConfig;
value: unknown;
accountId: string;
env?: NodeJS.ProcessEnv;
}): string {
const resolved = resolveSecretInputString({
value: params.value,
path:
params.accountId === DEFAULT_ACCOUNT_ID
? "channels.clickclack.token"
: `channels.clickclack.accounts.${params.accountId}.token`,
defaults: params.cfg.secrets?.defaults,
mode: "inspect",
});
if (resolved.status !== "available") {
if (resolved.status === "configured_unavailable" && resolved.ref.source === "env") {
const providerConfig = params.cfg.secrets?.providers?.[resolved.ref.provider];
if (providerConfig) {
if (providerConfig.source !== "env") {
throw new Error(
`Secret provider "${resolved.ref.provider}" has source "${providerConfig.source}" but ref requests "env".`,
);
}
if (providerConfig.allowlist && !providerConfig.allowlist.includes(resolved.ref.id)) {
throw new Error(
`Environment variable "${resolved.ref.id}" is not allowlisted in secrets.providers.${resolved.ref.provider}.allowlist.`,
);
}
} else if (
resolved.ref.provider !==
resolveDefaultSecretProviderAlias({ secrets: params.cfg.secrets }, "env")
) {
throw new Error(
`Secret provider "${resolved.ref.provider}" is not configured (ref: env:${resolved.ref.provider}:${resolved.ref.id}).`,
);
}
return normalizeSecretInputString((params.env ?? process.env)[resolved.ref.id]) ?? "";
}
return "";
}
return (
normalizeResolvedSecretInputString({
value: resolved.value,
path: "channels.clickclack.token",
}) ?? ""
);
}
/**
* Builds the normalized account snapshot used by gateway, outbound delivery,
* status reporting, and channel routing.
*/
export function resolveClickClackAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): ResolvedClickClackAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = resolveMergedClickClackAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.clickclack?.enabled !== false;
const enabled = baseEnabled && merged.enabled !== false;
const baseUrl = merged.baseUrl?.trim().replace(/\/$/, "") ?? "";
const token = resolveClickClackToken({
cfg: params.cfg,
value: merged.token,
accountId,
env: params.env,
});
const workspace = merged.workspace?.trim() ?? "";
return {
accountId,
enabled,
configured: Boolean(baseUrl && token && workspace),
name: normalizeOptionalString(merged.name),
baseUrl,
token,
workspace,
botUserId: normalizeOptionalString(merged.botUserId),
agentId: normalizeOptionalString(merged.agentId),
replyMode: merged.replyMode === "model" ? "model" : "agent",
model: normalizeOptionalString(merged.model),
systemPrompt: normalizeOptionalString(merged.systemPrompt),
timeoutSeconds: merged.timeoutSeconds,
toolsAllow: merged.toolsAllow,
defaultTo: merged.defaultTo?.trim() || "channel:general",
allowFrom: merged.allowFrom ?? ["*"],
reconnectMs: resolveIntegerOption(merged.reconnectMs, DEFAULT_RECONNECT_MS, {
min: MIN_RECONNECT_MS,
max: MAX_RECONNECT_MS,
}),
config: {
...merged,
allowFrom: merged.allowFrom ?? ["*"],
},
};
}
/**
* Returns all enabled accounts, including the implicit default account when
* legacy top-level ClickClack config is present.
*/
export function listEnabledClickClackAccounts(cfg: CoreConfig): ResolvedClickClackAccount[] {
return listClickClackAccountIds(cfg)
.map((accountId) => resolveClickClackAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,189 @@
/**
* ClickClack channel plugin definition: target parsing, account config, status,
* gateway startup, and outbound delivery wiring.
*/
import {
buildChannelOutboundSessionRoute,
buildThreadAwareOutboundSessionRoute,
createChatChannelPlugin,
} from "openclaw/plugin-sdk/channel-core";
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
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 {
DEFAULT_ACCOUNT_ID,
listClickClackAccountIds,
resolveClickClackAccount,
resolveDefaultClickClackAccountId,
} from "./accounts.js";
import { clickClackConfigSchema } from "./config-schema.js";
import { startClickClackGatewayAccount } from "./gateway.js";
import { sendClickClackText } from "./outbound.js";
import {
buildClickClackTarget,
looksLikeClickClackTarget,
parseClickClackTarget,
normalizeClickClackTarget,
} from "./target.js";
import type { CoreConfig, ResolvedClickClackAccount } from "./types.js";
const CHANNEL_ID = "clickclack" as const;
const meta = { ...getChatChannelMeta(CHANNEL_ID) };
const clickClackMessageAdapter = defineChannelMessageAdapter({
id: CHANNEL_ID,
durableFinal: {
capabilities: {
text: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: async (ctx) => {
const result = await sendClickClackText({
cfg: ctx.cfg as CoreConfig,
accountId: ctx.accountId,
to: ctx.to,
text: ctx.text,
threadId: ctx.threadId,
replyToId: ctx.replyToId,
});
const threadId = ctx.threadId == null ? undefined : String(ctx.threadId);
const replyToId = ctx.replyToId ?? undefined;
return {
messageId: result.messageId,
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: CHANNEL_ID, messageId: result.messageId }],
threadId,
replyToId,
kind: "text",
}),
};
},
},
});
/**
* Channel plugin instance registered by the bundled ClickClack entry.
*/
export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = createChatChannelPlugin({
base: {
id: CHANNEL_ID,
meta,
capabilities: {
chatTypes: ["direct", "group"],
threads: true,
blockStreaming: true,
},
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,
},
messaging: {
targetPrefixes: ["clickclack", "cc"],
normalizeTarget: normalizeClickClackTarget,
inferTargetChatType: ({ to }) => parseClickClackTarget(to).chatType,
targetResolver: {
looksLikeId: looksLikeClickClackTarget,
hint: "<channel:name|dm:usr_id|thread:msg_id>",
},
resolveOutboundSessionRoute: ({
cfg,
agentId,
accountId,
target,
replyToId,
threadId,
currentSessionKey,
}) => {
const parsed = parseClickClackTarget(target);
const baseRoute = buildChannelOutboundSessionRoute({
cfg,
agentId,
channel: CHANNEL_ID,
accountId,
peer: {
kind: parsed.chatType === "direct" ? "direct" : "channel",
id: buildClickClackTarget(parsed),
},
chatType: parsed.chatType,
from: `clickclack:${accountId ?? DEFAULT_ACCOUNT_ID}`,
to: buildClickClackTarget(parsed),
});
return buildThreadAwareOutboundSessionRoute({
route: baseRoute,
replyToId,
threadId: threadId ?? (parsed.kind === "thread" ? parsed.id : undefined),
currentSessionKey,
canRecoverCurrentThread: () => true,
});
},
resolveSessionConversation: ({ rawId }) => {
const parsed = parseClickClackTarget(rawId);
if (parsed.kind === "dm") {
return null;
}
return {
id: parsed.id,
threadId: parsed.kind === "thread" ? parsed.id : undefined,
baseConversationId: parsed.id,
parentConversationCandidates: [parsed.id],
};
},
},
status: createComputedAccountStatusAdapter<ResolvedClickClackAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) => ({
ok: snapshot.configured,
label: snapshot.configured ? "configured" : "missing config",
detail: snapshot.baseUrl ?? "",
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
}),
gateway: {
startAccount: startClickClackGatewayAccount,
},
message: clickClackMessageAdapter,
},
outbound: {
base: {
deliveryMode: "direct",
},
attachedResults: {
channel: CHANNEL_ID,
sendText: async ({ cfg, to, text, accountId, threadId, replyToId }) =>
await sendClickClackText({
cfg: cfg as CoreConfig,
accountId,
to,
text,
threadId,
replyToId,
}),
},
},
});

View File

@@ -0,0 +1,37 @@
/**
* Zod-backed config schema for ClickClack channel accounts.
*/
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
import { z } from "zod";
const ClickClackAccountConfigSchema = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
baseUrl: z.string().url().optional(),
token: buildSecretInputSchema().optional(),
workspace: z.string().optional(),
botUserId: z.string().optional(),
agentId: z.string().optional(),
replyMode: z.enum(["agent", "model"]).optional(),
model: z.string().optional(),
systemPrompt: z.string().optional(),
timeoutSeconds: z.number().int().min(1).max(3_600).optional(),
toolsAllow: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
allowFrom: z.array(z.string()).optional(),
reconnectMs: z.number().int().min(100).max(60_000).optional(),
})
.strict();
const ClickClackConfigSchema = ClickClackAccountConfigSchema.extend({
accounts: z.record(z.string(), ClickClackAccountConfigSchema.partial()).optional(),
defaultAccount: z.string().optional(),
}).strict();
/**
* Config schema exported to core so `openclaw doctor` and config validation
* understand both default and named ClickClack accounts.
*/
export const clickClackConfigSchema = buildChannelConfigSchema(ClickClackConfigSchema);

View File

@@ -0,0 +1,295 @@
// Clickclack tests cover gateway plugin behavior.
import { EventEmitter } from "node:events";
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedClickClackAccount } from "./types.js";
class FakeSocket extends EventEmitter {
emitErrorOnClose = false;
close = vi.fn(() => {
if (this.emitErrorOnClose) {
this.emit("error", new Error("socket closed while connecting"));
}
this.emit("close");
});
}
const mocks = vi.hoisted(() => ({
client: {
me: vi.fn(),
events: vi.fn(),
websocket: vi.fn(),
channelMessages: vi.fn(),
directMessages: vi.fn(),
thread: vi.fn(),
},
handleClickClackInbound: vi.fn(),
resolveClickClackInboundAccess: vi.fn(),
resolveWorkspaceId: vi.fn(),
}));
vi.mock("./access.js", () => ({
resolveClickClackInboundAccess: mocks.resolveClickClackInboundAccess,
}));
vi.mock("./http-client.js", () => ({
createClickClackClient: vi.fn(() => mocks.client),
}));
vi.mock("./inbound.js", () => ({
handleClickClackInbound: mocks.handleClickClackInbound,
}));
vi.mock("./resolve.js", () => ({
resolveWorkspaceId: mocks.resolveWorkspaceId,
}));
import { startClickClackGatewayAccount } from "./gateway.js";
function createGatewayContext(
abortSignal: AbortSignal,
): ChannelGatewayContext<ResolvedClickClackAccount> {
const setStatus = vi.fn();
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
return {
cfg: {
channels: {
clickclack: {
baseUrl: "https://clickclack.example",
token: "test-token",
workspace: "main",
reconnectMs: 1,
},
},
} as ChannelGatewayContext<ResolvedClickClackAccount>["cfg"],
accountId: "default",
account: {} as ResolvedClickClackAccount,
runtime: {} as ChannelGatewayContext<ResolvedClickClackAccount>["runtime"],
abortSignal,
log,
getStatus: () =>
({ accountId: "default" }) as ReturnType<
ChannelGatewayContext<ResolvedClickClackAccount>["getStatus"]
>,
setStatus,
};
}
describe("ClickClack gateway", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.client.me.mockResolvedValue({
id: "bot-user",
display_name: "Bot",
handle: "bot",
avatar_url: "",
created_at: "2026-01-01T00:00:00.000Z",
});
mocks.client.events.mockResolvedValue([]);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: true,
commandAuthorized: true,
});
mocks.resolveWorkspaceId.mockResolvedValue("workspace-1");
mocks.client.channelMessages.mockResolvedValue([
{
id: "msg-1",
workspace_id: "workspace-1",
channel_id: "chan-1",
author_id: "human-1",
thread_root_id: "msg-1",
body: "hello",
body_format: "markdown",
created_at: "2026-01-01T00:00:00.000Z",
author: {
id: "human-1",
kind: "human",
display_name: "Human",
handle: "human",
avatar_url: "",
created_at: "2026-01-01T00:00:00.000Z",
},
},
]);
});
it("skips malformed websocket frames without stopping the monitor", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
let runError: unknown;
const run = startClickClackGatewayAccount(ctx).catch((error: unknown) => {
runError = error;
});
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit("message", Buffer.from("{not json"));
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(runError).toBeUndefined();
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] skipped malformed ClickClack websocket event",
);
socket.emit(
"message",
Buffer.from(
JSON.stringify({
id: "evt-1",
cursor: "cursor-1",
type: "message.created",
workspace_id: "workspace-1",
channel_id: "chan-1",
seq: 2,
created_at: "2026-01-01T00:00:00.000Z",
payload: { message_id: "msg-1", author_id: "human-1" },
}),
),
);
await vi.waitFor(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound.mock.calls[0]?.[0].access).toEqual({
shouldDispatch: true,
commandAuthorized: true,
});
abort.abort();
await run;
expect(runError).toBeUndefined();
});
it("drops messages denied by ClickClack sender access before inbound handling", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
mocks.resolveClickClackInboundAccess.mockResolvedValue({
shouldDispatch: false,
commandAuthorized: false,
});
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit(
"message",
Buffer.from(
JSON.stringify({
id: "evt-1",
cursor: "cursor-1",
type: "message.created",
workspace_id: "workspace-1",
channel_id: "chan-1",
seq: 2,
created_at: "2026-01-01T00:00:00.000Z",
payload: { message_id: "msg-1", author_id: "human-1" },
}),
),
);
await vi.waitFor(() => expect(mocks.resolveClickClackInboundAccess).toHaveBeenCalledTimes(1));
expect(mocks.handleClickClackInbound).not.toHaveBeenCalled();
abort.abort();
await run;
});
it("reconnects after ClickClack websocket errors", async () => {
const firstSocket = new FakeSocket();
firstSocket.emitErrorOnClose = true;
const secondSocket = new FakeSocket();
mocks.client.websocket.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
firstSocket.emit("error", new Error("gateway dropped"));
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(2));
expect(ctx.log?.warn).toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: gateway dropped",
);
abort.abort();
await run;
});
it("does not log reconnect warnings when abort closes a connecting websocket", async () => {
const socket = new FakeSocket();
socket.emitErrorOnClose = true;
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));
abort.abort();
await run;
expect(ctx.log?.warn).not.toHaveBeenCalledWith(
"[default] ClickClack websocket error; reconnecting: socket closed while connecting",
);
expect(mocks.client.websocket).toHaveBeenCalledTimes(1);
});
it("clears running status when backlog polling fails", async () => {
mocks.client.events.mockRejectedValue(new Error("clickclack unavailable"));
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
await expect(startClickClackGatewayAccount(ctx)).rejects.toThrow("clickclack unavailable");
expect(ctx.setStatus).toHaveBeenCalledWith({
accountId: "default",
running: true,
configured: true,
enabled: true,
baseUrl: "https://clickclack.example",
});
expect(ctx.setStatus).toHaveBeenLastCalledWith({
accountId: "default",
running: false,
});
});
it("wraps non-Error ws message rejections with the original value in the message", async () => {
const socket = new FakeSocket();
mocks.client.websocket.mockReturnValue(socket);
const rejection = { code: "ECONNRESET", retryable: true };
mocks.resolveClickClackInboundAccess.mockRejectedValue(rejection);
const abort = new AbortController();
const ctx = createGatewayContext(abort.signal);
const run = startClickClackGatewayAccount(ctx);
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
socket.emit(
"message",
Buffer.from(
JSON.stringify({
id: "evt-1",
cursor: "cursor-1",
type: "message.created",
workspace_id: "workspace-1",
channel_id: "chan-1",
seq: 2,
created_at: "2026-01-01T00:00:00.000Z",
payload: { message_id: "msg-1", author_id: "human-1" },
}),
),
);
await expect(run).rejects.toMatchObject({
message: 'ClickClack ws message failed: {"code":"ECONNRESET","retryable":true}',
cause: rejection,
});
expect(ctx.setStatus).toHaveBeenLastCalledWith({
accountId: "default",
running: false,
});
});
});

View File

@@ -0,0 +1,242 @@
/**
* Gateway loop for polling ClickClack backlog events, opening the realtime
* websocket, and dispatching user messages into OpenClaw.
*/
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { RawData } from "ws";
import { resolveClickClackInboundAccess } from "./access.js";
import { resolveClickClackAccount } from "./accounts.js";
import { createClickClackClient } from "./http-client.js";
import { handleClickClackInbound } from "./inbound.js";
import { resolveWorkspaceId } from "./resolve.js";
import type {
ClickClackEvent,
ClickClackMessage,
CoreConfig,
ResolvedClickClackAccount,
} from "./types.js";
function payloadString(event: ClickClackEvent, key: string): string {
const value = event.payload?.[key];
return typeof value === "string" ? value : "";
}
async function resolveEventMessage(params: {
client: ReturnType<typeof createClickClackClient>;
event: ClickClackEvent;
}): Promise<ClickClackMessage | null> {
const messageId = payloadString(params.event, "message_id");
if (!messageId) {
return null;
}
const directConversationId = payloadString(params.event, "direct_conversation_id");
if (directConversationId && typeof params.event.seq === "number") {
// ClickClack event payloads carry ids and cursors; fetch a narrow window
// around the sequence so the message body/author fields stay authoritative.
const messages = await params.client.directMessages(
directConversationId,
params.event.seq - 1,
10,
);
return messages.find((message) => message.id === messageId) ?? null;
}
if (params.event.type === "thread.reply_created") {
const rootId = payloadString(params.event, "root_message_id");
if (!rootId) {
return null;
}
const thread = await params.client.thread(rootId);
return thread.replies.find((message) => message.id === messageId) ?? null;
}
if (params.event.channel_id && typeof params.event.seq === "number") {
const messages = await params.client.channelMessages(
params.event.channel_id,
params.event.seq - 1,
10,
);
return messages.find((message) => message.id === messageId) ?? null;
}
return null;
}
function decodeSocketMessage(data: RawData): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
return Buffer.concat(data).toString("utf8");
}
function parseSocketEvent(data: RawData): ClickClackEvent | null {
try {
return JSON.parse(decodeSocketMessage(data)) as ClickClackEvent;
} catch {
return null;
}
}
async function processEvent(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
client: ReturnType<typeof createClickClackClient>;
event: ClickClackEvent;
botUserId: string;
}) {
if (params.event.type !== "message.created" && params.event.type !== "thread.reply_created") {
return;
}
if (payloadString(params.event, "author_id") === params.botUserId) {
return;
}
const message = await resolveEventMessage({ client: params.client, event: params.event });
if (!message || message.author_id === params.botUserId) {
return;
}
if (message.author?.kind === "bot") {
return;
}
const access = await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
});
if (!access.shouldDispatch) {
return;
}
await handleClickClackInbound({
account: params.account,
config: params.config,
message,
access,
});
}
export async function startClickClackGatewayAccount(
ctx: ChannelGatewayContext<ResolvedClickClackAccount>,
) {
const configuredAccount = resolveClickClackAccount({
cfg: ctx.cfg,
accountId: ctx.account.accountId,
});
if (!configuredAccount.configured) {
throw new Error(`ClickClack is not configured for account "${configuredAccount.accountId}"`);
}
const client = createClickClackClient({
baseUrl: configuredAccount.baseUrl,
token: configuredAccount.token,
});
const workspaceId = await resolveWorkspaceId(client, configuredAccount.workspace);
const me = await client.me();
const account = {
...configuredAccount,
workspace: workspaceId,
botUserId: configuredAccount.botUserId ?? me.id,
};
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
let afterCursor = "";
let initialized = false;
try {
while (!ctx.abortSignal.aborted) {
const backlog = await client.events(workspaceId, afterCursor);
if (!initialized) {
// First pass establishes the cursor without replaying historical backlog
// into fresh gateway sessions.
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
}
initialized = true;
} else {
for (const event of backlog) {
afterCursor = event.cursor || afterCursor;
await processEvent({
account,
config: ctx.cfg,
client,
event,
botUserId: account.botUserId,
});
}
}
const socket = client.websocket(workspaceId, afterCursor);
await new Promise<void>((resolve, reject) => {
let settled = false;
let removeAbortListener: (() => void) | undefined;
const finishSocketCycle = () => {
if (settled) {
return;
}
settled = true;
removeAbortListener?.();
removeAbortListener = undefined;
resolve();
};
const abort = () => {
socket.close();
finishSocketCycle();
};
ctx.abortSignal.addEventListener("abort", abort, { once: true });
removeAbortListener = () => ctx.abortSignal.removeEventListener("abort", abort);
socket.on("message", (data) => {
void (async () => {
const event = parseSocketEvent(data);
if (!event) {
ctx.log?.warn?.(
`[${account.accountId}] skipped malformed ClickClack websocket event`,
);
return;
}
afterCursor = event.cursor || afterCursor;
await processEvent({
account,
config: ctx.cfg,
client,
event,
botUserId: account.botUserId ?? "",
});
})().catch((e: unknown) =>
reject(
e instanceof Error
? e
: new Error(`ClickClack ws message failed: ${formatErrorMessage(e)}`, {
cause: e,
}),
),
);
});
socket.on("close", finishSocketCycle);
socket.on("error", (error) => {
if (settled || ctx.abortSignal.aborted) {
finishSocketCycle();
return;
}
ctx.log?.warn?.(
`[${account.accountId}] ClickClack websocket error; reconnecting: ${
error instanceof Error ? error.message : String(error)
}`,
);
finishSocketCycle();
socket.close();
});
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => {
setTimeout(resolve, account.reconnectMs);
});
}
}
} finally {
ctx.setStatus({ accountId: account.accountId, running: false });
}
}

View File

@@ -0,0 +1,151 @@
import { createServer, type Server } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
async function listenLoopbackServer(server: Server): Promise<number> {
return await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("expected loopback TCP address"));
return;
}
resolve(address.port);
});
});
}
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
let resolveClosed: (sentBytes: number) => void = () => {};
const closed = new Promise<number>((resolve) => {
resolveClosed = resolve;
});
const server = createServer((req, res) => {
let sentBytes = 0;
let stopped = false;
let prefixSent = false;
const prefixChunk = Buffer.from('{"user":{"id":"');
const bodyChunk = Buffer.alloc(64 * 1024, 0x61);
const suffixChunk = Buffer.from('"}}');
const writeBuffer = (buffer: Buffer) => {
sentBytes += buffer.length;
if (!res.write(buffer)) {
res.once("drain", writeChunks);
return false;
}
return true;
};
const writeChunks = () => {
if (!prefixSent) {
prefixSent = true;
if (!writeBuffer(prefixChunk)) {
return;
}
}
while (true) {
if (stopped) {
return;
}
if (sentBytes + bodyChunk.length + suffixChunk.length >= LOOPBACK_RESPONSE_BYTES) {
break;
}
if (!writeBuffer(bodyChunk)) {
return;
}
}
if (!stopped) {
sentBytes += suffixChunk.length;
res.end(suffixChunk);
}
};
res.writeHead(200, { connection: "close", "content-type": "application/json" });
res.on("close", () => {
stopped = true;
resolveClosed(sentBytes);
});
req.on("aborted", () => {
stopped = true;
res.destroy();
});
writeChunks();
});
return { server, closed };
}
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("raw response.text() should not be used");
});
const response = {
ok: false,
status: 502,
text,
body: {
getReader: () => ({
read: async () => {
if (readCount > 0) {
return { done: true, value: undefined };
}
readCount += 1;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
} as unknown as Response;
return {
response,
cancel,
releaseLock,
text,
expectedDetail: body.slice(0, limit),
};
}
describe("ClickClack HTTP client", () => {
it("bounds oversized success JSON responses and closes the stream early", async () => {
const { server, closed } = createOversizedJsonServer();
const port = await listenLoopbackServer(server);
const client = createClickClackClient({
baseUrl: `http://127.0.0.1:${port}`,
token: "test-token",
});
try {
await expect(client.me()).rejects.toThrow(
"ClickClack response: JSON response exceeds 16777216 bytes",
);
const sentBytes = await closed;
expect(sentBytes).toBeLessThan(LOOPBACK_RESPONSE_BYTES);
} finally {
server.close();
}
});
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);
const client = createClickClackClient({
baseUrl: "https://clickclack.example",
token: "test-token",
fetch: fetchMock,
});
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
expect(streamed.text).not.toHaveBeenCalled();
expect(streamed.cancel).toHaveBeenCalledTimes(1);
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,155 @@
/**
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
* delivery code.
*/
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { WebSocket } from "ws";
import type {
ClickClackChannel,
ClickClackEvent,
ClickClackMessage,
ClickClackUser,
ClickClackWorkspace,
} from "./types.js";
type ClientOptions = {
baseUrl: string;
token: string;
fetch?: typeof fetch;
};
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
/**
* Creates a typed client for the ClickClack API using bearer-token auth.
*/
export function createClickClackClient(options: ClientOptions) {
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetcher = options.fetch ?? fetch;
const headers = {
Authorization: `Bearer ${options.token}`,
Accept: "application/json",
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const requestHeaders = new Headers(init.headers);
for (const [key, value] of Object.entries(headers)) {
requestHeaders.set(key, value);
}
if (init.body && !(init.body instanceof FormData)) {
requestHeaders.set("Content-Type", "application/json");
}
const response = await fetcher(`${baseUrl}${path}`, { ...init, headers: requestHeaders });
if (!response.ok) {
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
throw new Error(`ClickClack ${response.status}: ${detail}`);
}
return await readProviderJsonResponse<T>(response, "ClickClack response");
}
return {
me: async (): Promise<ClickClackUser> => {
const data = await request<{ user: ClickClackUser }>("/api/me");
return data.user;
},
workspaces: async (): Promise<ClickClackWorkspace[]> => {
const data = await request<{ workspaces: ClickClackWorkspace[] }>("/api/workspaces");
return data.workspaces;
},
channels: async (workspaceId: string): Promise<ClickClackChannel[]> => {
const data = await request<{ channels: ClickClackChannel[] }>(
`/api/workspaces/${encodeURIComponent(workspaceId)}/channels`,
);
return data.channels;
},
channelMessages: async (
channelId: string,
afterSeq: number,
limit = 20,
): Promise<ClickClackMessage[]> => {
const data = await request<{ messages: ClickClackMessage[] }>(
`/api/channels/${encodeURIComponent(channelId)}/messages?after_seq=${afterSeq}&limit=${limit}`,
);
return data.messages;
},
directMessages: async (
conversationId: string,
afterSeq: number,
limit = 20,
): Promise<ClickClackMessage[]> => {
const data = await request<{ messages: ClickClackMessage[] }>(
`/api/dms/${encodeURIComponent(conversationId)}/messages?after_seq=${afterSeq}&limit=${limit}`,
);
return data.messages;
},
thread: async (
messageId: string,
): Promise<{ root: ClickClackMessage; replies: ClickClackMessage[] }> =>
await request<{ root: ClickClackMessage; replies: ClickClackMessage[] }>(
`/api/messages/${encodeURIComponent(messageId)}/thread`,
),
createChannelMessage: async (channelId: string, body: string): Promise<ClickClackMessage> => {
const data = await request<{ message: ClickClackMessage }>(
`/api/channels/${encodeURIComponent(channelId)}/messages`,
{ method: "POST", body: JSON.stringify({ body }) },
);
return data.message;
},
createThreadReply: async (messageId: string, body: string): Promise<ClickClackMessage> => {
const data = await request<{ message: ClickClackMessage }>(
`/api/messages/${encodeURIComponent(messageId)}/thread/replies`,
{ method: "POST", body: JSON.stringify({ body }) },
);
return data.message;
},
createDirectConversation: async (
workspaceId: string,
memberIds: string[],
): Promise<{ id: string }> => {
const data = await request<{ conversation: { id: string } }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: workspaceId, member_ids: memberIds }),
});
return data.conversation;
},
createDirectMessage: async (
conversationId: string,
body: string,
): Promise<ClickClackMessage> => {
const data = await request<{ message: ClickClackMessage }>(
`/api/dms/${encodeURIComponent(conversationId)}/messages`,
{ method: "POST", body: JSON.stringify({ body }) },
);
return data.message;
},
events: async (workspaceId: string, afterCursor?: string): Promise<ClickClackEvent[]> => {
const query = new URLSearchParams({ workspace_id: workspaceId });
if (afterCursor) {
query.set("after_cursor", afterCursor);
}
const data = await request<{ events: ClickClackEvent[] }>(
`/api/realtime/events?${query.toString()}`,
);
return data.events;
},
websocket: (workspaceId: string, afterCursor?: string): WebSocket => {
const url = new URL(`${baseUrl}/api/realtime/ws`);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", workspaceId);
if (afterCursor) {
url.searchParams.set("after_cursor", afterCursor);
}
return new WebSocket(url, {
headers: {
Authorization: `Bearer ${options.token}`,
},
});
},
};
}
/** Client shape returned by `createClickClackClient`. */
export type ClickClackClient = ReturnType<typeof createClickClackClient>;

View File

@@ -0,0 +1,318 @@
// Clickclack tests cover inbound plugin behavior.
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { describe, expect, it, vi } from "vitest";
import { handleClickClackInbound } from "./inbound.js";
import { setClickClackRuntime } from "./runtime.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const sendClickClackTextMock = vi.hoisted(() => vi.fn());
type LlmCompleteMock = ReturnType<
typeof vi.fn<
(params: {
agentId?: string;
model?: string;
maxTokens?: number;
purpose?: string;
messages?: unknown[];
}) => Promise<unknown>
>
>;
vi.mock("./outbound.js", () => ({
sendClickClackText: sendClickClackTextMock,
}));
function createRuntime(): PluginRuntime {
return createPluginRuntimeMock({
agent: {
runEmbeddedAgent: vi.fn().mockResolvedValue({
payloads: [{ text: "service bot online" }],
meta: {},
}),
},
channel: {
routing: {
resolveAgentRoute({
accountId,
peer,
}: Parameters<PluginRuntime["channel"]["routing"]["resolveAgentRoute"]>[0]) {
return {
agentId: "main",
channel: "clickclack",
accountId: accountId ?? "default",
sessionKey: `agent:main:clickclack:${peer?.kind ?? "channel"}:${peer?.id ?? "general"}`,
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
};
},
buildAgentSessionKey({
agentId,
channel,
accountId,
peer,
}: Parameters<PluginRuntime["channel"]["routing"]["buildAgentSessionKey"]>[0]) {
return `agent:${agentId}:${channel}:${accountId ?? "default"}:${peer?.kind ?? "channel"}:${peer?.id ?? "general"}`;
},
},
},
llm: {
complete: vi.fn().mockResolvedValue({
text: "service bot online",
provider: "openai",
model: "gpt-5.4-mini",
agentId: "service-bot",
usage: {},
audit: {
caller: { kind: "plugin", id: "clickclack" },
},
}),
},
} as unknown as PluginRuntime);
}
function createAgentAccount(
overrides: Partial<ResolvedClickClackAccount> = {},
): ResolvedClickClackAccount {
const base = {
accountId: "default",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
token: "ccb_default",
workspace: "wsp_1",
replyMode: "agent",
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
reconnectMs: 1_500,
config: {
allowFrom: ["*"],
},
} satisfies ResolvedClickClackAccount;
return {
...base,
...overrides,
config: {
...base.config,
...overrides.config,
},
};
}
function createMessage(overrides: Partial<ClickClackMessage> = {}): ClickClackMessage {
return {
id: "msg_1",
workspace_id: "wsp_1",
channel_id: "chn_1",
author_id: "usr_owner",
thread_root_id: "msg_1",
body: "/fast on",
body_format: "markdown",
created_at: "2026-05-09T12:00:00.000Z",
author: {
id: "usr_owner",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
...overrides,
};
}
describe("handleClickClackInbound", () => {
it("runs model-mode bot accounts without tools and posts the bot reply", async () => {
sendClickClackTextMock.mockReset();
const runtime = createRuntime();
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
const account = {
accountId: "service",
enabled: true,
configured: true,
baseUrl: "http://127.0.0.1:8080",
token: "ccb_service",
workspace: "wsp_1",
agentId: "service-bot",
replyMode: "model",
model: "openai/gpt-5.4-mini",
toolsAllow: [],
defaultTo: "channel:general",
allowFrom: ["*"],
reconnectMs: 1_500,
config: {},
} satisfies ResolvedClickClackAccount;
await handleClickClackInbound({
account,
config: cfg,
message: {
id: "msg_1",
workspace_id: "wsp_1",
channel_id: "chn_1",
author_id: "usr_human",
thread_root_id: "msg_1",
body: "hello bot",
body_format: "markdown",
created_at: "2026-05-09T12:00:00.000Z",
author: {
id: "usr_human",
kind: "human",
display_name: "Peter",
handle: "steipete",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
},
});
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.agent.runEmbeddedAgent).not.toHaveBeenCalled();
const completionRequest = (runtime.llm.complete as LlmCompleteMock).mock.calls[0]?.[0];
expect(completionRequest?.agentId).toBe("service-bot");
expect(completionRequest?.model).toBe("openai/gpt-5.4-mini");
expect(completionRequest?.maxTokens).toBe(96);
expect(completionRequest?.purpose).toBe("clickclack bot reply");
expect(completionRequest?.messages).toEqual([{ role: "user", content: "hello bot" }]);
const sendRequest = sendClickClackTextMock.mock.calls[0]?.[0];
expect(sendRequest?.accountId).toBe("service");
expect(sendRequest?.to).toBe("channel:chn_1");
expect(sendRequest?.text).toBe("service bot online");
expect(sendRequest?.replyToId).toBe("msg_1");
});
it("marks agent turns command-authorized for allowlisted senders", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage(),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("propagates account toolsAllow into agent reply dispatch", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
tools: {
allow: ["*"],
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
toolsAllow: ["message"],
}),
config: cfg,
message: createMessage(),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
const dispatchParams = dispatchReply.mock.calls[0]?.[0] as
| (Record<string, unknown> & {
toolsAllow?: unknown;
})
| undefined;
expect(dispatchParams?.toolsAllow).toEqual(["message"]);
});
it("accepts ClickClack DM target syntax in allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["dm:usr_owner"],
config: { allowFrom: ["dm:usr_owner"] },
}),
config: cfg,
message: createMessage({
channel_id: undefined,
direct_conversation_id: "dcn_1",
}),
});
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
expect(dispatchReply).toHaveBeenCalledTimes(1);
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.ChatType).toBe("direct");
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
});
it("does not dispatch agent turns from senders outside allowFrom", async () => {
const runtime = createRuntime();
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);
setClickClackRuntime(runtime);
const cfg = {
agents: {
defaults: {
model: "openai/gpt-5.4-mini",
},
},
} satisfies CoreConfig;
await handleClickClackInbound({
account: createAgentAccount({
allowFrom: ["usr_owner"],
config: { allowFrom: ["usr_owner"] },
}),
config: cfg,
message: createMessage({
author_id: "usr_attacker",
author: {
id: "usr_attacker",
kind: "human",
display_name: "Attacker",
handle: "attacker",
avatar_url: "",
created_at: "2026-05-09T12:00:00.000Z",
},
}),
});
expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
expect(runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,221 @@
/**
* Converts authorized ClickClack messages into OpenClaw agent/model replies and
* routes resulting outbound text back to ClickClack.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveClickClackInboundAccess, type ClickClackInboundAccess } from "./access.js";
import { sendClickClackText } from "./outbound.js";
import { getClickClackRuntime } from "./runtime.js";
import { buildClickClackTarget } from "./target.js";
import type { ClickClackMessage, CoreConfig, ResolvedClickClackAccount } from "./types.js";
const CHANNEL_ID = "clickclack" as const;
function resolveAccountAgentRoute(params: {
cfg: OpenClawConfig;
account: ResolvedClickClackAccount;
target: string;
isDirect: boolean;
}) {
const runtime = getClickClackRuntime();
const route = runtime.channel.routing.resolveAgentRoute({
cfg: params.cfg,
channel: CHANNEL_ID,
accountId: params.account.accountId,
peer: {
kind: params.isDirect ? "direct" : "channel",
id: params.target,
},
});
const agentId = params.account.agentId ?? route.agentId;
if (agentId === route.agentId) {
return route;
}
return {
...route,
agentId,
sessionKey: runtime.channel.routing.buildAgentSessionKey({
agentId,
channel: CHANNEL_ID,
accountId: params.account.accountId,
peer: {
kind: params.isDirect ? "direct" : "channel",
id: params.target,
},
}),
};
}
async function dispatchModelReply(params: {
account: ResolvedClickClackAccount;
cfg: OpenClawConfig;
message: ClickClackMessage;
route: { agentId: string };
target: string;
}) {
const runtime = getClickClackRuntime();
const result = await runtime.llm.complete({
agentId: params.route.agentId,
model: params.account.model,
maxTokens: 96,
purpose: "clickclack bot reply",
systemPrompt: params.account.systemPrompt,
messages: [
{
role: "user",
content: params.message.body,
},
],
});
const text = result.text.trim();
if (!text) {
return;
}
await sendClickClackText({
cfg: params.cfg as CoreConfig,
accountId: params.account.accountId,
to: params.target,
text,
threadId: params.message.parent_message_id ? params.message.thread_root_id : undefined,
replyToId: params.message.id,
});
}
/**
* Dispatches one already-fetched ClickClack message through the configured
* reply mode for its account.
*/
export async function handleClickClackInbound(params: {
account: ResolvedClickClackAccount;
config: CoreConfig;
message: ClickClackMessage;
access?: ClickClackInboundAccess;
}) {
const runtime = getClickClackRuntime();
const message = params.message;
const access =
params.access ??
(await resolveClickClackInboundAccess({
account: params.account,
config: params.config,
message,
}));
if (!access.shouldDispatch) {
return;
}
const isDirect = Boolean(message.direct_conversation_id);
const target = buildClickClackTarget(
isDirect
? { chatType: "direct", kind: "dm", id: message.author_id }
: { chatType: "group", kind: "channel", id: message.channel_id ?? "" },
);
const route = resolveAccountAgentRoute({
cfg: params.config as OpenClawConfig,
account: params.account,
target,
isDirect,
});
if (params.account.replyMode === "model") {
await dispatchModelReply({
account: params.account,
cfg: params.config as OpenClawConfig,
message,
route,
target,
});
return;
}
const senderName = message.author?.display_name || message.author_id;
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
}),
sessionKey: route.sessionKey,
});
// Preserve both normalized channel fields and ClickClack-native ids so reply
// routing, session recovery, and command authorization see the same message.
const body = runtime.channel.reply.formatAgentEnvelope({
channel: "ClickClack",
from: senderName,
timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
body: message.body,
});
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
agentId: route.agentId,
});
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
RawBody: message.body,
CommandBody: message.body,
From: target,
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? params.account.accountId,
ChatType: isDirect ? "direct" : "group",
WasMentioned: isDirect ? undefined : true,
ConversationLabel: isDirect ? senderName : message.channel_id,
GroupChannel: message.channel_id,
NativeChannelId: message.channel_id || message.direct_conversation_id,
MessageThreadId: message.parent_message_id ? message.thread_root_id : undefined,
ThreadParentId: message.parent_message_id ? message.thread_root_id : undefined,
SenderName: senderName,
SenderId: message.author_id,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
MessageSid: message.id,
MessageSidFull: message.id,
ReplyToId: message.id,
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: access.commandAuthorized,
});
await runtime.channel.inbound.dispatchReply({
cfg: params.config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: params.account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
toolsAllow: params.account.toolsAllow,
delivery: {
deliver: async (payload) => {
const text =
payload && typeof payload === "object" && "text" in payload
? ((payload as { text?: string }).text ?? "")
: "";
if (!text.trim()) {
return;
}
await sendClickClackText({
cfg: params.config,
accountId: params.account.accountId,
to: target,
text,
threadId: message.parent_message_id ? message.thread_root_id : undefined,
replyToId: message.id,
});
},
onError: (error) => {
throw error instanceof Error
? error
: new Error(`clickclack dispatch failed: ${String(error)}`);
},
},
replyPipeline: {},
record: {
onRecordError: (error) => {
throw error instanceof Error
? error
: new Error(`clickclack session record failed: ${String(error)}`);
},
},
});
}

View File

@@ -0,0 +1,44 @@
/**
* Outbound ClickClack delivery helpers for channel messages, thread replies,
* and direct messages.
*/
import { resolveClickClackAccount } from "./accounts.js";
import { createClickClackClient } from "./http-client.js";
import { resolveChannelId, resolveWorkspaceId } from "./resolve.js";
import { parseClickClackTarget } from "./target.js";
import type { CoreConfig } from "./types.js";
/**
* Sends text to a normalized ClickClack target and returns the created message
* id for receipt/session tracking.
*/
export async function sendClickClackText(params: {
cfg: CoreConfig;
accountId?: string | null;
to: string;
text: string;
threadId?: string | number | null;
replyToId?: string | number | null;
}) {
const account = resolveClickClackAccount({ cfg: params.cfg, accountId: params.accountId });
const client = createClickClackClient({ baseUrl: account.baseUrl, token: account.token });
const workspaceId = await resolveWorkspaceId(client, account.workspace);
const parsed = parseClickClackTarget(params.to);
const explicitThreadId = params.threadId == null ? "" : String(params.threadId);
const replyToId = params.replyToId == null ? "" : String(params.replyToId);
if (explicitThreadId || replyToId || parsed.kind === "thread") {
// Explicit thread/reply context wins over the target kind so OpenClaw reply
// hooks keep conversations attached to the original ClickClack root.
const rootId = explicitThreadId || replyToId || parsed.id;
const message = await client.createThreadReply(rootId, params.text);
return { to: params.to, messageId: message.id };
}
if (parsed.kind === "dm") {
const dm = await client.createDirectConversation(workspaceId, [parsed.id]);
const message = await client.createDirectMessage(dm.id, params.text);
return { to: params.to, messageId: message.id };
}
const channelId = await resolveChannelId(client, workspaceId, parsed.id);
const message = await client.createChannelMessage(channelId, params.text);
return { to: params.to, messageId: message.id };
}

View File

@@ -0,0 +1,44 @@
/**
* ClickClack name/id resolution helpers for workspace and channel config.
*/
import type { ClickClackClient } from "./http-client.js";
/**
* Resolves a workspace slug/name/id from config to a ClickClack workspace id.
*/
export async function resolveWorkspaceId(client: ClickClackClient, workspace: string) {
if (workspace.startsWith("wsp_")) {
return workspace;
}
const workspaces = await client.workspaces();
const found = workspaces.find(
(candidate) =>
candidate.id === workspace || candidate.slug === workspace || candidate.name === workspace,
);
if (!found) {
throw new Error(`ClickClack workspace not found: ${workspace}`);
}
return found.id;
}
/**
* Resolves a channel name/id from config or target input to a ClickClack
* channel id.
*/
export async function resolveChannelId(
client: ClickClackClient,
workspaceId: string,
channel: string,
) {
if (channel.startsWith("chn_")) {
return channel;
}
const channels = await client.channels(workspaceId);
const found = channels.find(
(candidate) => candidate.id === channel || candidate.name === channel,
);
if (!found) {
throw new Error(`ClickClack channel not found: ${channel}`);
}
return found.id;
}

View File

@@ -0,0 +1,14 @@
/**
* Runtime store for host-provided OpenClaw services used by the ClickClack
* bundled plugin.
*/
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setClickClackRuntime, getRuntime: getClickClackRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "clickclack",
errorMessage: "ClickClack runtime not initialized",
});
export { getClickClackRuntime, setClickClackRuntime };

View File

@@ -0,0 +1,27 @@
// Clickclack tests cover target plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildClickClackTarget,
normalizeClickClackTarget,
parseClickClackTarget,
} from "./target.js";
describe("ClickClack targets", () => {
it("parses channel targets", () => {
expect(parseClickClackTarget("channel:general")).toEqual({
chatType: "group",
kind: "channel",
id: "general",
});
expect(normalizeClickClackTarget("general")).toBe("channel:general");
});
it("parses thread and dm targets", () => {
expect(buildClickClackTarget(parseClickClackTarget("thread:msg_1"))).toBe("thread:msg_1");
expect(parseClickClackTarget("dm:usr_1")).toEqual({
chatType: "direct",
kind: "dm",
id: "usr_1",
});
});
});

View File

@@ -0,0 +1,44 @@
/**
* Parser and formatter for ClickClack outbound target strings.
*/
import type { ClickClackTarget } from "./types.js";
/**
* Parses `channel:name`, `thread:msg_id`, `dm:usr_id`, or a bare channel name.
*/
export function parseClickClackTarget(raw: string): ClickClackTarget {
const value = raw.trim();
if (!value) {
throw new Error("ClickClack target is required");
}
const [prefix, ...rest] = value.split(":");
const body = rest.join(":").trim();
if (prefix === "channel" && body) {
return { chatType: "group", kind: "channel", id: body };
}
if (prefix === "thread" && body) {
return { chatType: "group", kind: "thread", id: body };
}
if (prefix === "dm" && body) {
return { chatType: "direct", kind: "dm", id: body };
}
if (!body) {
return { chatType: "group", kind: "channel", id: value };
}
throw new Error(`Unsupported ClickClack target: ${raw}`);
}
/** Formats a parsed ClickClack target back into canonical target syntax. */
export function buildClickClackTarget(target: ClickClackTarget): string {
return `${target.kind}:${target.id}`;
}
/** Normalizes user-entered ClickClack target text for channel routing. */
export function normalizeClickClackTarget(raw: string): string {
return buildClickClackTarget(parseClickClackTarget(raw));
}
/** Reports whether a target string can be offered to the ClickClack parser. */
export function looksLikeClickClackTarget(raw: string): boolean {
return /^(channel|thread|dm):/i.test(raw.trim()) || raw.trim().length > 0;
}

View File

@@ -0,0 +1,121 @@
/**
* Shared ClickClack config, runtime account, API object, and target types.
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
/** User-configurable settings for one ClickClack account. */
export type ClickClackAccountConfig = {
name?: string;
enabled?: boolean;
baseUrl?: string;
token?: unknown;
workspace?: string;
botUserId?: string;
agentId?: string;
replyMode?: "agent" | "model";
model?: string;
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo?: string;
allowFrom?: string[];
reconnectMs?: number;
};
/** Root ClickClack channel config with optional named accounts. */
export type ClickClackConfig = ClickClackAccountConfig & {
accounts?: Record<string, Partial<ClickClackAccountConfig>>;
defaultAccount?: string;
};
/** OpenClaw config narrowed to include ClickClack channel settings. */
export type CoreConfig = OpenClawConfig & {
channels?: OpenClawConfig["channels"] & {
clickclack?: ClickClackConfig;
};
};
/** Normalized account snapshot consumed by runtime paths. */
export type ResolvedClickClackAccount = {
accountId: string;
enabled: boolean;
configured: boolean;
name?: string;
baseUrl: string;
token: string;
workspace: string;
botUserId?: string;
agentId?: string;
replyMode: "agent" | "model";
model?: string;
systemPrompt?: string;
timeoutSeconds?: number;
toolsAllow?: string[];
defaultTo: string;
allowFrom: string[];
reconnectMs: number;
config: ClickClackAccountConfig;
};
/** User object returned by the ClickClack API. */
export type ClickClackUser = {
id: string;
kind?: "human" | "bot";
owner_user_id?: string;
display_name: string;
handle: string;
avatar_url: string;
created_at: string;
};
/** Workspace object returned by the ClickClack API. */
export type ClickClackWorkspace = {
id: string;
name: string;
slug: string;
created_at: string;
};
/** Channel object returned by the ClickClack API. */
export type ClickClackChannel = {
id: string;
workspace_id: string;
name: string;
kind: string;
created_at: string;
};
/** Message object returned by ClickClack channel, DM, and thread endpoints. */
export type ClickClackMessage = {
id: string;
workspace_id: string;
channel_id?: string;
direct_conversation_id?: string;
author_id: string;
parent_message_id?: string;
thread_root_id: string;
channel_seq?: number;
thread_seq?: number;
body: string;
body_format: "markdown";
created_at: string;
author?: ClickClackUser;
};
/** Realtime event envelope returned by ClickClack polling/websocket APIs. */
export type ClickClackEvent = {
id: string;
cursor: string;
type: string;
workspace_id: string;
channel_id?: string;
seq?: number;
created_at: string;
payload: Record<string, unknown>;
};
/** Parsed outbound destination for ClickClack delivery. */
export type ClickClackTarget =
| { chatType: "group"; kind: "channel"; id: string }
| { chatType: "group"; kind: "thread"; id: string }
| { chatType: "direct"; kind: "dm"; id: string };