mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 05:43:35 +00:00
428 lines
13 KiB
JavaScript
428 lines
13 KiB
JavaScript
#!/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 });
|
|
},
|
|
};
|
|
`,
|
|
);
|