UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

937 lines (936 loc) 33.3 kB
import { C as resolveExpiresAtMsFromDurationMs, j as resolveTimerTimeoutMs, o as asDateTimestampMs, y as parseStrictNonNegativeInteger } from "./number-coercion-CJQ8TR--.js"; import { i as formatErrorMessage$1 } from "./errors-BXgSefBE.js"; import { n as readResponseWithLimit } from "./read-response-with-limit-MDCSJrlg.js"; import { n as detectMime } from "./mime-C8mVE2Bw.js"; import { t as parseMediaContentLength } from "./content-length-DZY9SBS5.js"; import { a as generateSecureUuid } from "./secure-random-Ds4AFLgz.js"; import "./error-runtime-C8vbtAJt.js"; import "./media-runtime-fgpmX6eL.js"; import "./number-runtime-DBLVDypr.js"; import { t as resolveFetch } from "./fetch-C0IyrVwh.js"; import "./fetch-runtime-hJGvq3C8.js"; import "./core-DSxVv-v1.js"; import "./response-limit-runtime-B0ZHF0eR.js"; import path from "node:path"; import fs from "node:fs/promises"; import WebSocket from "ws"; import { Buffer as Buffer$1 } from "node:buffer"; import http from "node:http"; import https from "node:https"; //#region extensions/signal/src/client-container.ts /** * 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. */ const DEFAULT_TIMEOUT_MS$2 = 1e4; const DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES = 1048576; const CONTAINER_TEXT_STYLE_MARKERS = { BOLD: "**", ITALIC: "*", STRIKETHROUGH: "~", MONOSPACE: "`", SPOILER: "||" }; function normalizeBaseUrl$1(url) { 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, init, timeoutMs) { const fetchImpl = resolveFetch(); if (!fetchImpl) throw new Error("fetch is not available"); const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS$2); 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) { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return DEFAULT_ATTACHMENT_RESPONSE_MAX_BYTES; return Math.floor(value); } function readContentLength(res) { return parseMediaContentLength(res.headers?.get("content-length") ?? null) ?? void 0; } async function readCappedResponseBuffer(res, maxResponseBytes) { const contentLength = readContentLength(res); if (contentLength !== void 0 && contentLength > maxResponseBytes) throw new Error("Signal REST attachment exceeded size limit"); return await readResponseWithLimit(res, maxResponseBytes, { onOverflow: () => /* @__PURE__ */ new Error("Signal REST attachment exceeded size limit") }); } /** * Check if bbernhard container REST API is available. */ async function containerCheck(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS$2, account) { const normalized = normalizeBaseUrl$1(baseUrl); try { const 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) }; } } function containerReceiveCheck(normalizedBaseUrl, account, timeoutMs) { const wsUrl = `${normalizedBaseUrl.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(account)}`; return new Promise((resolve) => { const safeTimeoutMs = resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS$2); let settled = false; let ws; const timer = setTimeout(() => { settle({ ok: false, status: null, error: "Signal container receive WebSocket timed out" }); ws?.terminate(); }, safeTimeoutMs); timer.unref?.(); const settle = (result) => { 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) }); }); }); } /** * Make a REST API request to bbernhard container. */ async function containerRestRequest(endpoint, opts, method = "GET", body) { const url = `${normalizeBaseUrl$1(opts.baseUrl)}${endpoint}`; const init = { method, headers: { "Content-Type": "application/json" } }; if (body) init.body = JSON.stringify(body); const res = await fetchWithTimeout(url, init, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$2); if (res.status === 204) return; if (!res.ok) { const errorText = await res.text().catch(() => ""); throw new Error(`Signal REST ${res.status}: ${errorText || res.statusText}`); } const text = await res.text(); if (!text) return; return JSON.parse(text); } /** * Fetch attachment binary from bbernhard container. */ async function containerFetchAttachment(attachmentId, opts) { const res = await fetchWithTimeout(`${normalizeBaseUrl$1(opts.baseUrl)}/v1/attachments/${encodeURIComponent(attachmentId)}`, { method: "GET" }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$2); if (!res.ok) return null; return readCappedResponseBuffer(res, normalizeMaxResponseBytes(opts.maxResponseBytes)); } /** * Stream messages using WebSocket from bbernhard container. * The Promise resolves when the connection closes (for any reason). * The caller (runSignalLoopAdapter) is responsible for reconnection. */ async function streamContainerEvents(params) { const normalized = normalizeBaseUrl$1(params.baseUrl); const wsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/${encodeURIComponent(params.account ?? "")}`; const redactedWsUrl = `${normalized.replace(/^http/, "ws")}/v1/receive/<redacted>`; const log = params.logger?.log ?? (() => {}); const logError = params.logger?.error ?? (() => {}); log(`[signal-ws] connecting to ${redactedWsUrl}`); return new Promise((resolve, reject) => { let ws; let resolved = false; let abortHandler; const cleanup = () => { if (resolved) return; resolved = true; if (abortHandler) { params.abortSignal?.removeEventListener("abort", abortHandler); abortHandler = void 0; } }; try { ws = new WebSocket(wsUrl); } catch (err) { logError(`[signal-ws] failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`); reject(toLintErrorObject$1(err, "Non-Error rejection")); return; } ws.on("open", () => { log("[signal-ws] connected"); }); ws.on("message", (data) => { try { const text = data.toString(); const envelope = JSON.parse(text); 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)}`); }); ws.on("close", (code, reason) => { log(`[signal-ws] closed (code=${code}, reason=${reason?.toString() || "no reason"})`); cleanup(); resolve(); }); 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) { const results = []; for (const filePath of filePaths) { const buffer = await fs.readFile(filePath); const mime = await detectMime({ buffer, filePath }) ?? "application/octet-stream"; const filename = path.basename(filePath); const b64 = buffer.toString("base64"); results.push(`data:${mime};filename=${filename};base64,${b64}`); } return results; } function escapeContainerStyledText(text) { return text.replace(/[*~`|]/g, (char) => `\\${char}`); } function renderContainerStyledText(text, styles) { 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 !== 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 !== void 0 && next > pos) rendered += escapeContainerStyledText(text.slice(pos, next)); } return rendered; } function parseContainerSendTimestamp(raw) { if (raw == null) return; const timestamp = parseStrictNonNegativeInteger(raw); if (timestamp === void 0) throw new Error("Signal REST send returned invalid timestamp"); return timestamp; } /** * Send message via bbernhard container REST API. */ async function containerSendMessage(params) { const payload = { 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) payload.base64_attachments = await filesToBase64DataUris(params.attachments); const timestamp = parseContainerSendTimestamp((await containerRestRequest("/v2/send", { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "POST", payload))?.timestamp); return timestamp === void 0 ? {} : { timestamp }; } /** * Send typing indicator via bbernhard container REST API. */ async function containerSendTyping(params) { 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. */ async function containerSendReceipt(params) { 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. */ async function containerSendReaction(params) { const payload = { recipient: params.recipient, reaction: params.emoji, target_author: params.targetAuthor, timestamp: params.targetTimestamp }; if (params.groupId) payload.group_id = params.groupId; return await containerRestRequest(`/v1/reactions/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "POST", payload) ?? {}; } /** * Remove a reaction from a message via bbernhard container REST API. */ async function containerRemoveReaction(params) { const payload = { recipient: params.recipient, reaction: params.emoji, target_author: params.targetAuthor, timestamp: params.targetTimestamp }; if (params.groupId) payload.group_id = params.groupId; return await containerRestRequest(`/v1/reactions/${encodeURIComponent(params.account)}`, { baseUrl: params.baseUrl, timeoutMs: params.timeoutMs }, "DELETE", payload) ?? {}; } /** * Strip the "uuid:" prefix that native signal-cli accepts but the container API rejects. */ function stripUuidPrefix(id) { 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) { 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. */ async function containerRpcRequest(method, params, opts) { const p = params ?? {}; switch (method) { case "send": { const recipients = (p.recipient ?? []).map(stripUuidPrefix); const usernames = (p.username ?? []).map(stripUuidPrefix); const groupId = p.groupId; const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : void 0; const finalRecipients = recipients.length > 0 ? recipients : usernames.length > 0 ? usernames : formattedGroupId ? [formattedGroupId] : []; const textStyles = p["text-style"]?.map((s) => { const [start, length, style] = s.split(":"); return { start: Number(start), length: Number(length), style }; }); return await containerSendMessage({ baseUrl: opts.baseUrl, account: p.account ?? "", recipients: finalRecipients, message: p.message ?? "", textStyles, attachments: p.attachments, timeoutMs: opts.timeoutMs }); } case "sendTyping": { const recipient = stripUuidPrefix(p.recipient?.[0] ?? (p.groupId ? formatGroupIdForContainer(p.groupId) : "")); await containerSendTyping({ baseUrl: opts.baseUrl, account: p.account ?? "", recipient, stop: p.stop, timeoutMs: opts.timeoutMs }); return; } case "sendReceipt": { const recipient = stripUuidPrefix(p.recipient?.[0] ?? ""); await containerSendReceipt({ baseUrl: opts.baseUrl, account: p.account ?? "", recipient, timestamp: p.targetTimestamp, type: p.type, timeoutMs: opts.timeoutMs }); return; } case "sendReaction": { const recipient = stripUuidPrefix(p.recipients?.[0] ?? ""); const groupId = p.groupIds?.[0] ?? void 0; const formattedGroupId = groupId ? formatGroupIdForContainer(groupId) : void 0; const effectiveRecipient = formattedGroupId || recipient || ""; const reactionParams = { baseUrl: opts.baseUrl, account: p.account ?? "", recipient: effectiveRecipient, emoji: p.emoji ?? "", targetAuthor: stripUuidPrefix(p.targetAuthor ?? recipient), targetTimestamp: p.targetTimestamp, groupId: formattedGroupId, timeoutMs: opts.timeoutMs }; return await (p.remove ? containerRemoveReaction : containerSendReaction)(reactionParams); } case "getAttachment": { const attachmentId = p.id; const buffer = await containerFetchAttachment(attachmentId, { baseUrl: opts.baseUrl, timeoutMs: opts.timeoutMs, maxResponseBytes: opts.maxResponseBytes }); if (!buffer) return { data: void 0 }; return { data: buffer.toString("base64") }; } case "version": return await containerRestRequest("/v1/about", { baseUrl: opts.baseUrl, timeoutMs: opts.timeoutMs }); default: throw new Error(`Unsupported container RPC method: ${method}`); } } function toLintErrorObject$1(value, fallbackMessage) { 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; } //#endregion //#region extensions/signal/src/client.ts const DEFAULT_TIMEOUT_MS$1 = 1e4; const DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES = 1048576; const MAX_SIGNAL_SSE_BUFFER_BYTES = 1048576; const MAX_SIGNAL_SSE_EVENT_DATA_BYTES = 1048576; function createSignalSseAbortError() { const error = /* @__PURE__ */ new Error("Signal SSE aborted"); error.name = "AbortError"; return error; } function normalizeBaseUrl(url) { const trimmed = url.trim(); if (!trimmed) throw new Error("Signal base URL is required"); if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, ""); return `http://${trimmed}`.replace(/\/+$/, ""); } function parseSignalBaseUrl(url) { const parsed = new URL(normalizeBaseUrl(url)); if (parsed.username || parsed.password) throw new Error("Signal base URL must not include credentials"); return parsed; } function resolveSignalEndpointUrl(baseUrl, pathname) { return new URL(pathname, parseSignalBaseUrl(baseUrl)); } function parseSignalRpcResponse(text, status) { let parsed; try { parsed = JSON.parse(text); } catch (err) { throw new Error(`Signal RPC returned malformed JSON (status ${status})`, { cause: err }); } if (!parsed || typeof parsed !== "object") throw new Error(`Signal RPC returned invalid response envelope (status ${status})`); const rpc = parsed; const hasResult = Object.hasOwn(rpc, "result"); if (!rpc.error && !hasResult) throw new Error(`Signal RPC returned invalid response envelope (status ${status})`); return rpc; } function assertSignalHttpProtocol(url, label) { if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`Signal ${label} unsupported protocol: ${url.protocol}`); } function normalizeSignalHttpResponseMaxBytes(value) { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return DEFAULT_SIGNAL_HTTP_RESPONSE_MAX_BYTES; return Math.floor(value); } function normalizeSignalSseTimeoutMs(timeoutMs) { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null; return resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS$1); } function requestSignalHttpText(url, options) { assertSignalHttpProtocol(url, "HTTP"); const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS$1); const client = url.protocol === "https:" ? https : http; return new Promise((resolve, reject) => { let settled = false; const deadline = setTimeout(() => { request?.destroy(/* @__PURE__ */ new Error(`Signal HTTP exceeded deadline after ${timeoutMs}ms`)); }, timeoutMs); deadline.unref?.(); const cleanup = () => { clearTimeout(deadline); request?.setTimeout(0); }; const rejectOnce = (error) => { if (settled) return; settled = true; cleanup(); reject(toLintErrorObject(error, "Non-Error rejection")); }; const resolveOnce = (response) => { if (settled) return; settled = true; cleanup(); resolve(response); }; const maxResponseBytes = normalizeSignalHttpResponseMaxBytes(options.maxResponseBytes); const request = client.request(url, { method: options.method, headers: options.headers }, (res) => { const chunks = []; let totalBytes = 0; res.on("data", (chunk) => { const next = typeof chunk === "string" ? Buffer$1.from(chunk) : chunk; totalBytes += next.byteLength; if (totalBytes > maxResponseBytes) { const error = /* @__PURE__ */ new Error("Signal HTTP response exceeded size limit"); request?.destroy(error); res.destroy(error); rejectOnce(error); return; } chunks.push(next); }); res.on("error", rejectOnce); res.on("end", () => { resolveOnce({ status: res.statusCode ?? 0, statusText: res.statusMessage || "error", text: Buffer$1.concat(chunks).toString("utf8") }); }); }); request.setTimeout(timeoutMs, () => { request?.destroy(/* @__PURE__ */ new Error(`Signal HTTP timed out after ${timeoutMs}ms`)); }); request.on("error", rejectOnce); if (options.body !== void 0) request.write(options.body); request.end(); }); } async function signalRpcRequest$1(method, params, opts) { const id = generateSecureUuid(); const body = JSON.stringify({ jsonrpc: "2.0", method, params, id }); const res = await requestSignalHttpText(resolveSignalEndpointUrl(opts.baseUrl, "/api/v1/rpc"), { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": String(Buffer$1.byteLength(body)) }, body, timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS$1, maxResponseBytes: opts.maxResponseBytes }); if (res.status === 201) return; if (!res.text) throw new Error(`Signal RPC empty response (status ${res.status})`); const parsed = parseSignalRpcResponse(res.text, res.status); if (parsed.error) { const code = parsed.error.code ?? "unknown"; const msg = parsed.error.message ?? "Signal RPC error"; throw new Error(`Signal RPC ${code}: ${msg}`); } return parsed.result; } async function signalCheck$1(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS$1) { try { const res = await requestSignalHttpText(resolveSignalEndpointUrl(baseUrl, "/api/v1/check"), { method: "GET", timeoutMs }); if (res.status < 200 || res.status >= 300) return { ok: false, status: res.status, error: `HTTP ${res.status}` }; return { ok: true, status: res.status, error: null }; } catch (err) { return { ok: false, status: null, error: formatErrorMessage$1(err) }; } } function openSignalEventStream(url, abortSignal, timeoutMs = DEFAULT_TIMEOUT_MS$1) { assertSignalHttpProtocol(url, "SSE"); if (abortSignal?.aborted) throw createSignalSseAbortError(); const client = url.protocol === "https:" ? https : http; return new Promise((resolve, reject) => { let settled = false; let response; let onAbort = () => {}; const effectiveTimeoutMs = normalizeSignalSseTimeoutMs(timeoutMs); const headerDeadline = effectiveTimeoutMs === null ? void 0 : setTimeout(() => { const error = /* @__PURE__ */ new Error(`Signal SSE connection timed out after ${effectiveTimeoutMs}ms`); response?.destroy(error); request.destroy(error); rejectOnce(error); }, effectiveTimeoutMs); headerDeadline?.unref?.(); const cleanup = () => { if (headerDeadline) clearTimeout(headerDeadline); abortSignal?.removeEventListener("abort", onAbort); }; const rejectOnce = (error) => { if (settled) return; settled = true; cleanup(); reject(toLintErrorObject(error, "Non-Error rejection")); }; const request = client.request(url, { method: "GET", headers: { Accept: "text/event-stream" } }, (res) => { const status = res.statusCode ?? 0; if (status < 200 || status >= 300) { res.resume(); rejectOnce(/* @__PURE__ */ new Error(`Signal SSE failed (${status} ${res.statusMessage || "error"})`)); return; } if (settled) { res.destroy(); return; } if (headerDeadline) clearTimeout(headerDeadline); settled = true; response = res; resolve({ response: res, cleanup }); }); onAbort = () => { const error = createSignalSseAbortError(); response?.destroy(error); request.destroy(error); rejectOnce(error); }; abortSignal?.addEventListener("abort", onAbort, { once: true }); request.on("error", rejectOnce); request.end(); }); } async function streamSignalEvents$1(params) { const url = resolveSignalEndpointUrl(params.baseUrl, "/api/v1/events"); if (params.account) url.searchParams.set("account", params.account); const { response, cleanup } = await openSignalEventStream(url, params.abortSignal, params.timeoutMs ?? DEFAULT_TIMEOUT_MS$1); const decoder = new TextDecoder(); let buffer = ""; let bufferedBytes = 0; let currentEvent = {}; let currentEventDataBytes = 0; const flushEvent = () => { if (!currentEvent.data && !currentEvent.event && !currentEvent.id) return; params.onEvent({ event: currentEvent.event, data: currentEvent.data, id: currentEvent.id }); currentEvent = {}; currentEventDataBytes = 0; }; const processLine = (line) => { if (line === "") { flushEvent(); return; } if (line.startsWith(":")) return; const [rawField, ...rest] = line.split(":"); const field = rawField.trim(); const rawValue = rest.join(":"); const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; if (field === "event") currentEvent.event = value; else if (field === "data") { const segment = currentEvent.data ? `\n${value}` : value; currentEventDataBytes += Buffer$1.byteLength(segment, "utf8"); if (currentEventDataBytes > MAX_SIGNAL_SSE_EVENT_DATA_BYTES) throw new Error("Signal SSE event data exceeded size limit"); currentEvent.data = currentEvent.data ? `${currentEvent.data}${segment}` : segment; } else if (field === "id") currentEvent.id = value; }; const drainCompleteLines = () => { let lineEnd = buffer.indexOf("\n"); while (lineEnd !== -1) { let line = buffer.slice(0, lineEnd); buffer = buffer.slice(lineEnd + 1); if (line.endsWith("\r")) line = line.slice(0, -1); processLine(line); lineEnd = buffer.indexOf("\n"); } bufferedBytes = Buffer$1.byteLength(buffer, "utf8"); }; try { for await (const chunk of response) { const value = typeof chunk === "string" ? Buffer$1.from(chunk) : chunk; bufferedBytes += value.byteLength; if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) throw new Error("Signal SSE buffer exceeded size limit"); buffer += decoder.decode(value, { stream: true }); drainCompleteLines(); } const tail = decoder.decode(); if (tail) { buffer += tail; bufferedBytes = Buffer$1.byteLength(buffer, "utf8"); } if (bufferedBytes > MAX_SIGNAL_SSE_BUFFER_BYTES) throw new Error("Signal SSE buffer exceeded size limit"); drainCompleteLines(); } finally { cleanup(); } flushEvent(); } function toLintErrorObject(value, fallbackMessage) { 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; } //#endregion //#region extensions/signal/src/client-adapter.ts /** * Signal client adapter - unified interface for both native signal-cli and bbernhard container. * * This adapter provides a single API that routes to the appropriate implementation * based on the configured API mode. Exports mirror client.ts names so consumers * only need to change their import path. */ const DEFAULT_TIMEOUT_MS = 1e4; const MODE_CACHE_TTL_MS = 3e4; const NATIVE_PREFERENCE_GRACE_MS = 50; const detectedModeCache = /* @__PURE__ */ new Map(); function resolveConfiguredApiMode(configured) { if (configured === "native" || configured === "container") return configured; return "auto"; } function formatErrorMessage(error) { return error instanceof Error ? error.message : String(error); } function resolveAutoProbeTimeoutMs(timeoutMs) { return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; } function waitForNativePreferenceGrace(nativeResultPromise) { return new Promise((resolve) => { const timer = setTimeout(() => resolve({ ok: false }), NATIVE_PREFERENCE_GRACE_MS); timer.unref?.(); nativeResultPromise.then((result) => { clearTimeout(timer); resolve(result); }); }); } async function resolveAutoApiMode(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) { const rawNow = Date.now(); const now = asDateTimestampMs(rawNow); const cached = detectedModeCache.get(baseUrl); if (cached) if (now !== void 0 && cached.expiresAt > now) { if (cached.mode !== "container" || !options.requireContainerReceive || Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim()) return cached.mode; } else detectedModeCache.delete(baseUrl); const detected = await detectSignalApiMode(baseUrl, timeoutMs, options); const expiresAt = resolveExpiresAtMsFromDurationMs(MODE_CACHE_TTL_MS, { nowMs: rawNow }); if (expiresAt !== void 0) detectedModeCache.set(baseUrl, { mode: detected, expiresAt, ...detected === "container" && options.requireContainerReceive && options.account ? { receiveAccount: options.account } : {} }); return detected; } async function resolveApiModeForOperation(params) { const configured = resolveConfiguredApiMode(params.apiMode); if (configured === "native" || configured === "container") return configured; return resolveAutoApiMode(params.baseUrl, params.timeoutMs ?? DEFAULT_TIMEOUT_MS, { account: params.account, requireContainerReceive: params.requireContainerReceive }); } /** * Detect which Signal API mode is available by probing endpoints. * Native wins when both APIs are healthy because it preserves the richer JSON-RPC contract. */ async function detectSignalApiMode(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) { const containerAccount = options.requireContainerReceive ? options.account?.trim() : void 0; const nativeResultPromise = signalCheck$1(baseUrl, timeoutMs).catch(() => ({ ok: false })); const containerResultPromise = containerAccount ? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false })) : options.requireContainerReceive ? Promise.resolve({ ok: false }) : containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); const nativeHealthyPromise = nativeResultPromise.then((result) => { if (result.ok) return "native"; throw new Error("native not ok"); }); const containerHealthyPromise = containerResultPromise.then((result) => { if (result.ok) return "container"; throw new Error("container not ok"); }); try { if (await Promise.any([nativeHealthyPromise, containerHealthyPromise]) === "native") return "native"; return (await waitForNativePreferenceGrace(nativeResultPromise)).ok ? "native" : "container"; } catch { throw new Error(`Signal API not reachable at ${baseUrl}`); } } /** * Drop-in replacement for native signalRpcRequest. * Routes to native JSON-RPC or container REST based on config. */ async function signalRpcRequest(method, params, opts) { if (await resolveApiModeForOperation({ baseUrl: opts.baseUrl, accountId: opts.accountId, account: typeof params?.account === "string" ? params.account : void 0, timeoutMs: opts.timeoutMs, apiMode: opts.apiMode }) === "native") return signalRpcRequest$1(method, params, opts); return containerRpcRequest(method, params, opts); } /** * Drop-in replacement for native signalCheck. */ async function signalCheck(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) { const configured = resolveConfiguredApiMode(options.apiMode); const mode = configured === "auto" ? await resolveAutoApiMode(baseUrl, timeoutMs).catch((error) => { return { ok: false, status: null, error: formatErrorMessage(error) }; }) : configured; if (typeof mode !== "string") return mode; if (mode === "container") return containerCheck(baseUrl, timeoutMs); return signalCheck$1(baseUrl, timeoutMs); } /** * Drop-in replacement for native streamSignalEvents. * Container mode uses WebSocket; native uses SSE. */ async function streamSignalEvents(params) { if (await resolveApiModeForOperation({ baseUrl: params.baseUrl, accountId: params.accountId, account: params.account, requireContainerReceive: true, timeoutMs: resolveAutoProbeTimeoutMs(params.timeoutMs), apiMode: params.apiMode }) === "container") return streamContainerEvents({ baseUrl: params.baseUrl, account: params.account, abortSignal: params.abortSignal, timeoutMs: params.timeoutMs, onEvent: (event) => params.onEvent({ event: "receive", data: JSON.stringify(event) }), logger: params.logger }); return streamSignalEvents$1({ baseUrl: params.baseUrl, account: params.account, abortSignal: params.abortSignal, timeoutMs: params.timeoutMs, onEvent: (event) => params.onEvent(event) }); } //#endregion export { signalRpcRequest as n, streamSignalEvents as r, signalCheck as t };