From 4a6b33d7991d499eeefee98cee73dcff0637e4b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 18:00:45 +0100 Subject: [PATCH] refactor: add gateway server helper modules --- src/gateway/server-http.ts | 227 +++++++++++ src/gateway/server-providers.ts | 672 +++++++++++++++++++++++++++++++ src/gateway/server-utils.test.ts | 27 ++ src/gateway/server-utils.ts | 34 ++ 4 files changed, 960 insertions(+) create mode 100644 src/gateway/server-http.ts create mode 100644 src/gateway/server-providers.ts create mode 100644 src/gateway/server-utils.test.ts create mode 100644 src/gateway/server-utils.ts diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts new file mode 100644 index 000000000..55c0b7e34 --- /dev/null +++ b/src/gateway/server-http.ts @@ -0,0 +1,227 @@ +import { + createServer as createHttpServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from "node:http"; +import { type WebSocketServer } from "ws"; +import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; +import type { CanvasHostHandler } from "../canvas-host/server.js"; +import { type HooksConfigResolved, extractHookToken, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody } from "./hooks.js"; +import { applyHookMappings } from "./hooks-mapping.js"; +import { handleControlUiHttpRequest } from "./control-ui.js"; +import type { createSubsystemLogger } from "../logging.js"; + +type SubsystemLogger = ReturnType; + +type HookDispatchers = { + dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void; + dispatchAgentHook: (value: { + message: string; + name: string; + wakeMode: "now" | "next-heartbeat"; + sessionKey: string; + deliver: boolean; + channel: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + }) => string; +}; + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +export type HooksRequestHandler = ( + req: IncomingMessage, + res: ServerResponse, +) => Promise; + +export function createHooksRequestHandler(opts: { + hooksConfig: HooksConfigResolved | null; + bindHost: string; + port: number; + logHooks: SubsystemLogger; +} & HookDispatchers): HooksRequestHandler { + const { hooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts; + return async (req, res) => { + if (!hooksConfig) return false; + const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`); + const basePath = hooksConfig.basePath; + if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) { + return false; + } + + const token = extractHookToken(req, url); + if (!token || token !== hooksConfig.token) { + res.statusCode = 401; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Unauthorized"); + return true; + } + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + + const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); + if (!subPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + return true; + } + + const body = await readJsonBody(req, hooksConfig.maxBodyBytes); + if (!body.ok) { + const status = body.error === "payload too large" ? 413 : 400; + sendJson(res, status, { ok: false, error: body.error }); + return true; + } + + const payload = + typeof body.value === "object" && body.value !== null ? body.value : {}; + const headers = normalizeHookHeaders(req); + + if (subPath === "wake") { + const normalized = normalizeWakePayload(payload as Record); + if (!normalized.ok) { + sendJson(res, 400, { ok: false, error: normalized.error }); + return true; + } + dispatchWakeHook(normalized.value); + sendJson(res, 200, { ok: true, mode: normalized.value.mode }); + return true; + } + + if (subPath === "agent") { + const normalized = normalizeAgentPayload(payload as Record); + if (!normalized.ok) { + sendJson(res, 400, { ok: false, error: normalized.error }); + return true; + } + const runId = dispatchAgentHook(normalized.value); + sendJson(res, 202, { ok: true, runId }); + return true; + } + + if (hooksConfig.mappings.length > 0) { + try { + const mapped = await applyHookMappings(hooksConfig.mappings, { + payload: payload as Record, + headers, + url, + path: subPath, + }); + if (mapped) { + if (!mapped.ok) { + sendJson(res, 400, { ok: false, error: mapped.error }); + return true; + } + if (mapped.action === null) { + res.statusCode = 204; + res.end(); + return true; + } + if (mapped.action.kind === "wake") { + dispatchWakeHook({ + text: mapped.action.text, + mode: mapped.action.mode, + }); + sendJson(res, 200, { ok: true, mode: mapped.action.mode }); + return true; + } + const runId = dispatchAgentHook({ + message: mapped.action.message, + name: mapped.action.name ?? "Hook", + wakeMode: mapped.action.wakeMode, + sessionKey: mapped.action.sessionKey ?? "", + deliver: mapped.action.deliver === true, + channel: mapped.action.channel ?? "last", + to: mapped.action.to, + thinking: mapped.action.thinking, + timeoutSeconds: mapped.action.timeoutSeconds, + }); + sendJson(res, 202, { ok: true, runId }); + return true; + } + } catch (err) { + logHooks.warn(`hook mapping failed: ${String(err)}`); + sendJson(res, 500, { ok: false, error: "hook mapping failed" }); + return true; + } + } + + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + return true; + }; +} + +export function createGatewayHttpServer(opts: { + canvasHost: CanvasHostHandler | null; + controlUiEnabled: boolean; + controlUiBasePath: string; + handleHooksRequest: HooksRequestHandler; +}): HttpServer { + const { canvasHost, controlUiEnabled, controlUiBasePath, handleHooksRequest } = + opts; + const httpServer: HttpServer = createHttpServer((req, res) => { + // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. + if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; + + void (async () => { + if (await handleHooksRequest(req, res)) return; + if (canvasHost) { + if (await handleA2uiHttpRequest(req, res)) return; + if (await canvasHost.handleHttpRequest(req, res)) return; + } + if (controlUiEnabled) { + if ( + handleControlUiHttpRequest(req, res, { + basePath: controlUiBasePath, + }) + ) + return; + } + + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + })().catch((err) => { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(String(err)); + }); + }); + + return httpServer; +} + +export function attachGatewayUpgradeHandler(opts: { + httpServer: HttpServer; + wss: WebSocketServer; + canvasHost: CanvasHostHandler | null; +}) { + const { httpServer, wss, canvasHost } = opts; + httpServer.on("upgrade", (req, socket, head) => { + if (canvasHost?.handleUpgrade(req, socket, head)) return; + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); +} diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts new file mode 100644 index 000000000..0d22e9dcd --- /dev/null +++ b/src/gateway/server-providers.ts @@ -0,0 +1,672 @@ +import type { ClawdisConfig } from "../config/config.js"; +import { shouldLogVerbose } from "../globals.js"; +import type { createSubsystemLogger } from "../logging.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { monitorDiscordProvider } from "../discord/index.js"; +import { probeDiscord } from "../discord/probe.js"; +import { monitorIMessageProvider } from "../imessage/index.js"; +import { monitorSignalProvider } from "../signal/index.js"; +import { resolveTelegramToken } from "../telegram/token.js"; +import { monitorTelegramProvider } from "../telegram/monitor.js"; +import { probeTelegram } from "../telegram/probe.js"; +import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; +import { readWebSelfId } from "../web/session.js"; +import type { WebProviderStatus } from "../web/auto-reply.js"; +import { formatError } from "./server-utils.js"; + +export type TelegramRuntimeStatus = { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + mode?: "webhook" | "polling" | null; +}; + +export type DiscordRuntimeStatus = { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; +}; + +export type SignalRuntimeStatus = { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + baseUrl?: string | null; +}; + +export type IMessageRuntimeStatus = { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + cliPath?: string | null; + dbPath?: string | null; +}; + +export type ProviderRuntimeSnapshot = { + whatsapp: WebProviderStatus; + telegram: TelegramRuntimeStatus; + discord: DiscordRuntimeStatus; + signal: SignalRuntimeStatus; + imessage: IMessageRuntimeStatus; +}; + +type SubsystemLogger = ReturnType; + +type ProviderManagerOptions = { + loadConfig: () => ClawdisConfig; + logWhatsApp: SubsystemLogger; + logTelegram: SubsystemLogger; + logDiscord: SubsystemLogger; + logSignal: SubsystemLogger; + logIMessage: SubsystemLogger; + whatsappRuntimeEnv: RuntimeEnv; + telegramRuntimeEnv: RuntimeEnv; + discordRuntimeEnv: RuntimeEnv; + signalRuntimeEnv: RuntimeEnv; + imessageRuntimeEnv: RuntimeEnv; +}; + +export type ProviderManager = { + getRuntimeSnapshot: () => ProviderRuntimeSnapshot; + startProviders: () => Promise; + startWhatsAppProvider: () => Promise; + stopWhatsAppProvider: () => Promise; + startTelegramProvider: () => Promise; + stopTelegramProvider: () => Promise; + startDiscordProvider: () => Promise; + stopDiscordProvider: () => Promise; + startSignalProvider: () => Promise; + stopSignalProvider: () => Promise; + startIMessageProvider: () => Promise; + stopIMessageProvider: () => Promise; + markWhatsAppLoggedOut: (cleared: boolean) => void; +}; + +export function createProviderManager( + opts: ProviderManagerOptions, +): ProviderManager { + const { + loadConfig, + logWhatsApp, + logTelegram, + logDiscord, + logSignal, + logIMessage, + whatsappRuntimeEnv, + telegramRuntimeEnv, + discordRuntimeEnv, + signalRuntimeEnv, + imessageRuntimeEnv, + } = opts; + + let whatsappAbort: AbortController | null = null; + let telegramAbort: AbortController | null = null; + let discordAbort: AbortController | null = null; + let signalAbort: AbortController | null = null; + let imessageAbort: AbortController | null = null; + let whatsappTask: Promise | null = null; + let telegramTask: Promise | null = null; + let discordTask: Promise | null = null; + let signalTask: Promise | null = null; + let imessageTask: Promise | null = null; + + let whatsappRuntime: WebProviderStatus = { + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + }; + let telegramRuntime: TelegramRuntimeStatus = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + mode: null, + }; + let discordRuntime: DiscordRuntimeStatus = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }; + let signalRuntime: SignalRuntimeStatus = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + baseUrl: null, + }; + let imessageRuntime: IMessageRuntimeStatus = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + cliPath: null, + dbPath: null, + }; + + const updateWhatsAppStatus = (next: WebProviderStatus) => { + whatsappRuntime = next; + }; + + const startWhatsAppProvider = async () => { + if (whatsappTask) return; + const cfg = loadConfig(); + if (cfg.web?.enabled === false) { + whatsappRuntime = { + ...whatsappRuntime, + running: false, + connected: false, + lastError: "disabled", + }; + logWhatsApp.info("skipping provider start (web.enabled=false)"); + return; + } + if (!(await webAuthExists())) { + whatsappRuntime = { + ...whatsappRuntime, + running: false, + connected: false, + lastError: "not linked", + }; + logWhatsApp.info("skipping provider start (no linked session)"); + return; + } + const { e164, jid } = readWebSelfId(); + const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; + logWhatsApp.info(`starting provider (${identity})`); + whatsappAbort = new AbortController(); + whatsappRuntime = { + ...whatsappRuntime, + running: true, + connected: false, + lastError: null, + }; + const task = monitorWebProvider( + shouldLogVerbose(), + undefined, + true, + undefined, + whatsappRuntimeEnv, + whatsappAbort.signal, + { statusSink: updateWhatsAppStatus }, + ) + .catch((err) => { + whatsappRuntime = { + ...whatsappRuntime, + lastError: formatError(err), + }; + logWhatsApp.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + whatsappAbort = null; + whatsappTask = null; + whatsappRuntime = { + ...whatsappRuntime, + running: false, + connected: false, + }; + }); + whatsappTask = task; + }; + + const stopWhatsAppProvider = async () => { + if (!whatsappAbort && !whatsappTask) return; + whatsappAbort?.abort(); + try { + await whatsappTask; + } catch { + // ignore + } + whatsappAbort = null; + whatsappTask = null; + whatsappRuntime = { + ...whatsappRuntime, + running: false, + connected: false, + }; + }; + + const startTelegramProvider = async () => { + if (telegramTask) return; + const cfg = loadConfig(); + if (cfg.telegram?.enabled === false) { + telegramRuntime = { + ...telegramRuntime, + running: false, + lastError: "disabled", + }; + if (shouldLogVerbose()) { + logTelegram.debug("telegram provider disabled (telegram.enabled=false)"); + } + return; + } + const { token: telegramToken } = resolveTelegramToken(cfg, { + logMissingFile: (message) => logTelegram.warn(message), + }); + if (!telegramToken.trim()) { + telegramRuntime = { + ...telegramRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (shouldLogVerbose()) { + logTelegram.debug( + "telegram provider not configured (no TELEGRAM_BOT_TOKEN)", + ); + } + return; + } + let telegramBotLabel = ""; + try { + const probe = await probeTelegram( + telegramToken.trim(), + 2500, + cfg.telegram?.proxy, + ); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) telegramBotLabel = ` (@${username})`; + } catch (err) { + if (shouldLogVerbose()) { + logTelegram.debug(`bot probe failed: ${String(err)}`); + } + } + logTelegram.info( + `starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`, + ); + telegramAbort = new AbortController(); + telegramRuntime = { + ...telegramRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + mode: cfg.telegram?.webhookUrl ? "webhook" : "polling", + }; + const task = monitorTelegramProvider({ + token: telegramToken.trim(), + runtime: telegramRuntimeEnv, + abortSignal: telegramAbort.signal, + useWebhook: Boolean(cfg.telegram?.webhookUrl), + webhookUrl: cfg.telegram?.webhookUrl, + webhookSecret: cfg.telegram?.webhookSecret, + webhookPath: cfg.telegram?.webhookPath, + }) + .catch((err) => { + telegramRuntime = { + ...telegramRuntime, + lastError: formatError(err), + }; + logTelegram.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + telegramAbort = null; + telegramTask = null; + telegramRuntime = { + ...telegramRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + telegramTask = task; + }; + + const stopTelegramProvider = async () => { + if (!telegramAbort && !telegramTask) return; + telegramAbort?.abort(); + try { + await telegramTask; + } catch { + // ignore + } + telegramAbort = null; + telegramTask = null; + telegramRuntime = { + ...telegramRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + + const startDiscordProvider = async () => { + if (discordTask) return; + const cfg = loadConfig(); + if (cfg.discord?.enabled === false) { + discordRuntime = { + ...discordRuntime, + running: false, + lastError: "disabled", + }; + if (shouldLogVerbose()) { + logDiscord.debug("discord provider disabled (discord.enabled=false)"); + } + return; + } + const discordToken = + process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; + if (!discordToken.trim()) { + discordRuntime = { + ...discordRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (shouldLogVerbose()) { + logDiscord.debug( + "discord provider not configured (no DISCORD_BOT_TOKEN)", + ); + } + return; + } + let discordBotLabel = ""; + try { + const probe = await probeDiscord(discordToken.trim(), 2500); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) discordBotLabel = ` (@${username})`; + } catch (err) { + if (shouldLogVerbose()) { + logDiscord.debug(`bot probe failed: ${String(err)}`); + } + } + logDiscord.info( + `starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`, + ); + discordAbort = new AbortController(); + discordRuntime = { + ...discordRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + }; + const task = monitorDiscordProvider({ + token: discordToken.trim(), + runtime: discordRuntimeEnv, + abortSignal: discordAbort.signal, + slashCommand: cfg.discord?.slashCommand, + mediaMaxMb: cfg.discord?.mediaMaxMb, + historyLimit: cfg.discord?.historyLimit, + }) + .catch((err) => { + discordRuntime = { + ...discordRuntime, + lastError: formatError(err), + }; + logDiscord.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + discordAbort = null; + discordTask = null; + discordRuntime = { + ...discordRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + discordTask = task; + }; + + const stopDiscordProvider = async () => { + if (!discordAbort && !discordTask) return; + discordAbort?.abort(); + try { + await discordTask; + } catch { + // ignore + } + discordAbort = null; + discordTask = null; + discordRuntime = { + ...discordRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + + const startSignalProvider = async () => { + if (signalTask) return; + const cfg = loadConfig(); + if (!cfg.signal) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (shouldLogVerbose()) { + logSignal.debug("signal provider not configured (no signal config)"); + } + return; + } + if (cfg.signal?.enabled === false) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "disabled", + }; + if (shouldLogVerbose()) { + logSignal.debug("signal provider disabled (signal.enabled=false)"); + } + return; + } + const signalCfg = cfg.signal; + const signalMeaningfullyConfigured = Boolean( + signalCfg.account?.trim() || + signalCfg.httpUrl?.trim() || + signalCfg.cliPath?.trim() || + signalCfg.httpHost?.trim() || + typeof signalCfg.httpPort === "number" || + typeof signalCfg.autoStart === "boolean", + ); + if (!signalMeaningfullyConfigured) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (shouldLogVerbose()) { + logSignal.debug( + "signal provider not configured (signal config present but missing required fields)", + ); + } + return; + } + const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; + const port = cfg.signal?.httpPort ?? 8080; + const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; + logSignal.info(`starting provider (${baseUrl})`); + signalAbort = new AbortController(); + signalRuntime = { + ...signalRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + baseUrl, + }; + const task = monitorSignalProvider({ + baseUrl, + account: cfg.signal?.account, + cliPath: cfg.signal?.cliPath, + httpHost: cfg.signal?.httpHost, + httpPort: cfg.signal?.httpPort, + autoStart: + typeof cfg.signal?.autoStart === "boolean" + ? cfg.signal.autoStart + : undefined, + runtime: signalRuntimeEnv, + abortSignal: signalAbort.signal, + }) + .catch((err) => { + signalRuntime = { + ...signalRuntime, + lastError: formatError(err), + }; + logSignal.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + signalAbort = null; + signalTask = null; + signalRuntime = { + ...signalRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + signalTask = task; + }; + + const stopSignalProvider = async () => { + if (!signalAbort && !signalTask) return; + signalAbort?.abort(); + try { + await signalTask; + } catch { + // ignore + } + signalAbort = null; + signalTask = null; + signalRuntime = { + ...signalRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + + const startIMessageProvider = async () => { + if (imessageTask) return; + const cfg = loadConfig(); + if (!cfg.imessage) { + imessageRuntime = { + ...imessageRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (shouldLogVerbose()) { + logIMessage.debug( + "imessage provider not configured (no imessage config)", + ); + } + return; + } + if (cfg.imessage?.enabled === false) { + imessageRuntime = { + ...imessageRuntime, + running: false, + lastError: "disabled", + }; + if (shouldLogVerbose()) { + logIMessage.debug( + "imessage provider disabled (imessage.enabled=false)", + ); + } + return; + } + const cliPath = cfg.imessage?.cliPath?.trim() || "imsg"; + const dbPath = cfg.imessage?.dbPath?.trim(); + logIMessage.info( + `starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, + ); + imessageAbort = new AbortController(); + imessageRuntime = { + ...imessageRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + cliPath, + dbPath: dbPath ?? null, + }; + const task = monitorIMessageProvider({ + cliPath, + dbPath, + allowFrom: cfg.imessage?.allowFrom, + includeAttachments: cfg.imessage?.includeAttachments, + mediaMaxMb: cfg.imessage?.mediaMaxMb, + runtime: imessageRuntimeEnv, + abortSignal: imessageAbort.signal, + }) + .catch((err) => { + imessageRuntime = { + ...imessageRuntime, + lastError: formatError(err), + }; + logIMessage.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + imessageAbort = null; + imessageTask = null; + imessageRuntime = { + ...imessageRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + imessageTask = task; + }; + + const stopIMessageProvider = async () => { + if (!imessageAbort && !imessageTask) return; + imessageAbort?.abort(); + try { + await imessageTask; + } catch { + // ignore + } + imessageAbort = null; + imessageTask = null; + imessageRuntime = { + ...imessageRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + + const startProviders = async () => { + await startWhatsAppProvider(); + await startDiscordProvider(); + await startTelegramProvider(); + await startSignalProvider(); + await startIMessageProvider(); + }; + + const markWhatsAppLoggedOut = (cleared: boolean) => { + whatsappRuntime = { + ...whatsappRuntime, + running: false, + connected: false, + lastError: cleared ? "logged out" : whatsappRuntime.lastError, + }; + }; + + const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({ + whatsapp: { ...whatsappRuntime }, + telegram: { ...telegramRuntime }, + discord: { ...discordRuntime }, + signal: { ...signalRuntime }, + imessage: { ...imessageRuntime }, + }); + + return { + getRuntimeSnapshot, + startProviders, + startWhatsAppProvider, + stopWhatsAppProvider, + startTelegramProvider, + stopTelegramProvider, + startDiscordProvider, + stopDiscordProvider, + startSignalProvider, + stopSignalProvider, + startIMessageProvider, + stopIMessageProvider, + markWhatsAppLoggedOut, + }; +} diff --git a/src/gateway/server-utils.test.ts b/src/gateway/server-utils.test.ts new file mode 100644 index 000000000..d48830ef5 --- /dev/null +++ b/src/gateway/server-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; +import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js"; + +describe("normalizeVoiceWakeTriggers", () => { + test("returns defaults when input is empty", () => { + expect(normalizeVoiceWakeTriggers([])).toEqual(defaultVoiceWakeTriggers()); + expect(normalizeVoiceWakeTriggers(null)).toEqual(defaultVoiceWakeTriggers()); + }); + + test("trims and limits entries", () => { + const result = normalizeVoiceWakeTriggers([" hello ", "", "world"]); + expect(result).toEqual(["hello", "world"]); + }); +}); + +describe("formatError", () => { + test("prefers message for Error", () => { + expect(formatError(new Error("boom"))).toBe("boom"); + }); + + test("handles status/code", () => { + expect(formatError({ status: 500, code: "EPIPE" })).toBe("500 EPIPE"); + expect(formatError({ status: 404 })).toBe("404"); + expect(formatError({ code: "ENOENT" })).toBe("ENOENT"); + }); +}); diff --git a/src/gateway/server-utils.ts b/src/gateway/server-utils.ts new file mode 100644 index 000000000..55e4b7062 --- /dev/null +++ b/src/gateway/server-utils.ts @@ -0,0 +1,34 @@ +import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; + +export function normalizeVoiceWakeTriggers(input: unknown): string[] { + const raw = Array.isArray(input) ? input : []; + const cleaned = raw + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter((v) => v.length > 0) + .slice(0, 32) + .map((v) => v.slice(0, 64)); + return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); +} + +export function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + const statusValue = (err as { status?: unknown })?.status; + const codeValue = (err as { code?: unknown })?.code; + const statusText = + typeof statusValue === "string" || typeof statusValue === "number" + ? String(statusValue) + : undefined; + const codeText = + typeof codeValue === "string" || typeof codeValue === "number" + ? String(codeValue) + : undefined; + if (statusText || codeText) { + return [statusText, codeText].filter(Boolean).join(" "); + } + try { + return JSON.stringify(err, null, 2); + } catch { + return String(err); + } +}