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

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

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

View File

@@ -0,0 +1,398 @@
// Assertions for release user-journey E2E scenarios.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
assertAgentReplyContainsMarker,
assertOpenAiRequestLogUsed,
} from "../agent-turn-output.mjs";
import { readBoundedResponseText as readBoundedResponseTextWithLimit } from "../bounded-response-text.mjs";
import {
applyMockOpenAiModelConfig,
parseMockOpenAiPort,
} from "../fixtures/mock-openai-config.mjs";
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
import {
ERROR_DETAIL_TAIL_BYTES,
fileContainsText,
readJson,
} from "../release-assertion-files.mjs";
import { readTextFileTail } from "../text-file-utils.mjs";
function clickClackHttpTimeoutMs() {
return readPositiveInt(
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS,
5000,
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS",
);
}
function clickClackHttpBodyMaxBytes() {
return readPositiveInt(
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES,
1024 * 1024,
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES",
);
}
function readPositiveInt(raw, fallback, label) {
const text = String(raw ?? "").trim();
if (!text) {
return fallback;
}
if (!/^\d+$/u.test(text)) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
}
return parsed;
}
async function withClickClackFixtureResponse(url, init, consume, options = {}) {
const timeoutMs = options.timeoutMs ?? clickClackHttpTimeoutMs();
const controller = new AbortController();
const timeoutError = new Error(`${url} timed out after ${timeoutMs}ms`);
let timer;
let response;
const timeoutPromise = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
});
try {
response = await Promise.race([
fetch(url, {
...init,
signal: controller.signal,
}),
timeoutPromise,
]);
return await consume(response, { timeoutPromise });
} finally {
clearTimeout(timer);
await response?.body?.cancel?.().catch(() => undefined);
}
}
async function readBoundedResponseText(
response,
label,
byteLimit = clickClackHttpBodyMaxBytes(),
options = {},
) {
return await readBoundedResponseTextWithLimit(response, label, byteLimit, options.timeoutPromise);
}
async function readBoundedResponseJson(response, label, options = {}) {
return JSON.parse(await readBoundedResponseText(response, label, undefined, options));
}
function resolveHomePath(value) {
if (value === "~") {
return process.env.HOME;
}
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
return path.join(process.env.HOME ?? "", value.slice(2));
}
return value;
}
function comparablePath(value) {
const resolved = path.resolve(resolveHomePath(value));
try {
return fs.realpathSync.native(resolved);
} catch {
return resolved;
}
}
function pathsEqual(left, right) {
return comparablePath(left) === comparablePath(right);
}
function configPath() {
return (
process.env.OPENCLAW_CONFIG_PATH ??
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function writeConfig(cfg) {
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
}
function installRecords() {
return readPluginInstallRecords({ configPath: configPath() });
}
function assertOnboard() {
const home = process.argv[3];
const stateDir = path.join(home, ".openclaw");
const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
assert(fs.existsSync(configPath()), "onboard did not write openclaw.json");
const stateRaw =
fs.readFileSync(configPath(), "utf8") +
(fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : "");
assert(
!stateRaw.includes("sk-openclaw-release-user-journey"),
"onboard persisted raw OpenAI key",
);
}
function configureMockModel() {
const mockPort = parseMockOpenAiPort(process.argv[3]);
const cfg = readJson(configPath());
applyMockOpenAiModelConfig(cfg, { mockPort });
writeConfig(cfg);
}
function assertAgentTurn() {
const marker = process.argv[3];
const outputPath = process.argv[4];
const requestLogPath = process.argv[5];
assertAgentReplyContainsMarker(marker, outputPath);
assertOpenAiRequestLogUsed(requestLogPath);
}
function assertFileContains() {
const file = process.argv[3];
const needle = process.argv[4];
assert(
fileContainsText(file, needle),
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
);
}
function rememberPluginInstallPath() {
const pluginId = process.argv[3];
const installPathFile = process.argv[4];
const sourcePathFile = process.argv[5];
const expectedSourcePath = process.argv[6];
assert(pluginId, "missing plugin id");
assert(installPathFile, "missing install path file");
const record = installRecords()[pluginId];
assert(record, `missing install record for ${pluginId}`);
const installPath = resolveHomePath(record.installPath);
assert(installPath, `install path missing for ${pluginId}`);
assert(
fs.existsSync(installPath),
`install path missing on disk for ${pluginId}: ${installPath}`,
);
if (expectedSourcePath && record.sourcePath) {
assert(
pathsEqual(record.sourcePath, expectedSourcePath),
`unexpected source path for ${pluginId}: ${record.sourcePath}, expected ${expectedSourcePath}`,
);
}
fs.writeFileSync(installPathFile, installPath, "utf8");
if (sourcePathFile && (expectedSourcePath || record.sourcePath)) {
fs.writeFileSync(
sourcePathFile,
expectedSourcePath || resolveHomePath(record.sourcePath),
"utf8",
);
}
}
function assertPluginUninstalled() {
const pluginId = process.argv[3];
const installPathFile = process.argv[4];
const sourcePathFile = process.argv[5];
const cfg = readJson(configPath());
const records = installRecords();
assert(!records[pluginId], `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
if (!installPathFile) {
return;
}
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
const sourcePath =
sourcePathFile && fs.existsSync(sourcePathFile)
? fs.readFileSync(sourcePathFile, "utf8").trim()
: "";
if (sourcePath) {
assert(
fs.existsSync(sourcePath),
`source path was deleted during uninstall for ${pluginId}: ${sourcePath}`,
);
}
const installPathIsSourcePath = sourcePath ? pathsEqual(installPath, sourcePath) : false;
assert(
installPathIsSourcePath || !fs.existsSync(installPath),
`managed plugin directory still present: ${installPath}`,
);
}
function configureClickClack() {
const baseUrl = process.argv[3];
const cfg = readJson(configPath());
cfg.plugins = {
...cfg.plugins,
enabled: true,
entries: {
...cfg.plugins?.entries,
clickclack: {
...cfg.plugins?.entries?.clickclack,
enabled: true,
llm: {
...cfg.plugins?.entries?.clickclack?.llm,
allowAgentIdOverride: true,
allowModelOverride: true,
allowedModels: ["openai/gpt-5.5"],
},
},
},
};
cfg.channels = {
...cfg.channels,
clickclack: {
...cfg.channels?.clickclack,
enabled: true,
baseUrl,
token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" },
workspace: "release",
defaultTo: "channel:general",
replyMode: "model",
model: "openai/gpt-5.5",
reconnectMs: 250,
},
};
writeConfig(cfg);
}
function assertChannelStatus() {
const channel = process.argv[3];
const statusPath = process.argv[4];
const status = readJson(statusPath);
const configured = Array.isArray(status.configuredChannels) ? status.configuredChannels : [];
const liveStatus = status.channels?.[channel];
assert(
configured.includes(channel) || liveStatus?.ok === true,
`${channel} missing from channels status: ${JSON.stringify(status)}`,
);
}
async function postClickClackInbound() {
const baseUrl = process.argv[3];
const body = process.argv[4];
await withClickClackFixtureResponse(
`${baseUrl}/fixture/inbound`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ body }),
},
async (response, options) => {
const text = response.ok
? ""
: await readBoundedResponseText(response, "ClickClack inbound", undefined, options);
assert(response.ok, `fixture inbound failed: ${response.status} ${text}`);
},
);
}
async function waitClickClackSocket() {
const baseUrl = process.argv[3];
const timeoutSeconds = readPositiveInt(
process.argv[4],
30,
"ClickClack websocket timeout seconds",
);
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const remainingMs = Math.max(1, deadline - Date.now());
const state = await withClickClackFixtureResponse(
`${baseUrl}/fixture/state`,
{},
async (response, options) =>
response.ok
? await readBoundedResponseJson(response, "ClickClack fixture state", options)
: undefined,
{
timeoutMs: Math.min(clickClackHttpTimeoutMs(), remainingMs),
},
).catch(() => undefined);
if (state) {
if (Number(state.socketCount ?? 0) > 0) {
return;
}
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`);
}
function assertClickClackState() {
const mode = process.argv[3];
const statePath = process.argv[4];
const needle = process.argv[5];
const state = readJson(statePath);
const haystack = JSON.stringify(mode === "outbound" ? state.outboundMessages : state);
assert(haystack.includes(needle), `ClickClack state did not contain ${needle}: ${haystack}`);
}
async function waitClickClackReply() {
const statePath = process.argv[3];
const marker = process.argv[4];
const timeoutSeconds = readPositiveInt(process.argv[5], 30, "ClickClack reply timeout seconds");
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
if (fs.existsSync(statePath)) {
const state = readJson(statePath);
if (JSON.stringify(state.threadReplies ?? []).includes(marker)) {
return;
}
}
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
const state = fs.existsSync(statePath) ? fs.readFileSync(statePath, "utf8") : "<missing>";
throw new Error(`Timed out waiting for ClickClack reply marker ${marker}. State: ${state}`);
}
const commands = {
"assert-onboard": assertOnboard,
"remember-plugin-install-path": rememberPluginInstallPath,
"configure-mock-model": configureMockModel,
"assert-agent-turn": assertAgentTurn,
"assert-file-contains": assertFileContains,
"assert-plugin-uninstalled": assertPluginUninstalled,
"configure-clickclack": configureClickClack,
"assert-channel-status": assertChannelStatus,
"post-clickclack-inbound": postClickClackInbound,
"wait-clickclack-socket": waitClickClackSocket,
"assert-clickclack-state": assertClickClackState,
"wait-clickclack-reply": waitClickClackReply,
};
export async function runReleaseUserJourneyAssertion(command, args = []) {
const fn = commands[command];
if (!fn) {
throw new Error(`unknown release-user-journey assertion command: ${command ?? "<missing>"}`);
}
const previousArgv = process.argv;
process.argv = [previousArgv[0] ?? "node", fileURLToPath(import.meta.url), command, ...args];
try {
await fn();
} finally {
process.argv = previousArgv;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await runReleaseUserJourneyAssertion(process.argv[2], process.argv.slice(3));
}

View File

@@ -0,0 +1,345 @@
// ClickClack fixture server for release user-journey E2E scenarios.
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
const port = readTcpPortEnv("CLICKCLACK_FIXTURE_PORT", 44181);
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token";
const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json";
const workspace = {
id: "ws_release",
name: "Release Workspace",
slug: "release",
created_at: new Date(0).toISOString(),
};
const channel = {
id: "ch_general",
workspace_id: workspace.id,
name: "general",
kind: "text",
created_at: new Date(0).toISOString(),
};
const botUser = {
id: "usr_bot",
kind: "bot",
display_name: "OpenClaw Bot",
handle: "openclaw",
avatar_url: "",
created_at: new Date(0).toISOString(),
};
const humanUser = {
id: "usr_human",
kind: "human",
display_name: "Release User",
handle: "release-user",
avatar_url: "",
created_at: new Date(0).toISOString(),
};
let messageSeq = 0;
let eventSeq = 0;
const messages = [];
const threadReplies = [];
const outboundMessages = [];
const sockets = new Set();
function persist() {
fs.writeFileSync(
statePath,
`${JSON.stringify(
{
messages,
threadReplies,
outboundMessages,
socketCount: sockets.size,
},
null,
2,
)}\n`,
);
}
function now() {
return new Date().toISOString();
}
function json(res, status, body) {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
function unauthorized(res) {
json(res, 401, { error: "unauthorized" });
}
function checkAuth(req, res) {
if (req.url?.startsWith("/fixture/") || req.url === "/health") {
return true;
}
if (req.headers.authorization !== `Bearer ${token}`) {
unauthorized(res);
return false;
}
return true;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
let bytes = 0;
let settled = false;
req.setEncoding("utf8");
req.on("data", (chunk) => {
if (settled) {
return;
}
bytes += Buffer.byteLength(chunk, "utf8");
if (bytes > requestMaxBytes) {
settled = true;
body = "";
req.resume();
reject(requestBodyTooLargeError());
return;
}
body += chunk;
});
req.on("end", () => {
if (settled) {
return;
}
settled = true;
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
req.on("error", (error) => {
if (!settled) {
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
}
function requestBodyTooLargeError() {
return Object.assign(
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
{
code: "ETOOBIG",
},
);
}
function isRequestBodyTooLargeError(error) {
return error instanceof Error && error.code === "ETOOBIG";
}
function handleRequestError(res, error) {
if (res.headersSent) {
res.destroy();
return;
}
if (isRequestBodyTooLargeError(error)) {
json(res, 413, { error: error.message });
return;
}
json(res, 500, { error: String(error instanceof Error ? error.message : error) });
}
function createMessage({ body, author = humanUser, parentMessageId }) {
messageSeq += 1;
const id = `msg_${messageSeq}`;
const message = {
id,
workspace_id: workspace.id,
channel_id: channel.id,
author_id: author.id,
...(parentMessageId ? { parent_message_id: parentMessageId } : {}),
thread_root_id: parentMessageId ?? id,
channel_seq: messageSeq,
thread_seq: parentMessageId ? threadReplies.length + 1 : 0,
body,
body_format: "markdown",
created_at: now(),
author,
};
if (parentMessageId) {
threadReplies.push(message);
} else {
messages.push(message);
}
persist();
return message;
}
function eventFor(message) {
eventSeq += 1;
return {
id: `evt_${eventSeq}`,
cursor: String(eventSeq),
type: message.parent_message_id ? "thread.reply_created" : "message.created",
workspace_id: workspace.id,
channel_id: channel.id,
seq: message.channel_seq,
created_at: now(),
payload: {
message_id: message.id,
author_id: message.author_id,
...(message.parent_message_id ? { root_message_id: message.thread_root_id } : {}),
},
};
}
function frameText(text) {
const payload = Buffer.from(text);
if (payload.length < 126) {
return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
}
if (payload.length < 65536) {
const header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(payload.length, 2);
return Buffer.concat([header, payload]);
}
const header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(payload.length), 2);
return Buffer.concat([header, payload]);
}
function broadcast(event) {
const frame = frameText(JSON.stringify(event));
for (const socket of sockets) {
socket.write(frame);
}
}
async function handleRequest(req, res) {
try {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!checkAuth(req, res)) {
return;
}
if (req.method === "GET" && url.pathname === "/health") {
json(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/api/me") {
json(res, 200, { user: botUser });
return;
}
if (req.method === "GET" && url.pathname === "/api/workspaces") {
json(res, 200, { workspaces: [workspace] });
return;
}
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
json(res, 200, { channels: [channel] });
return;
}
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
json(res, 200, {
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
});
return;
}
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
outboundMessages.push(message);
persist();
json(res, 200, { message });
return;
}
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
if (req.method === "POST" && threadReplyMatch) {
const body = await readBody(req);
const message = createMessage({
body: String(body.body ?? ""),
author: botUser,
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
});
json(res, 200, { message });
return;
}
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
if (req.method === "GET" && threadMatch) {
const rootId = decodeURIComponent(threadMatch[1]);
json(res, 200, {
root: messages.find((message) => message.id === rootId) ?? null,
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
json(res, 200, { events: [] });
return;
}
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
broadcast(eventFor(message));
json(res, 200, { message });
return;
}
if (req.method === "GET" && url.pathname === "/fixture/state") {
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
return;
}
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
} catch (error) {
handleRequestError(res, error);
}
}
const server = http.createServer((req, res) => {
void handleRequest(req, res);
});
server.on("upgrade", (req, socket) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (url.pathname !== "/api/realtime/ws" || req.headers.authorization !== `Bearer ${token}`) {
socket.destroy();
return;
}
const key = req.headers["sec-websocket-key"];
if (typeof key !== "string") {
socket.destroy();
return;
}
const accept = crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
socket.write(
[
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${accept}`,
"",
"",
].join("\r\n"),
);
sockets.add(socket);
persist();
socket.on("close", () => {
sockets.delete(socket);
persist();
});
socket.on("error", () => {
sockets.delete(socket);
persist();
});
});
persist();
server.listen(port, "127.0.0.1", () => {
console.log(`clickclack fixture listening on ${port}`);
});

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
export NO_COLOR=1
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_trash_shim
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
export OPENAI_API_KEY="sk-openclaw-release-user-journey"
export OPENCLAW_GATEWAY_TOKEN="release-user-journey-token"
export CLICKCLACK_BOT_TOKEN="clickclack-release-token"
PORT="18789"
MOCK_PORT="44180"
CLICKCLACK_PORT="44181"
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
LOG_DIR="$scenario_tmp/logs"
mkdir -p "$LOG_DIR"
INSTALL_LOG="$LOG_DIR/install.log"
ONBOARD_LOG="$LOG_DIR/onboard.log"
OPENAI_LOG="$LOG_DIR/openai.log"
AGENT_LOG="$LOG_DIR/agent.log"
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
DOCTOR_LOG="$LOG_DIR/doctor.log"
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
mock_pid=""
clickclack_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${clickclack_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
rm -rf "$scenario_tmp"
}
trap cleanup EXIT
dump_debug_logs() {
local status="$1"
echo "release user journey failed with exit code $status" >&2
openclaw_e2e_dump_logs \
"$INSTALL_LOG" \
"$ONBOARD_LOG" \
"$OPENAI_LOG" \
"$MOCK_REQUEST_LOG" \
"$AGENT_LOG" \
"$PLUGIN_A_INSTALL_LOG" \
"$PLUGIN_A_CLI_LOG" \
"$PLUGIN_A_UNINSTALL_LOG" \
"$PLUGIN_B_INSTALL_LOG" \
"$PLUGIN_B_CLI_LOG" \
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
"$CLICKCLACK_SERVER_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$GATEWAY_1_LOG" \
"$GATEWAY_2_LOG" \
"$STATUS_JSON" \
"$STATUS_AFTER_RESTART_JSON" \
"$DOCTOR_LOG" \
"$CLICKCLACK_STATE"
}
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
start_gateway() {
local log_path="$1"
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
}
stop_gateway() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
gateway_pid=""
}
write_journey_plugin() {
local dir="$1"
local id="$2"
local version="$3"
local method="$4"
local name="$5"
local cli_root="$6"
local cli_output="$7"
mkdir -p "$dir"
node - "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
fs.writeFileSync(
path.join(dir, "package.json"),
`${JSON.stringify(
{
name: `@openclaw/${id}`,
version,
openclaw: { extensions: ["./index.js"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(dir, "index.js"),
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
);
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
);
NODE
}
openclaw_e2e_install_package "$INSTALL_LOG"
command -v openclaw >/dev/null
package_root="$(openclaw_e2e_package_root)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
openclaw_e2e_enable_openclaw_cli_timeout
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
clickclack_pid="$!"
for _ in $(seq 1 100); do
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
break
fi
sleep 0.1
done
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
echo "Running non-interactive onboarding..."
openclaw onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--auth-choice skip \
--gateway-port "$PORT" \
--gateway-bind loopback \
--skip-daemon \
--skip-ui \
--skip-channels \
--skip-skills \
--skip-health >"$ONBOARD_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
echo "Running package-installed agent turn..."
openclaw agent --local \
--agent main \
--session-id release-user-journey-agent \
--message "Return marker $SUCCESS_MARKER" \
--thinking off \
--json >"$AGENT_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
echo "Installing first external plugin..."
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
remember-plugin-install-path \
journey-plugin-a \
"$plugin_a_install_path_file" \
"$plugin_a_source_path_file" \
"$plugin_a_dir"
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
echo "Uninstalling first external plugin..."
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs \
assert-plugin-uninstalled \
journey-plugin-a \
"$plugin_a_install_path_file" \
"$plugin_a_source_path_file"
echo "Installing replacement external plugin..."
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
echo "Installing ClickClack fixture plugin..."
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
echo "Configuring ClickClack..."
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
echo "Sending ClickClack outbound message..."
openclaw message send \
--channel clickclack \
--target channel:general \
--message "release journey outbound" \
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
echo "Starting Gateway for ClickClack inbound..."
start_gateway "$GATEWAY_1_LOG"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
echo "Restarting Gateway and checking state survival..."
stop_gateway
start_gateway "$GATEWAY_2_LOG"
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
stop_gateway
echo "Running doctor at end of release journey..."
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
echo "Release user journey scenario passed."

View File

@@ -0,0 +1,427 @@
#!/usr/bin/env node
// Writes the external ClickClack channel fixture used by release journey E2Es.
import fs from "node:fs";
import path from "node:path";
const pluginDir = process.argv[2];
if (!pluginDir) {
console.error("usage: write-clickclack-plugin.mjs <plugin-dir>");
process.exit(2);
}
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
`${JSON.stringify(
{
name: "clickclack",
version: "0.0.1",
type: "module",
openclaw: { extensions: ["./index.mjs"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: "clickclack",
activation: { onStartup: false },
channels: ["clickclack"],
channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] },
channelConfigs: {
clickclack: {
schema: {
type: "object",
additionalProperties: true,
properties: {
enabled: { type: "boolean", default: true },
baseUrl: { type: "string" },
workspace: { type: "string" },
defaultTo: { type: "string" },
token: {},
},
},
},
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "index.mjs"),
`import crypto from "node:crypto";
import net from "node:net";
const CHANNEL_ID = "clickclack";
const DEFAULT_ACCOUNT_ID = "default";
function configFor(cfg) {
return cfg?.channels?.clickclack ?? {};
}
function readToken(raw) {
if (typeof raw === "string") {
return raw.trim();
}
if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") {
return String(process.env[raw.id] ?? "").trim();
}
return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim();
}
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
const config = configFor(cfg);
const token = readToken(config.token);
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
return {
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
enabled: config.enabled !== false,
configured: Boolean(baseUrl && token),
baseUrl,
token,
workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release",
defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general",
reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250,
};
}
async function requestJson(account, method, pathname, body) {
const response = await fetch(new URL(pathname, account.baseUrl), {
method,
headers: {
authorization: \`Bearer \${account.token}\`,
...(body == null ? {} : { "content-type": "application/json" }),
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`);
}
return await response.json();
}
async function resolveWorkspaceId(account) {
const data = await requestJson(account, "GET", "/api/workspaces");
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
const match = workspaces.find((workspace) =>
workspace?.id === account.workspace ||
workspace?.slug === account.workspace ||
workspace?.name === account.workspace
);
if (!match?.id) {
throw new Error(\`ClickClack workspace not found: \${account.workspace}\`);
}
return match.id;
}
async function resolveChannelId(account, workspaceId, rawTarget) {
const target = String(rawTarget ?? "").trim();
const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target;
const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`);
const channels = Array.isArray(data.channels) ? data.channels : [];
const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName);
if (!match?.id) {
throw new Error(\`ClickClack channel not found: \${channelName}\`);
}
return match.id;
}
async function sendText(cfg, to, text, accountId, threadId, replyToId) {
const account = resolveAccount(cfg, accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
const rootId = threadId == null ? String(replyToId ?? "") : String(threadId);
if (rootId) {
const data = await requestJson(
account,
"POST",
\`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`,
{ body: text },
);
return data.message;
}
const channelId = await resolveChannelId(account, workspaceId, to);
const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, {
body: text,
});
return data.message;
}
function decodeFrame(buffer) {
if (buffer.length < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) {
return null;
}
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) {
return null;
}
length = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
if (buffer.length < offset + length) {
return null;
}
return {
opcode,
text: buffer.subarray(offset, offset + length).toString("utf8"),
rest: buffer.subarray(offset + length),
};
}
function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) {
const base = new URL(account.baseUrl);
const key = crypto.randomBytes(16).toString("base64");
const socket = net.createConnection({
host: base.hostname,
port: Number(base.port || (base.protocol === "https:" ? 443 : 80)),
});
let buffer = Buffer.alloc(0);
let upgraded = false;
const close = () => socket.destroy();
signal.addEventListener("abort", close, { once: true });
socket.on("connect", () => {
const query = new URLSearchParams({ workspace_id: workspaceId });
if (afterCursor) {
query.set("after_cursor", afterCursor);
}
socket.write(
[
\`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`,
\`Host: \${base.host}\`,
"Upgrade: websocket",
"Connection: Upgrade",
\`Sec-WebSocket-Key: \${key}\`,
"Sec-WebSocket-Version: 13",
\`Authorization: Bearer \${account.token}\`,
"",
"",
].join("\\r\\n"),
);
});
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (!upgraded) {
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
if (headerEnd === -1) {
return;
}
const headers = buffer.subarray(0, headerEnd).toString("utf8");
if (!headers.startsWith("HTTP/1.1 101")) {
socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed"));
return;
}
upgraded = true;
buffer = buffer.subarray(headerEnd + 4);
}
for (;;) {
const frame = decodeFrame(buffer);
if (!frame) {
return;
}
buffer = frame.rest;
if (frame.opcode === 1) {
onEvent(JSON.parse(frame.text));
} else if (frame.opcode === 8) {
socket.end();
return;
}
}
});
socket.on("close", () => signal.removeEventListener("abort", close));
return socket;
}
async function resolveEventMessage(account, event) {
if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") {
return null;
}
const data = await requestJson(
account,
"GET",
\`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`,
);
const messages = Array.isArray(data.messages) ? data.messages : [];
return messages.find((message) => message?.id === event.payload?.message_id) ?? null;
}
async function dispatchInbound(ctx, account, message) {
const runtime = ctx.channelRuntime;
if (!runtime) {
throw new Error("ClickClack fixture requires channel runtime");
}
const target = \`channel:\${message.channel_id}\`;
const route = runtime.routing.resolveAgentRoute({
cfg: ctx.cfg,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: { kind: "channel", id: target },
});
const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const previousTimestamp = runtime.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const senderName = message.author?.display_name || message.author_id || "Release User";
const body = runtime.reply.formatAgentEnvelope({
channel: "ClickClack",
from: senderName,
timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg),
body: message.body,
});
const ctxPayload = runtime.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
RawBody: message.body,
CommandBody: message.body,
From: target,
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? account.accountId,
ChatType: "group",
WasMentioned: true,
ConversationLabel: message.channel_id,
GroupChannel: message.channel_id,
NativeChannelId: message.channel_id,
MessageSid: message.id,
MessageSidFull: message.id,
ReplyToId: message.id,
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: true,
});
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
dispatcherOptions: {
deliver: async (payload) => {
const text = payload && typeof payload === "object" ? String(payload.text ?? "") : "";
if (text.trim()) {
await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id);
}
},
onError: (error) => {
throw error instanceof Error ? error : new Error(String(error));
},
},
});
}
const clickclackPlugin = {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "ClickClack",
selectionLabel: "ClickClack",
docsPath: "/channels/clickclack",
blurb: "Release journey ClickClack fixture.",
},
capabilities: { chatTypes: ["group"], threads: true },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount,
isConfigured: (account) => account.configured,
isEnabled: (account) => account.enabled,
resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo,
},
status: {
buildChannelSummary: ({ snapshot }) => ({
ok: snapshot.configured === true,
label: snapshot.configured ? "configured" : "missing config",
detail: snapshot.baseUrl ?? "",
}),
buildAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
},
outbound: {
deliveryMode: "direct",
sendText: async (ctx) => {
const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId);
return { channel: CHANNEL_ID, messageId: message.id };
},
},
gateway: {
startAccount: async (ctx) => {
const account = resolveAccount(ctx.cfg, ctx.account.accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
try {
while (!ctx.abortSignal.aborted) {
const socket = openEventSocket(
account,
workspaceId,
"",
(event) => {
void (async () => {
const message = await resolveEventMessage(account, event);
if (message && message.author?.kind !== "bot") {
await dispatchInbound(ctx, account, message);
}
})().catch((error) => {
ctx.log?.error?.(error instanceof Error ? error.message : String(error));
});
},
ctx.abortSignal,
);
await new Promise((resolve) => {
socket.once("close", resolve);
socket.once("error", resolve);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
}
}
} finally {
ctx.setStatus({ accountId: account.accountId, running: false });
}
},
},
};
export default {
id: CHANNEL_ID,
register(api) {
api.registerChannel({ plugin: clickclackPlugin });
},
};
`,
);