/** * Signal client for bbernhard/signal-cli-rest-api container. * Uses WebSocket for receiving messages and REST API for sending. * * This is a separate implementation from client.ts (native signal-cli) * to keep the two modes cleanly isolated. */ import fs from "node:fs/promises"; import nodePath from "node:path"; import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime"; import { detectMime, parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime"; import { parseStrictNonNegativeInteger, resolveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; import { readProviderTextResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import WebSocket from "ws"; export type ContainerRpcOptions = { baseUrl: string; timeoutMs?: number; maxResponseBytes?: number; }; export type ContainerWebSocketMessage = { envelope?: { syncMessage?: unknown; dataMessage?: { message?: string; groupInfo?: { groupId?: string; groupName?: string }; attachments?: Array<{ id?: string; contentType?: string; filename?: string; size?: number; }>; quote?: { text?: string }; reaction?: unknown; }; editMessage?: { dataMessage?: unknown }; reactionMessage?: unknown; sourceNumber?: string; sourceUuid?: string; sourceName?: string; timestamp?: number; }; exception?: { message?: string }; }; const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES = 1_048_576; const CONTAINER_TEXT_STYLE_MARKERS: Record = { BOLD: "**", ITALIC: "*", STRIKETHROUGH: "~", MONOSPACE: "`", SPOILER: "||", }; function normalizeBaseUrl(url: string): string { const trimmed = url.trim(); if (!trimmed) { throw new Error("Signal base URL is required"); } const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; const parsed = new URL(withProtocol); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error(`Signal base URL unsupported protocol: ${parsed.protocol}`); } if (parsed.username || parsed.password) { throw new Error("Signal base URL must not include credentials"); } const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, ""); return `${parsed.protocol}//${parsed.host}${pathname}`; } async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number) { const fetchImpl = resolveFetch(); if (!fetchImpl) { throw new Error("fetch is not available"); } const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), safeTimeoutMs); try { return await fetchImpl(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } function normalizeMaxResponseBytes(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES; } return Math.floor(value); } function readContentLength(res: Response): number | undefined { return parseMediaContentLength(res.headers?.get("content-length") ?? null) ?? undefined; } async function readCappedResponseBuffer(res: Response, maxResponseBytes: number): Promise { const contentLength = readContentLength(res); if (contentLength !== undefined && contentLength > maxResponseBytes) { throw new Error("Signal REST attachment exceeded size limit"); } return await readResponseWithLimit(res, maxResponseBytes, { onOverflow: () => new Error("Signal REST attachment exceeded size limit"), }); } async function releaseUnreadResponseBody(res: Response | undefined): Promise { if (res?.bodyUsed !== true) { await res?.body?.cancel().catch(() => undefined); } } /** * Check if bbernhard container REST API is available. */ export async function containerCheck( baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS, account?: string, ): Promise<{ ok: boolean; status?: number | null; error?: string | null }> { const normalized = normalizeBaseUrl(baseUrl); let res: Response | undefined; try { res = await fetchWithTimeout(`${normalized}/v1/about`, { method: "GET" }, timeoutMs); if (!res.ok) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; } const receiveAccount = account?.trim(); if (receiveAccount) { return await containerReceiveCheck(normalized, receiveAccount, timeoutMs); } return { ok: true, status: res.status, error: null }; } catch (err) { return { ok: false, status: null, error: err instanceof Error ? err.message : String(err), }; } finally { await releaseUnreadResponseBody(res); } } function containerReceiveCheck( normalizedBaseUrl: string, account: string, timeoutMs: number, ): Promise<{ ok: boolean; status?: number | null; error?: string | null }> { const wsUrl = `${normalizedBaseUrl.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(account)}`; return new Promise((resolve) => { const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS); let settled = false; let ws: WebSocket | undefined; const timer = setTimeout(() => { settle({ ok: false, status: null, error: "Signal container receive WebSocket timed out" }); ws?.terminate(); }, safeTimeoutMs); timer.unref?.(); const settle = (result: { ok: boolean; status?: number | null; error?: string | null }) => { if (settled) { return; } settled = true; clearTimeout(timer); resolve(result); }; try { ws = new WebSocket(wsUrl); } catch (err) { settle({ ok: false, status: null, error: err instanceof Error ? err.message : String(err), }); return; } ws.once("open", () => { settle({ ok: true, status: 101, error: null }); ws?.close(); }); ws.once("unexpected-response", (_request, response) => { settle({ ok: false, status: response.statusCode ?? null, error: `Signal container receive endpoint did not upgrade to WebSocket (HTTP ${ response.statusCode ?? "unknown" })`, }); ws?.terminate(); }); ws.once("error", (err) => { settle({ ok: false, status: null, error: err instanceof Error ? err.message : String(err), }); }); ws.once("close", (code, reason) => { const reasonText = reason.length > 0 ? `: ${reason.toString("utf8")}` : ""; settle({ ok: false, status: null, error: `Signal container receive WebSocket closed before open (${code}${reasonText})`, }); }); }); } /** * Make a REST API request to bbernhard container. */ export async function containerRestRequest( endpoint: string, opts: ContainerRpcOptions, method: "GET" | "POST" | "PUT" | "DELETE" = "GET", body?: unknown, ): Promise { const baseUrl = normalizeBaseUrl(opts.baseUrl); const url = `${baseUrl}${endpoint}`; const init: RequestInit = { method, headers: { "Content-Type": "application/json" }, }; if (body) { init.body = JSON.stringify(body); } const res = await fetchWithTimeout(url, init, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); if (res.status === 204) { return undefined as T; } if (!res.ok) { // Bound the error body: signal-cli-rest-api is an untrusted external container, // and a hostile/buggy response must not let an error path buffer an unbounded body. const errorText = await readResponseTextLimited(res).catch(() => ""); throw new Error(`Signal REST ${res.status}: ${errorText || res.statusText}`); } // Bound the success body under the shared 16 MiB provider cap before JSON.parse so a // malicious/runaway container response cannot OOM the runtime (send/typing/version all // funnel through here). Reuse the same bounded reader family as the attachment path. const text = await readProviderTextResponse(res, "Signal REST"); if (!text) { return undefined as T; } try { return JSON.parse(text) as T; } catch { throw new Error("Signal REST returned malformed JSON"); } } /** * Fetch attachment binary from bbernhard container. */ export async function containerFetchAttachment( attachmentId: string, opts: ContainerRpcOptions, ): Promise { const baseUrl = normalizeBaseUrl(opts.baseUrl); const url = `${baseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}`; let res: Response | undefined; try { res = await fetchWithTimeout(url, { method: "GET" }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); if (!res.ok) { return null; } return await readCappedResponseBuffer(res, normalizeMaxResponseBytes(opts.maxResponseBytes)); } finally { await releaseUnreadResponseBody(res); } } /** * Stream messages using WebSocket from bbernhard container. * The Promise resolves when the connection closes (for any reason). * The caller (runSignalLoopAdapter) is responsible for reconnection. */ export async function streamContainerEvents(params: { baseUrl: string; account?: string; abortSignal?: AbortSignal; timeoutMs?: number; onEvent: (event: ContainerWebSocketMessage) => void; logger?: { log?: (msg: string) => void; error?: (msg: string) => void }; }): Promise { const normalized = normalizeBaseUrl(params.baseUrl); const wsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(params.account ?? "")}`; const redactedWsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/`; const log = params.logger?.log ?? (() => {}); const logError = params.logger?.error ?? (() => {}); log(`[signal-ws] connecting to ${redactedWsUrl}`); return new Promise((resolve, reject) => { let ws: WebSocket; let resolved = false; let abortHandler: (() => void) | undefined; const cleanup = () => { if (resolved) { return; } resolved = true; if (abortHandler) { params.abortSignal?.removeEventListener("abort", abortHandler); abortHandler = undefined; } }; try { ws = new WebSocket(wsUrl); } catch (err) { logError( `[signal-ws] failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`, ); reject(toLintErrorObject(err, "Non-Error rejection")); return; } ws.on("open", () => { log("[signal-ws] connected"); }); ws.on("message", (data: Buffer) => { try { const text = data.toString(); const envelope = JSON.parse(text) as ContainerWebSocketMessage; if (envelope) { params.onEvent(envelope); } } catch (err) { logError(`[signal-ws] parse error: ${err instanceof Error ? err.message : String(err)}`); } }); ws.on("error", (err) => { logError(`[signal-ws] error: ${err instanceof Error ? err.message : String(err)}`); // Don't resolve here - the close event will fire next }); ws.on("close", (code, reason) => { const reasonStr = reason?.toString() || "no reason"; log(`[signal-ws] closed (code=${code}, reason=${reasonStr})`); cleanup(); resolve(); // Let the outer loop handle reconnection }); ws.on("ping", () => { log("[signal-ws] ping received"); }); ws.on("pong", () => { log("[signal-ws] pong received"); }); if (params.abortSignal) { abortHandler = () => { log("[signal-ws] aborted, closing connection"); cleanup(); ws.close(); resolve(); }; params.abortSignal.addEventListener("abort", abortHandler, { once: true }); } }); } /** * Convert local file paths to base64 data URIs for the container REST API. * The bbernhard container /v2/send only accepts `base64_attachments` (not file paths). */ async function filesToBase64DataUris(filePaths: string[]): Promise { const results: string[] = []; for (const filePath of filePaths) { const buffer = await fs.readFile(filePath); const mime = (await detectMime({ buffer, filePath })) ?? "application/octet-stream"; const filename = nodePath.basename(filePath); const b64 = buffer.toString("base64"); results.push(`data:${mime};filename=${filename};base64,${b64}`); } return results; } function escapeContainerStyledText(text: string): string { return text.replace(/[*~`|]/g, (char) => `\\${char}`); } function renderContainerStyledText( text: string, styles: Array<{ start: number; length: number; style: string }>, ): string { const spans = styles .map((style) => { const marker = CONTAINER_TEXT_STYLE_MARKERS[style.style]; if (!marker) { return null; } const start = Math.max(0, Math.min(style.start, text.length)); const end = Math.max(start, Math.min(style.start + style.length, text.length)); if (end <= start) { return null; } return { start, end, marker }; }) .filter((span): span is { start: number; end: number; marker: string } => span !== null); if (spans.length === 0) { return text; } const positions = [ ...new Set([0, text.length, ...spans.flatMap((span) => [span.start, span.end])]), ].toSorted((a, b) => a - b); let rendered = ""; for (let i = 0; i < positions.length; i += 1) { const pos = positions[i]; for (const span of spans .filter((candidate) => candidate.end === pos) .toSorted((a, b) => b.start - a.start)) { rendered += span.marker; } for (const span of spans .filter((candidate) => candidate.start === pos) .toSorted((a, b) => b.end - a.end)) { rendered += span.marker; } const next = positions[i + 1]; if (next !== undefined && next > pos) { rendered += escapeContainerStyledText(text.slice(pos, next)); } } return rendered; } function parseContainerSendTimestamp(raw: unknown): number | undefined { if (raw == null) { return undefined; } const timestamp = parseStrictNonNegativeInteger(raw); if (timestamp === undefined) { throw new Error("Signal REST send returned invalid timestamp"); } return timestamp; } /** * Send message via bbernhard container REST API. */ export async function containerSendMessage(params: { baseUrl: string; account: string; recipients: string[]; message: string; textStyles?: Array<{ start: number; length: number; style: string }>; attachments?: string[]; timeoutMs?: number; }): Promise<{ timestamp?: number }> { const payload: Record = { message: params.message, number: params.account, recipients: params.recipients, }; if (params.textStyles && params.textStyles.length > 0) { payload.message = renderContainerStyledText(params.message, params.textStyles); payload["text_mode"] = "styled"; } if (params.attachments && params.attachments.length > 0) { // Container API only accepts base64-encoded attachments, not file paths. payload.base64_attachments = await filesToBase64DataUris(params.attachments); } const result = await containerRestRequest<{ timestamp?: unknown }>( "/v2/send", { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "POST", payload, ); const timestamp = parseContainerSendTimestamp(result?.timestamp); return timestamp === undefined ? {} : { timestamp }; } /** * Send typing indicator via bbernhard container REST API. */ export async function containerSendTyping(params: { baseUrl: string; account: string; recipient: string; stop?: boolean; timeoutMs?: number; }): Promise { const method = params.stop ? "DELETE" : "PUT"; await containerRestRequest( `/v1/typing-indicator/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, method, { recipient: params.recipient }, ); return true; } /** * Send read receipt via bbernhard container REST API. */ export async function containerSendReceipt(params: { baseUrl: string; account: string; recipient: string; timestamp: number; type?: "read" | "viewed"; timeoutMs?: number; }): Promise { await containerRestRequest( `/v1/receipts/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "POST", { recipient: params.recipient, timestamp: params.timestamp, receipt_type: params.type ?? "read", }, ); return true; } /** * Send a reaction to a message via bbernhard container REST API. */ export async function containerSendReaction(params: { baseUrl: string; account: string; recipient: string; emoji: string; targetAuthor: string; targetTimestamp: number; groupId?: string; timeoutMs?: number; }): Promise<{ timestamp?: number }> { const payload: Record = { recipient: params.recipient, reaction: params.emoji, target_author: params.targetAuthor, timestamp: params.targetTimestamp, }; if (params.groupId) { payload.group_id = params.groupId; } const result = await containerRestRequest<{ timestamp?: number }>( `/v1/reactions/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "POST", payload, ); return result ?? {}; } /** * Remove a reaction from a message via bbernhard container REST API. */ export async function containerRemoveReaction(params: { baseUrl: string; account: string; recipient: string; emoji: string; targetAuthor: string; targetTimestamp: number; groupId?: string; timeoutMs?: number; }): Promise<{ timestamp?: number }> { const payload: Record = { recipient: params.recipient, reaction: params.emoji, target_author: params.targetAuthor, timestamp: params.targetTimestamp, }; if (params.groupId) { payload.group_id = params.groupId; } const result = await containerRestRequest<{ timestamp?: number }>( `/v1/reactions/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "DELETE", payload, ); return result ?? {}; } /** * Strip the "uuid:" prefix that native signal-cli accepts but the container API rejects. */ function stripUuidPrefix(id: string): string { return id.startsWith("uuid:") ? id.slice(5) : id; } /** * Convert a group internal_id to the container-expected format. * The bbernhard container expects groups as "group.{base64(internal_id)}". */ function formatGroupIdForContainer(groupId: string): string { if (groupId.startsWith("group.")) { return groupId; } return `group.${Buffer.from(groupId).toString("base64")}`; } /** * Drop-in replacement for native signalRpcRequest that translates * JSON-RPC method + params into the equivalent container REST API calls. * This keeps all container protocol details (uuid: stripping, group ID * formatting, base64 attachments, text-style conversion) isolated here. */ export async function containerRpcRequest( method: string, params: Record | undefined, opts: ContainerRpcOptions, ): Promise { const p = params ?? {}; switch (method) { case "send": { const recipients = ((p.recipient as string[] | undefined) ?? []).map(stripUuidPrefix); const usernames = ((p.username as string[] | undefined) ?? []).map(stripUuidPrefix); const groupId = p.groupId as string | undefined; const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : undefined; const finalRecipients = recipients.length > 0 ? recipients : usernames.length > 0 ? usernames : formattedGroupId ? [formattedGroupId] : []; const textStylesRaw = p["text-style"] as string[] | undefined; const textStyles = textStylesRaw?.map((s) => { const [start, length, style] = s.split(":"); return { start: Number(start), length: Number(length), style }; }); const result = await containerSendMessage({ baseUrl: opts.baseUrl, account: (p.account as string) ?? "", recipients: finalRecipients, message: (p.message as string) ?? "", textStyles, attachments: p.attachments as string[] | undefined, timeoutMs: opts.timeoutMs, }); return result as T; } case "sendTyping": { const recipient = stripUuidPrefix( (p.recipient as string[] | undefined)?.[0] ?? ((p.groupId as string | undefined) ? formatGroupIdForContainer(p.groupId as string) : ""), ); await containerSendTyping({ baseUrl: opts.baseUrl, account: (p.account as string) ?? "", recipient, stop: p.stop as boolean | undefined, timeoutMs: opts.timeoutMs, }); return undefined as T; } case "sendReceipt": { const recipient = stripUuidPrefix((p.recipient as string[] | undefined)?.[0] ?? ""); await containerSendReceipt({ baseUrl: opts.baseUrl, account: (p.account as string) ?? "", recipient, timestamp: p.targetTimestamp as number, type: p.type as "read" | "viewed" | undefined, timeoutMs: opts.timeoutMs, }); return undefined as T; } case "sendReaction": { const recipient = stripUuidPrefix((p.recipients as string[] | undefined)?.[0] ?? ""); const groupId = (p.groupIds as string[] | undefined)?.[0] ?? undefined; const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : undefined; // Container API uses `recipient` for both DMs and groups. // For groups, pass the formatted group ID as recipient. const effectiveRecipient = formattedGroupId || recipient || ""; const reactionParams = { baseUrl: opts.baseUrl, account: (p.account as string) ?? "", recipient: effectiveRecipient, emoji: (p.emoji as string) ?? "", targetAuthor: stripUuidPrefix((p.targetAuthor as string) ?? recipient), targetTimestamp: p.targetTimestamp as number, groupId: formattedGroupId, timeoutMs: opts.timeoutMs, }; const fn = p.remove ? containerRemoveReaction : containerSendReaction; return (await fn(reactionParams)) as T; } case "getAttachment": { const attachmentId = p.id as string; const buffer = await containerFetchAttachment(attachmentId, { baseUrl: opts.baseUrl, timeoutMs: opts.timeoutMs, maxResponseBytes: opts.maxResponseBytes, }); // Convert to native format: { data: base64String } if (!buffer) { return { data: undefined } as T; } return { data: buffer.toString("base64") } as T; } case "version": { const result = await containerRestRequest<{ versions?: string[]; build?: number }>( "/v1/about", { baseUrl: opts.baseUrl, timeoutMs: opts.timeoutMs }, ); return result as T; } default: throw new Error(`Unsupported container RPC method: ${method}`); } } function toLintErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; } if (typeof value === "string") { return new Error(value); } const error = new Error(fallbackMessage, { cause: value }); if ((typeof value === "object" && value !== null) || typeof value === "function") { Object.assign(error, value); } return error; }