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
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:
87
extensions/clickclack/src/access.ts
Normal file
87
extensions/clickclack/src/access.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
183
extensions/clickclack/src/accounts.test.ts
Normal file
183
extensions/clickclack/src/accounts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
161
extensions/clickclack/src/accounts.ts
Normal file
161
extensions/clickclack/src/accounts.ts
Normal 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);
|
||||
}
|
||||
189
extensions/clickclack/src/channel.ts
Normal file
189
extensions/clickclack/src/channel.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
37
extensions/clickclack/src/config-schema.ts
Normal file
37
extensions/clickclack/src/config-schema.ts
Normal 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);
|
||||
295
extensions/clickclack/src/gateway.test.ts
Normal file
295
extensions/clickclack/src/gateway.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
242
extensions/clickclack/src/gateway.ts
Normal file
242
extensions/clickclack/src/gateway.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
151
extensions/clickclack/src/http-client.test.ts
Normal file
151
extensions/clickclack/src/http-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
155
extensions/clickclack/src/http-client.ts
Normal file
155
extensions/clickclack/src/http-client.ts
Normal 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>;
|
||||
318
extensions/clickclack/src/inbound.test.ts
Normal file
318
extensions/clickclack/src/inbound.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
221
extensions/clickclack/src/inbound.ts
Normal file
221
extensions/clickclack/src/inbound.ts
Normal 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)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
44
extensions/clickclack/src/outbound.ts
Normal file
44
extensions/clickclack/src/outbound.ts
Normal 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 };
|
||||
}
|
||||
44
extensions/clickclack/src/resolve.ts
Normal file
44
extensions/clickclack/src/resolve.ts
Normal 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;
|
||||
}
|
||||
14
extensions/clickclack/src/runtime.ts
Normal file
14
extensions/clickclack/src/runtime.ts
Normal 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 };
|
||||
27
extensions/clickclack/src/target.test.ts
Normal file
27
extensions/clickclack/src/target.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
44
extensions/clickclack/src/target.ts
Normal file
44
extensions/clickclack/src/target.ts
Normal 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;
|
||||
}
|
||||
121
extensions/clickclack/src/types.ts
Normal file
121
extensions/clickclack/src/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user