From 918cbdcf030c6ac7bf7636210a32fa4a53b1a867 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 23 Dec 2025 00:28:40 +0000 Subject: [PATCH] refactor: lint cleanups and helpers --- src/agents/clawdis-tools.ts | 356 ++++++++++++++++---------- src/agents/pi-embedded-runner.ts | 2 +- src/auto-reply/reply.triggers.test.ts | 2 +- src/auto-reply/templating.ts | 4 +- src/browser/cdp.test.ts | 8 +- src/browser/cdp.ts | 18 +- src/browser/client-actions-core.ts | 8 +- src/browser/client-fetch.ts | 12 +- src/browser/pw-ai.test.ts | 4 +- src/browser/pw-tools-core.ts | 69 ++++- src/browser/routes/agent.ts | 24 +- src/browser/routes/utils.ts | 6 +- src/canvas-host/server.test.ts | 3 +- src/cli/program.force.test.ts | 2 +- src/gateway/client.test.ts | 3 +- src/gateway/client.ts | 8 +- src/gateway/server.test.ts | 30 ++- src/gateway/server.ts | 79 ++++-- src/index.ts | 8 +- src/infra/agent-events.ts | 9 +- src/infra/bonjour.test.ts | 23 +- src/infra/bridge/server.ts | 3 +- src/infra/errors.ts | 26 ++ src/infra/system-presence.ts | 2 +- src/infra/ws.ts | 13 + src/telegram/bot.ts | 8 +- src/telegram/monitor.ts | 2 +- src/telegram/proxy.ts | 6 +- src/telegram/send.ts | 14 +- src/telegram/webhook.ts | 15 +- src/web/auto-reply.ts | 36 +-- src/web/login.coverage.test.ts | 1 + src/web/login.test.ts | 3 +- src/web/login.ts | 4 +- src/web/reconnect.ts | 6 +- src/web/session.ts | 59 +++-- src/web/test-helpers.ts | 2 +- test/auto-reply.retry.test.ts | 34 ++- test/mocks/baileys.ts | 105 ++++---- 39 files changed, 679 insertions(+), 338 deletions(-) create mode 100644 src/infra/errors.ts create mode 100644 src/infra/ws.ts diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index e5ab22ed5..8ff19fcb4 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -3,16 +3,6 @@ import fs from "node:fs/promises"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; import { type TSchema, Type } from "@sinclair/typebox"; - -import { - browserAct, - browserArmDialog, - browserArmFileChooser, - browserConsoleMessages, - browserNavigate, - browserPdfSave, - browserScreenshotAction, -} from "../browser/client-actions.js"; import { browserCloseTab, browserFocusTab, @@ -23,13 +13,22 @@ import { browserStop, browserTabs, } from "../browser/client.js"; +import { + browserAct, + browserArmDialog, + browserArmFileChooser, + browserConsoleMessages, + browserNavigate, + browserPdfSave, + browserScreenshotAction, +} from "../browser/client-actions.js"; import { resolveBrowserConfig } from "../browser/config.js"; import { + type CameraFacing, cameraTempPath, parseCameraClipPayload, parseCameraSnapPayload, writeBase64ToFile, - type CameraFacing, } from "../cli/nodes-camera.js"; import { canvasSnapshotTempPath, @@ -72,6 +71,31 @@ function resolveGatewayOptions(opts?: GatewayCallOptions) { return { url, token, timeoutMs }; } +type StringParamOptions = { + required?: boolean; + trim?: boolean; + label?: string; +}; + +function readStringParam( + params: Record, + key: string, + options: StringParamOptions = {}, +) { + const { required = false, trim = true, label = key } = options; + const raw = params[key]; + if (typeof raw !== "string") { + if (required) throw new Error(`${label} required`); + return undefined; + } + const value = trim ? raw.trim() : raw; + if (!value) { + if (required) throw new Error(`${label} required`); + return undefined; + } + return value; +} + async function callGatewayTool( method: string, opts: GatewayCallOptions, @@ -342,10 +366,22 @@ const BrowserActSchema = Type.Object({ }); const BrowserToolSchema = Type.Union([ - Type.Object({ action: Type.Literal("status"), controlUrl: Type.Optional(Type.String()) }), - Type.Object({ action: Type.Literal("start"), controlUrl: Type.Optional(Type.String()) }), - Type.Object({ action: Type.Literal("stop"), controlUrl: Type.Optional(Type.String()) }), - Type.Object({ action: Type.Literal("tabs"), controlUrl: Type.Optional(Type.String()) }), + Type.Object({ + action: Type.Literal("status"), + controlUrl: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("start"), + controlUrl: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("stop"), + controlUrl: Type.Optional(Type.String()), + }), + Type.Object({ + action: Type.Literal("tabs"), + controlUrl: Type.Optional(Type.String()), + }), Type.Object({ action: Type.Literal("open"), controlUrl: Type.Optional(Type.String()), @@ -364,7 +400,9 @@ const BrowserToolSchema = Type.Union([ Type.Object({ action: Type.Literal("snapshot"), controlUrl: Type.Optional(Type.String()), - format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])), + format: Type.Optional( + Type.Union([Type.Literal("aria"), Type.Literal("ai")]), + ), targetId: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()), }), @@ -375,7 +413,9 @@ const BrowserToolSchema = Type.Union([ fullPage: Type.Optional(Type.Boolean()), ref: Type.Optional(Type.String()), element: Type.Optional(Type.String()), - type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])), + type: Type.Optional( + Type.Union([Type.Literal("png"), Type.Literal("jpeg")]), + ), }), Type.Object({ action: Type.Literal("navigate"), @@ -425,9 +465,8 @@ function createBrowserTool(): AnyAgentTool { parameters: BrowserToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const action = String(params.action ?? ""); - const controlUrl = - typeof params.controlUrl === "string" ? params.controlUrl : undefined; + const action = readStringParam(params, "action", { required: true }); + const controlUrl = readStringParam(params, "controlUrl"); const baseUrl = resolveBrowserBaseUrl(controlUrl); switch (action) { @@ -442,19 +481,20 @@ function createBrowserTool(): AnyAgentTool { case "tabs": return jsonResult({ tabs: await browserTabs(baseUrl) }); case "open": { - const targetUrl = String(params.targetUrl ?? "").trim(); - if (!targetUrl) throw new Error("targetUrl required"); + const targetUrl = readStringParam(params, "targetUrl", { + required: true, + }); return jsonResult(await browserOpenTab(baseUrl, targetUrl)); } case "focus": { - const targetId = String(params.targetId ?? "").trim(); - if (!targetId) throw new Error("targetId required"); + const targetId = readStringParam(params, "targetId", { + required: true, + }); await browserFocusTab(baseUrl, targetId); return jsonResult({ ok: true }); } case "close": { - const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : ""; + const targetId = readStringParam(params, "targetId"); if (targetId) await browserCloseTab(baseUrl, targetId); else await browserAct(baseUrl, { kind: "close" }); return jsonResult({ ok: true }); @@ -465,7 +505,9 @@ function createBrowserTool(): AnyAgentTool { ? (params.format as "ai" | "aria") : "aria"; const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + typeof params.targetId === "string" + ? params.targetId.trim() + : undefined; const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? params.limit @@ -484,13 +526,10 @@ function createBrowserTool(): AnyAgentTool { return jsonResult(snapshot); } case "screenshot": { - const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + const targetId = readStringParam(params, "targetId"); const fullPage = Boolean(params.fullPage); - const ref = - typeof params.ref === "string" ? params.ref.trim() : undefined; - const element = - typeof params.element === "string" ? params.element.trim() : undefined; + const ref = readStringParam(params, "ref"); + const element = readStringParam(params, "element"); const type = params.type === "jpeg" ? "jpeg" : "png"; const result = await browserScreenshotAction(baseUrl, { targetId, @@ -506,10 +545,10 @@ function createBrowserTool(): AnyAgentTool { }); } case "navigate": { - const targetUrl = String(params.targetUrl ?? "").trim(); - if (!targetUrl) throw new Error("targetUrl required"); - const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + const targetUrl = readStringParam(params, "targetUrl", { + required: true, + }); + const targetId = readStringParam(params, "targetId"); return jsonResult( await browserNavigate(baseUrl, { url: targetUrl, targetId }), ); @@ -518,14 +557,18 @@ function createBrowserTool(): AnyAgentTool { const level = typeof params.level === "string" ? params.level.trim() : undefined; const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + typeof params.targetId === "string" + ? params.targetId.trim() + : undefined; return jsonResult( await browserConsoleMessages(baseUrl, { level, targetId }), ); } case "pdf": { const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + typeof params.targetId === "string" + ? params.targetId.trim() + : undefined; const result = await browserPdfSave(baseUrl, { targetId }); return { content: [{ type: "text", text: `FILE:${result.path}` }], @@ -538,23 +581,35 @@ function createBrowserTool(): AnyAgentTool { : []; if (paths.length === 0) throw new Error("paths required"); const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + typeof params.targetId === "string" + ? params.targetId.trim() + : undefined; const timeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + typeof params.timeoutMs === "number" && + Number.isFinite(params.timeoutMs) ? params.timeoutMs : undefined; return jsonResult( - await browserArmFileChooser(baseUrl, { paths, targetId, timeoutMs }), + await browserArmFileChooser(baseUrl, { + paths, + targetId, + timeoutMs, + }), ); } case "dialog": { const accept = Boolean(params.accept); const promptText = - typeof params.promptText === "string" ? params.promptText : undefined; + typeof params.promptText === "string" + ? params.promptText + : undefined; const targetId = - typeof params.targetId === "string" ? params.targetId.trim() : undefined; + typeof params.targetId === "string" + ? params.targetId.trim() + : undefined; const timeoutMs = - typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + typeof params.timeoutMs === "number" && + Number.isFinite(params.timeoutMs) ? params.timeoutMs : undefined; return jsonResult( @@ -571,7 +626,10 @@ function createBrowserTool(): AnyAgentTool { if (!request || typeof request !== "object") { throw new Error("request required"); } - const result = await browserAct(baseUrl, request as Parameters[1]); + const result = await browserAct( + baseUrl, + request as Parameters[1], + ); return jsonResult(result); } default: @@ -623,7 +681,13 @@ const CanvasToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), node: Type.Optional(Type.String()), - format: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpg"), Type.Literal("jpeg")])), + format: Type.Optional( + Type.Union([ + Type.Literal("png"), + Type.Literal("jpg"), + Type.Literal("jpeg"), + ]), + ), maxWidth: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()), }), @@ -654,25 +718,24 @@ function createCanvasTool(): AnyAgentTool { parameters: CanvasToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const action = String(params.action ?? ""); + const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { - gatewayUrl: - typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, - gatewayToken: - typeof params.gatewayToken === "string" - ? params.gatewayToken - : undefined, + gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), + gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, }; const nodeId = await resolveNodeId( gatewayOpts, - typeof params.node === "string" ? params.node : undefined, + readStringParam(params, "node", { trim: true }), true, ); - const invoke = async (command: string, invokeParams?: Record) => + const invoke = async ( + command: string, + invokeParams?: Record, + ) => await callGatewayTool("node.invoke", gatewayOpts, { nodeId, command, @@ -686,7 +749,8 @@ function createCanvasTool(): AnyAgentTool { x: typeof params.x === "number" ? params.x : undefined, y: typeof params.y === "number" ? params.y : undefined, width: typeof params.width === "number" ? params.width : undefined, - height: typeof params.height === "number" ? params.height : undefined, + height: + typeof params.height === "number" ? params.height : undefined, }; const invokeParams: Record = {}; if (typeof params.target === "string" && params.target.trim()) { @@ -707,14 +771,14 @@ function createCanvasTool(): AnyAgentTool { await invoke("canvas.hide", undefined); return jsonResult({ ok: true }); case "navigate": { - const url = String(params.url ?? "").trim(); - if (!url) throw new Error("url required"); + const url = readStringParam(params, "url", { required: true }); await invoke("canvas.navigate", { url }); return jsonResult({ ok: true }); } case "eval": { - const javaScript = String(params.javaScript ?? "").trim(); - if (!javaScript) throw new Error("javaScript required"); + const javaScript = readStringParam(params, "javaScript", { + required: true, + }); const raw = (await invoke("canvas.eval", { javaScript })) as { payload?: { result?: string }; }; @@ -724,15 +788,19 @@ function createCanvasTool(): AnyAgentTool { } case "snapshot": { const formatRaw = - typeof params.format === "string" ? params.format.toLowerCase() : "png"; + typeof params.format === "string" + ? params.format.toLowerCase() + : "png"; const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; const maxWidth = - typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) + typeof params.maxWidth === "number" && + Number.isFinite(params.maxWidth) ? params.maxWidth : undefined; const quality = - typeof params.quality === "number" && Number.isFinite(params.quality) + typeof params.quality === "number" && + Number.isFinite(params.quality) ? params.quality : undefined; const raw = (await invoke("canvas.snapshot", { @@ -819,16 +887,20 @@ const NodesToolSchema = Type.Union([ title: Type.Optional(Type.String()), body: Type.Optional(Type.String()), sound: Type.Optional(Type.String()), - priority: Type.Optional(Type.Union([ - Type.Literal("passive"), - Type.Literal("active"), - Type.Literal("timeSensitive"), - ])), - delivery: Type.Optional(Type.Union([ - Type.Literal("system"), - Type.Literal("overlay"), - Type.Literal("auto"), - ])), + priority: Type.Optional( + Type.Union([ + Type.Literal("passive"), + Type.Literal("active"), + Type.Literal("timeSensitive"), + ]), + ), + delivery: Type.Optional( + Type.Union([ + Type.Literal("system"), + Type.Literal("overlay"), + Type.Literal("auto"), + ]), + ), }), Type.Object({ action: Type.Literal("camera_snap"), @@ -836,7 +908,13 @@ const NodesToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), node: Type.String(), - facing: Type.Optional(Type.Union([Type.Literal("front"), Type.Literal("back"), Type.Literal("both")])), + facing: Type.Optional( + Type.Union([ + Type.Literal("front"), + Type.Literal("back"), + Type.Literal("both"), + ]), + ), maxWidth: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()), }), @@ -846,7 +924,9 @@ const NodesToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), node: Type.String(), - facing: Type.Optional(Type.Union([Type.Literal("front"), Type.Literal("back")])), + facing: Type.Optional( + Type.Union([Type.Literal("front"), Type.Literal("back")]), + ), duration: Type.Optional(Type.String()), durationMs: Type.Optional(Type.Number()), includeAudio: Type.Optional(Type.Boolean()), @@ -875,24 +955,21 @@ function createNodesTool(): AnyAgentTool { parameters: NodesToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const action = String(params.action ?? ""); + const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { - gatewayUrl: - typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, - gatewayToken: - typeof params.gatewayToken === "string" - ? params.gatewayToken - : undefined, + gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), + gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, }; switch (action) { case "status": - return jsonResult(await callGatewayTool("node.list", gatewayOpts, {})); + return jsonResult( + await callGatewayTool("node.list", gatewayOpts, {}), + ); case "describe": { - const node = String(params.node ?? "").trim(); - if (!node) throw new Error("node required"); + const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); return jsonResult( await callGatewayTool("node.describe", gatewayOpts, { nodeId }), @@ -903,8 +980,9 @@ function createNodesTool(): AnyAgentTool { await callGatewayTool("node.pair.list", gatewayOpts, {}), ); case "approve": { - const requestId = String(params.requestId ?? "").trim(); - if (!requestId) throw new Error("requestId required"); + const requestId = readStringParam(params, "requestId", { + required: true, + }); return jsonResult( await callGatewayTool("node.pair.approve", gatewayOpts, { requestId, @@ -912,8 +990,9 @@ function createNodesTool(): AnyAgentTool { ); } case "reject": { - const requestId = String(params.requestId ?? "").trim(); - if (!requestId) throw new Error("requestId required"); + const requestId = readStringParam(params, "requestId", { + required: true, + }); return jsonResult( await callGatewayTool("node.pair.reject", gatewayOpts, { requestId, @@ -921,8 +1000,7 @@ function createNodesTool(): AnyAgentTool { ); } case "notify": { - const node = String(params.node ?? "").trim(); - if (!node) throw new Error("node required"); + const node = readStringParam(params, "node", { required: true }); const title = typeof params.title === "string" ? params.title : ""; const body = typeof params.body === "string" ? params.body : ""; if (!title.trim() && !body.trim()) { @@ -935,22 +1013,28 @@ function createNodesTool(): AnyAgentTool { params: { title: title.trim() || undefined, body: body.trim() || undefined, - sound: typeof params.sound === "string" ? params.sound : undefined, + sound: + typeof params.sound === "string" ? params.sound : undefined, priority: - typeof params.priority === "string" ? params.priority : undefined, + typeof params.priority === "string" + ? params.priority + : undefined, delivery: - typeof params.delivery === "string" ? params.delivery : undefined, + typeof params.delivery === "string" + ? params.delivery + : undefined, }, idempotencyKey: crypto.randomUUID(), }); return jsonResult({ ok: true }); } case "camera_snap": { - const node = String(params.node ?? "").trim(); - if (!node) throw new Error("node required"); + const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); const facingRaw = - typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; + typeof params.facing === "string" + ? params.facing.toLowerCase() + : "both"; const facings: CameraFacing[] = facingRaw === "both" ? ["front", "back"] @@ -960,11 +1044,13 @@ function createNodesTool(): AnyAgentTool { throw new Error("invalid facing (front|back|both)"); })(); const maxWidth = - typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) + typeof params.maxWidth === "number" && + Number.isFinite(params.maxWidth) ? params.maxWidth : undefined; const quality = - typeof params.quality === "number" && Number.isFinite(params.quality) + typeof params.quality === "number" && + Number.isFinite(params.quality) ? params.quality : undefined; @@ -994,8 +1080,7 @@ function createNodesTool(): AnyAgentTool { content.push({ type: "image", data: payload.base64, - mimeType: - payload.format === "jpeg" ? "image/jpeg" : "image/png", + mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png", }); details.push({ facing, @@ -1009,22 +1094,26 @@ function createNodesTool(): AnyAgentTool { return await sanitizeToolResultImages(result, "nodes:camera_snap"); } case "camera_clip": { - const node = String(params.node ?? "").trim(); - if (!node) throw new Error("node required"); + const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); const facing = - typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; + typeof params.facing === "string" + ? params.facing.toLowerCase() + : "front"; if (facing !== "front" && facing !== "back") { throw new Error("invalid facing (front|back)"); } const durationMs = - typeof params.durationMs === "number" && Number.isFinite(params.durationMs) + typeof params.durationMs === "number" && + Number.isFinite(params.durationMs) ? params.durationMs : typeof params.duration === "string" ? parseDurationMs(params.duration) : 3000; const includeAudio = - typeof params.includeAudio === "boolean" ? params.includeAudio : true; + typeof params.includeAudio === "boolean" + ? params.includeAudio + : true; const raw = (await callGatewayTool("node.invoke", gatewayOpts, { nodeId, command: "camera.clip", @@ -1054,11 +1143,11 @@ function createNodesTool(): AnyAgentTool { }; } case "screen_record": { - const node = String(params.node ?? "").trim(); - if (!node) throw new Error("node required"); + const node = readStringParam(params, "node", { required: true }); const nodeId = await resolveNodeId(gatewayOpts, node); const durationMs = - typeof params.durationMs === "number" && Number.isFinite(params.durationMs) + typeof params.durationMs === "number" && + Number.isFinite(params.durationMs) ? params.durationMs : typeof params.duration === "string" ? parseDurationMs(params.duration) @@ -1068,11 +1157,14 @@ function createNodesTool(): AnyAgentTool { ? params.fps : 10; const screenIndex = - typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex) + typeof params.screenIndex === "number" && + Number.isFinite(params.screenIndex) ? params.screenIndex : 0; const includeAudio = - typeof params.includeAudio === "boolean" ? params.includeAudio : true; + typeof params.includeAudio === "boolean" + ? params.includeAudio + : true; const raw = (await callGatewayTool("node.invoke", gatewayOpts, { nodeId, command: "screen.record", @@ -1168,7 +1260,9 @@ const CronToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), text: Type.String(), - mode: Type.Optional(Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")])), + mode: Type.Optional( + Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), + ), }), ]); @@ -1181,21 +1275,19 @@ function createCronTool(): AnyAgentTool { parameters: CronToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const action = String(params.action ?? ""); + const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { - gatewayUrl: - typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, - gatewayToken: - typeof params.gatewayToken === "string" - ? params.gatewayToken - : undefined, + gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), + gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, }; switch (action) { case "status": - return jsonResult(await callGatewayTool("cron.status", gatewayOpts, {})); + return jsonResult( + await callGatewayTool("cron.status", gatewayOpts, {}), + ); case "list": return jsonResult( await callGatewayTool("cron.list", gatewayOpts, { @@ -1211,8 +1303,7 @@ function createCronTool(): AnyAgentTool { ); } case "update": { - const jobId = String(params.jobId ?? "").trim(); - if (!jobId) throw new Error("jobId required"); + const jobId = readStringParam(params, "jobId", { required: true }); if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } @@ -1224,29 +1315,25 @@ function createCronTool(): AnyAgentTool { ); } case "remove": { - const jobId = String(params.jobId ?? "").trim(); - if (!jobId) throw new Error("jobId required"); + const jobId = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.remove", gatewayOpts, { jobId }), ); } case "run": { - const jobId = String(params.jobId ?? "").trim(); - if (!jobId) throw new Error("jobId required"); + const jobId = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.run", gatewayOpts, { jobId }), ); } case "runs": { - const jobId = String(params.jobId ?? "").trim(); - if (!jobId) throw new Error("jobId required"); + const jobId = readStringParam(params, "jobId", { required: true }); return jsonResult( await callGatewayTool("cron.runs", gatewayOpts, { jobId }), ); } case "wake": { - const text = String(params.text ?? "").trim(); - if (!text) throw new Error("text required"); + const text = readStringParam(params, "text", { required: true }); const mode = params.mode === "now" || params.mode === "next-heartbeat" ? params.mode @@ -1268,5 +1355,10 @@ function createCronTool(): AnyAgentTool { } export function createClawdisTools(): AnyAgentTool[] { - return [createBrowserTool(), createCanvasTool(), createNodesTool(), createCronTool()]; + return [ + createBrowserTool(), + createCanvasTool(), + createNodesTool(), + createCronTool(), + ]; } diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 625934e2d..b1c29132b 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -418,7 +418,7 @@ export async function runEmbeddedPiAgent(params: { params.abortSignal.addEventListener("abort", onAbort, { once: true }); } } - let promptError: unknown | null = null; + let promptError: unknown = null; try { try { await session.prompt(params.prompt); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 4c1b0cfb3..ab9dde8d4 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -77,7 +77,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true); + expect(text?.startsWith("⚙️ Restarting")).toBe(true); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index d88650f2c..14a4a76e6 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -25,7 +25,7 @@ export type TemplateContext = MsgContext & { export function applyTemplate(str: string | undefined, ctx: TemplateContext) { if (!str) return ""; return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { - const value = (ctx as Record)[key]; - return value == null ? "" : String(value); + const value = ctx[key as keyof TemplateContext]; + return value ?? ""; }); } diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index d574690c7..da9a21ee9 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -2,7 +2,7 @@ import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; - +import { rawDataToString } from "../infra/ws.js"; import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js"; describe("cdp", () => { @@ -29,7 +29,7 @@ describe("cdp", () => { wsServer.on("connection", (socket) => { socket.on("message", (data) => { - const msg = JSON.parse(String(data)) as { + const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string; params?: { url?: string }; @@ -78,7 +78,7 @@ describe("cdp", () => { wsServer.on("connection", (socket) => { socket.on("message", (data) => { - const msg = JSON.parse(String(data)) as { + const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string; params?: { expression?: string }; @@ -115,7 +115,7 @@ describe("cdp", () => { wsServer.on("connection", (socket) => { socket.on("message", (data) => { - const msg = JSON.parse(String(data)) as { + const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string; }; diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index adb1dc694..2d2c5523d 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -1,5 +1,7 @@ import WebSocket from "ws"; +import { rawDataToString } from "../infra/ws.js"; + type CdpResponse = { id: number; result?: unknown; @@ -44,7 +46,7 @@ function createCdpSender(ws: WebSocket) { ws.on("message", (data) => { try { - const parsed = JSON.parse(String(data)) as CdpResponse; + const parsed = JSON.parse(rawDataToString(data)) as CdpResponse; if (typeof parsed.id !== "number") return; const p = pending.get(parsed.id); if (!p) return; @@ -252,7 +254,11 @@ type RawAXNode = { function axValue(v: unknown): string { if (!v || typeof v !== "object") return ""; const value = (v as { value?: unknown }).value; - return typeof value === "string" ? value : String(value ?? ""); + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; } function formatAriaSnapshot( @@ -444,7 +450,13 @@ export async function getDomText(opts: { awaitPromise: true, returnByValue: true, }); - const text = String(evaluated.result?.value ?? ""); + const textValue = (evaluated.result?.value ?? "") as unknown; + const text = + typeof textValue === "string" + ? textValue + : typeof textValue === "number" || typeof textValue === "boolean" + ? String(textValue) + : ""; return { text }; } diff --git a/src/browser/client-actions-core.ts b/src/browser/client-actions-core.ts index 36bc51ea8..117167349 100644 --- a/src/browser/client-actions-core.ts +++ b/src/browser/client-actions-core.ts @@ -5,6 +5,12 @@ import type { } from "./client-actions-types.js"; import { fetchBrowserJson } from "./client-fetch.js"; +export type BrowserFormField = { + ref: string; + type: string; + value?: string | number | boolean; +}; + export type BrowserActRequest = | { kind: "click"; @@ -28,7 +34,7 @@ export type BrowserActRequest = | { kind: "select"; ref: string; values: string[]; targetId?: string } | { kind: "fill"; - fields: Array>; + fields: BrowserFormField[]; targetId?: string; } | { kind: "resize"; width: number; height: number; targetId?: string } diff --git a/src/browser/client-fetch.ts b/src/browser/client-fetch.ts index 6d484af98..4bf9f635e 100644 --- a/src/browser/client-fetch.ts +++ b/src/browser/client-fetch.ts @@ -1,3 +1,5 @@ +import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; + function unwrapCause(err: unknown): unknown { if (!err || typeof err !== "object") return null; const cause = (err as { cause?: unknown }).cause; @@ -10,13 +12,7 @@ function enhanceBrowserFetchError( timeoutMs: number, ): Error { const cause = unwrapCause(err); - const code = - (cause && typeof cause === "object" && "code" in cause - ? String((cause as { code?: unknown }).code ?? "") - : "") || - (err && typeof err === "object" && "code" in err - ? String((err as { code?: unknown }).code ?? "") - : ""); + const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? ""; const hint = "Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again."; @@ -32,7 +28,7 @@ function enhanceBrowserFetchError( ); } - const msg = String(err); + const msg = formatErrorMessage(err); if (msg.toLowerCase().includes("abort")) { return new Error( `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`, diff --git a/src/browser/pw-ai.test.ts b/src/browser/pw-ai.test.ts index f096f8c25..d769ed55d 100644 --- a/src/browser/pw-ai.test.ts +++ b/src/browser/pw-ai.test.ts @@ -128,9 +128,7 @@ describe("pw-ai", () => { const { chromium } = await import("playwright-core"); const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" }); const browser = createBrowser([p1.page]); - const connect = chromium.connectOverCDP as unknown as ReturnType< - typeof vi.fn - >; + const connect = vi.spyOn(chromium, "connectOverCDP"); connect.mockResolvedValue(browser); const mod = await importModule(); diff --git a/src/browser/pw-tools-core.ts b/src/browser/pw-tools-core.ts index d0cb441c6..409787b01 100644 --- a/src/browser/pw-tools-core.ts +++ b/src/browser/pw-tools-core.ts @@ -1,3 +1,4 @@ +import type { BrowserFormField } from "./client-actions-core.js"; import { type BrowserConsoleMessage, ensurePageState, @@ -168,18 +169,29 @@ export async function typeViaPlaywright(opts: { export async function fillFormViaPlaywright(opts: { cdpPort: number; targetId?: string; - fields: Array>; + fields: BrowserFormField[]; }): Promise { const page = await getPageForTargetId(opts); ensurePageState(page); for (const field of opts.fields) { - const ref = String(field.ref ?? "").trim(); - const type = String(field.type ?? "").trim(); - const value = String(field.value ?? ""); + const ref = field.ref.trim(); + const type = field.type.trim(); + const rawValue = field.value; + const value = + typeof rawValue === "string" + ? rawValue + : typeof rawValue === "number" || typeof rawValue === "boolean" + ? String(rawValue) + : ""; if (!ref || !type) continue; const locator = refLocator(page, ref); if (type === "checkbox" || type === "radio") { - await locator.setChecked(value === "true"); + const checked = + rawValue === true || + rawValue === 1 || + rawValue === "1" || + rawValue === "true"; + await locator.setChecked(checked); continue; } await locator.fill(value); @@ -199,18 +211,47 @@ export async function evaluateViaPlaywright(opts: { if (opts.ref) { const locator = refLocator(page, opts.ref); return await locator.evaluate((el, fnBody) => { - const runner = new Function( - "element", - `"use strict"; const fn = ${fnBody}; return fn(element);`, - ) as (element: Element) => unknown; - return runner(el as Element); + const compileRunner = (body: string) => { + const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate(element) : candidate;`; + // This intentionally evaluates user-supplied code in the browser context. + // oxlint-disable-next-line typescript-eslint/no-implied-eval + return new Function("element", inner) as (element: Element) => unknown; + }; + let compiled: unknown; + try { + compiled = compileRunner(fnBody); + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "invalid expression"; + throw new Error(`Invalid evaluate function: ${message}`); + } + return (compiled as (element: Element) => unknown)(el as Element); }, fnText); } return await page.evaluate((fnBody) => { - const runner = new Function( - `"use strict"; const fn = ${fnBody}; return fn();`, - ) as () => unknown; - return runner(); + const compileRunner = (body: string) => { + const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate() : candidate;`; + // This intentionally evaluates user-supplied code in the browser context. + // oxlint-disable-next-line typescript-eslint/no-implied-eval + return new Function(inner) as () => unknown; + }; + let compiled: unknown; + try { + compiled = compileRunner(fnBody); + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "invalid expression"; + throw new Error(`Invalid evaluate function: ${message}`); + } + return (compiled as () => unknown)(); }, fnText); } diff --git a/src/browser/routes/agent.ts b/src/browser/routes/agent.ts index 83b589ffa..66a1ff883 100644 --- a/src/browser/routes/agent.ts +++ b/src/browser/routes/agent.ts @@ -4,6 +4,7 @@ import type express from "express"; import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; import { captureScreenshot, snapshotAria } from "../cdp.js"; +import type { BrowserFormField } from "../client-actions-core.js"; import { DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, @@ -236,11 +237,24 @@ export function registerBrowserAgentRoutes( return res.json({ ok: true, targetId: tab.targetId }); } case "fill": { - const fields = Array.isArray(body.fields) - ? (body.fields as Array>) - : null; - if (!fields?.length) - return jsonError(res, 400, "fields are required"); + const rawFields = Array.isArray(body.fields) ? body.fields : []; + const fields = rawFields + .map((field) => { + if (!field || typeof field !== "object") return null; + const rec = field as Record; + const ref = toStringOrEmpty(rec.ref); + const type = toStringOrEmpty(rec.type); + if (!ref || !type) return null; + const value = + typeof rec.value === "string" || + typeof rec.value === "number" || + typeof rec.value === "boolean" + ? rec.value + : undefined; + return { ref, type, value }; + }) + .filter((field): field is BrowserFormField => Boolean(field)); + if (!fields.length) return jsonError(res, 400, "fields are required"); await pw.fillFormViaPlaywright({ cdpPort, targetId: tab.targetId, diff --git a/src/browser/routes/utils.ts b/src/browser/routes/utils.ts index bc393b05c..26b2da37c 100644 --- a/src/browser/routes/utils.ts +++ b/src/browser/routes/utils.ts @@ -9,7 +9,11 @@ export function jsonError( } export function toStringOrEmpty(value: unknown) { - return typeof value === "string" ? value.trim() : String(value ?? "").trim(); + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") { + return String(value).trim(); + } + return ""; } export function toNumber(value: unknown) { diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 6cee06542..f1f8c0652 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; +import { rawDataToString } from "../infra/ws.js"; import { defaultRuntime } from "../runtime.js"; import { CANVAS_HOST_PATH, @@ -146,7 +147,7 @@ describe("canvas host", () => { ); ws.on("message", (data) => { clearTimeout(timer); - resolve(String(data)); + resolve(rawDataToString(data)); }); }); diff --git a/src/cli/program.force.test.ts b/src/cli/program.force.test.ts index 3ccd8cb7c..38dae4f5a 100644 --- a/src/cli/program.force.test.ts +++ b/src/cli/program.force.test.ts @@ -25,7 +25,7 @@ describe("gateway --force helpers", () => { beforeEach(() => { vi.clearAllMocks(); - originalKill = process.kill; + originalKill = process.kill.bind(process); }); afterEach(() => { diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 9ce4ee8a6..10f94b818 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1,6 +1,7 @@ import { createServer } from "node:net"; import { afterEach, describe, expect, test } from "vitest"; import { WebSocketServer } from "ws"; +import { rawDataToString } from "../infra/ws.js"; import { GatewayClient } from "./client.js"; // Find a free localhost port for ad-hoc WS servers. @@ -30,7 +31,7 @@ describe("GatewayClient", () => { wss.on("connection", (socket) => { socket.once("message", (data) => { - const first = JSON.parse(String(data)) as { id?: string }; + const first = JSON.parse(rawDataToString(data)) as { id?: string }; const id = first.id ?? "connect"; // Respond with tiny tick interval to trigger watchdog quickly. const helloOk = { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 6e4e8b940..1dc5a0215 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { WebSocket } from "ws"; +import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; import { type ConnectParams, @@ -57,14 +58,15 @@ export class GatewayClient { this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 }); this.ws.on("open", () => this.sendConnect()); - this.ws.on("message", (data) => this.handleMessage(data.toString())); + this.ws.on("message", (data) => this.handleMessage(rawDataToString(data))); this.ws.on("close", (code, reason) => { + const reasonText = rawDataToString(reason); this.ws = null; this.flushPendingErrors( - new Error(`gateway closed (${code}): ${reason.toString()}`), + new Error(`gateway closed (${code}): ${reasonText}`), ); this.scheduleReconnect(); - this.opts.onClose?.(code, reason.toString()); + this.opts.onClose?.(code, reasonText); }); this.ws.on("error", (err) => { logDebug(`gateway client error: ${String(err)}`); diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 3b732d656..0f87844b2 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -10,6 +10,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { rawDataToString } from "../infra/ws.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { __resetModelCatalogCacheForTest, @@ -298,7 +299,7 @@ function onceMessage( reject(new Error(`closed ${code}: ${reason.toString()}`)); }; const handler = (data: WebSocket.RawData) => { - const obj = JSON.parse(String(data)); + const obj = JSON.parse(rawDataToString(data)); if (filter(obj)) { clearTimeout(timer); ws.off("message", handler); @@ -678,7 +679,7 @@ describe("gateway server", () => { expect(res1.ok).toBe(true); const req1 = (res1.payload as { request?: { requestId?: unknown } } | null) ?.request; - const requestId = String(req1?.requestId ?? ""); + const requestId = typeof req1?.requestId === "string" ? req1.requestId : ""; expect(requestId.length).toBeGreaterThan(0); const evt1 = await requestedP; @@ -731,10 +732,10 @@ describe("gateway server", () => { payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "pair-approve-1"); expect(approveRes.ok).toBe(true); - const token = String( - (approveRes.payload as { node?: { token?: unknown } } | null)?.node - ?.token ?? "", - ); + const tokenValue = ( + approveRes.payload as { node?: { token?: unknown } } | null + )?.node?.token; + const token = typeof tokenValue === "string" ? tokenValue : ""; expect(token.length).toBeGreaterThan(0); const evt2 = await resolvedP; @@ -1235,7 +1236,8 @@ describe("gateway server", () => { payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1"); expect(addRes.ok).toBe(true); - const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? ""); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); ws.send( @@ -1345,7 +1347,8 @@ describe("gateway server", () => { payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2"); expect(addRes.ok).toBe(true); - const jobId = String((addRes.payload as { id?: unknown } | null)?.id ?? ""); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); ws.send( @@ -1451,7 +1454,11 @@ describe("gateway server", () => { | { enabled?: unknown; storePath?: unknown } | undefined; expect(statusPayload?.enabled).toBe(true); - expect(String(statusPayload?.storePath ?? "")).toContain("jobs.json"); + const storePath = + typeof statusPayload?.storePath === "string" + ? statusPayload.storePath + : ""; + expect(storePath).toContain("jobs.json"); const atMs = Date.now() + 80; ws.send( @@ -1475,9 +1482,8 @@ describe("gateway server", () => { payload?: unknown; }>(ws, (o) => o.type === "res" && o.id === "cron-add-auto-1"); expect(addRes.ok).toBe(true); - const jobId = String( - (addRes.payload as { id?: unknown } | null)?.id ?? "", - ); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; expect(jobId.length > 0).toBe(true); const finishedEvt = await onceMessage<{ diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6b4a1f7e6..0be0b90fe 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -105,6 +105,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone, } from "../infra/widearea-dns.js"; +import { rawDataToString } from "../infra/ws.js"; import { createSubsystemLogger, getChildLogger, @@ -1144,10 +1145,18 @@ const wsInflightSince = new Map(); function formatError(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; - const status = (err as { status?: unknown })?.status; - const code = (err as { code?: unknown })?.code; - if (status || code) - return `status=${status ?? "unknown"} code=${code ?? "unknown"}`; + 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 `status=${statusText ?? "unknown"} code=${codeText ?? "unknown"}`; return JSON.stringify(err, null, 2); } @@ -1161,8 +1170,7 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) { broadcastHealthUpdate(snap); } return snap; - })(); - healthRefresh.finally(() => { + })().finally(() => { healthRefresh = null; }); } @@ -1183,13 +1191,17 @@ export async function startGatewayServer( } const controlUiEnabled = opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; + const authBase = cfgAtStart.gateway?.auth ?? {}; + const authOverrides = opts.auth ?? {}; const authConfig = { - ...(cfgAtStart.gateway?.auth ?? {}), - ...(opts.auth ?? {}), + ...authBase, + ...authOverrides, }; + const tailscaleBase = cfgAtStart.gateway?.tailscale ?? {}; + const tailscaleOverrides = opts.tailscale ?? {}; const tailscaleConfig = { - ...(cfgAtStart.gateway?.tailscale ?? {}), - ...(opts.tailscale ?? {}), + ...tailscaleBase, + ...tailscaleOverrides, }; const tailscaleMode = tailscaleConfig.mode ?? "off"; const token = getGatewayToken(); @@ -1849,8 +1861,17 @@ export async function startGatewayServer( }, }; } - const raw = String((params as { raw?: unknown }).raw ?? ""); - const parsedRes = parseConfigJson5(raw); + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "invalid config.set params: raw (string) required", + }, + }; + } + const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { return { ok: false, @@ -2949,7 +2970,9 @@ export async function startGatewayServer( const payload = { ...base, state: "error", - errorMessage: evt.data.error ? String(evt.data.error) : undefined, + errorMessage: evt.data.error + ? formatForLog(evt.data.error) + : undefined, }; broadcast("chat", payload); bridgeSendToSession(sessionKey, "chat", payload); @@ -3061,7 +3084,7 @@ export async function startGatewayServer( socket.on("message", async (data) => { if (closed) return; - const text = data.toString(); + const text = rawDataToString(data); try { const parsed = JSON.parse(text); if (!client) { @@ -4034,8 +4057,19 @@ export async function startGatewayServer( ); break; } - const raw = String((params as { raw?: unknown }).raw ?? ""); - const parsedRes = parseConfigJson5(raw); + const rawValue = (params as { raw?: unknown }).raw; + if (typeof rawValue !== "string") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid config.set params: raw (string) required", + ), + ); + break; + } + const parsedRes = parseConfigJson5(rawValue); if (!parsedRes.ok) { respond( false, @@ -4147,8 +4181,10 @@ export async function startGatewayServer( env?: Record; }; const cfg = loadConfig(); - const skills = { ...(cfg.skills ?? {}) }; - const current = { ...(skills[p.skillKey] ?? {}) }; + const skills = cfg.skills ? { ...cfg.skills } : {}; + const current = skills[p.skillKey] + ? { ...skills[p.skillKey] } + : {}; if (typeof p.enabled === "boolean") { current.enabled = p.enabled; } @@ -4158,11 +4194,11 @@ export async function startGatewayServer( else delete current.apiKey; } if (p.env && typeof p.env === "object") { - const nextEnv = { ...(current.env ?? {}) }; + const nextEnv = current.env ? { ...current.env } : {}; for (const [key, value] of Object.entries(p.env)) { const trimmedKey = key.trim(); if (!trimmedKey) continue; - const trimmedVal = String(value ?? "").trim(); + const trimmedVal = value.trim(); if (!trimmedVal) delete nextEnv[trimmedKey]; else nextEnv[trimmedKey] = trimmedVal; } @@ -4541,7 +4577,8 @@ export async function startGatewayServer( } case "system-event": { const params = (req.params ?? {}) as Record; - const text = String(params.text ?? "").trim(); + const text = + typeof params.text === "string" ? params.text.trim() : ""; if (!text) { respond( false, diff --git a/src/index.ts b/src/index.ts index 2dbedba63..2c7a38762 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,5 +92,11 @@ if (isMain) { process.exit(1); }); - program.parseAsync(process.argv); + void program.parseAsync(process.argv).catch((err) => { + console.error( + "[clawdis] CLI failed:", + err instanceof Error ? (err.stack ?? err.message) : err, + ); + process.exit(1); + }); } diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 929815615..4c581b46d 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -1,7 +1,14 @@ +export type AgentEventStream = + | "job" + | "tool" + | "assistant" + | "error" + | (string & {}); + export type AgentEventPayload = { runId: string; seq: number; - stream: "job" | "tool" | string; + stream: AgentEventStream; ts: number; data: Record; }; diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index d4e59922a..cdcd0793d 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -9,6 +9,9 @@ const logWarn = vi.fn(); const logDebug = vi.fn(); const getLoggerInfo = vi.fn(); +const asString = (value: unknown, fallback: string) => + typeof value === "string" && value.trim() ? value : fallback; + vi.mock("../logger.js", () => { return { logWarn: (message: string) => logWarn(message), @@ -86,8 +89,8 @@ describe("gateway bonjour advertiser", () => { serviceState: "announced", on: vi.fn(), getFQDN: () => - `${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, - getHostname: () => String(options.hostname ?? "unknown"), + `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); @@ -153,8 +156,8 @@ describe("gateway bonjour advertiser", () => { serviceState: "announced", on, getFQDN: () => - `${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, - getHostname: () => String(options.hostname ?? "unknown"), + `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); @@ -195,8 +198,8 @@ describe("gateway bonjour advertiser", () => { serviceState: "unannounced", on: vi.fn(), getFQDN: () => - `${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, - getHostname: () => String(options.hostname ?? "unknown"), + `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); @@ -245,8 +248,8 @@ describe("gateway bonjour advertiser", () => { serviceState: "unannounced", on: vi.fn(), getFQDN: () => - `${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, - getHostname: () => String(options.hostname ?? "unknown"), + `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); @@ -281,8 +284,8 @@ describe("gateway bonjour advertiser", () => { serviceState: "announced", on: vi.fn(), getFQDN: () => - `${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, - getHostname: () => String(options.hostname ?? "unknown"), + `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, + getHostname: () => asString(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; }); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index d80f7c483..2e84b6bec 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -329,8 +329,9 @@ export async function startNodeBridgeServer( ? hello.commands.map((c) => String(c)).filter(Boolean) : verified.node.commands; const helloPermissions = normalizePermissions(hello.permissions); + const basePermissions = verified.node.permissions ?? {}; const permissions = helloPermissions - ? { ...(verified.node.permissions ?? {}), ...helloPermissions } + ? { ...basePermissions, ...helloPermissions } : verified.node.permissions; isAuthenticated = true; diff --git a/src/infra/errors.ts b/src/infra/errors.ts new file mode 100644 index 000000000..388155249 --- /dev/null +++ b/src/infra/errors.ts @@ -0,0 +1,26 @@ +export function extractErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== "object") return undefined; + const code = (err as { code?: unknown }).code; + if (typeof code === "string") return code; + if (typeof code === "number") return String(code); + return undefined; +} + +export function formatErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message || err.name || "Error"; + } + if (typeof err === "string") return err; + if ( + typeof err === "number" || + typeof err === "boolean" || + typeof err === "bigint" + ) { + return String(err); + } + try { + return JSON.stringify(err); + } catch { + return Object.prototype.toString.call(err); + } +} diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index aa1713623..fa4967e02 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -240,7 +240,7 @@ export function listSystemPresence(): SystemPresence[] { ensureSelfPresence(); // prune expired const now = Date.now(); - for (const [k, v] of [...entries]) { + for (const [k, v] of entries) { if (now - v.ts > TTL_MS) entries.delete(k); } // enforce max size (LRU by ts) diff --git a/src/infra/ws.ts b/src/infra/ws.ts new file mode 100644 index 000000000..a5986c269 --- /dev/null +++ b/src/infra/ws.ts @@ -0,0 +1,13 @@ +import { Buffer } from "node:buffer"; + +import type WebSocket from "ws"; + +export function rawDataToString( + data: WebSocket.RawData, + encoding: BufferEncoding = "utf8", +): string { + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString(encoding); + if (Array.isArray(data)) return Buffer.concat(data).toString(encoding); + return Buffer.from(data as ArrayBuffer | ArrayBufferView).toString(encoding); +} diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index dde267bc0..6f3e812e1 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -12,6 +12,7 @@ import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose } from "../globals.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { getChildLogger } from "../logging.js"; import { mediaKindFromMime } from "../media/constants.js"; import { detectMime } from "../media/mime.js"; @@ -341,11 +342,10 @@ async function sendTelegramText( try { await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown" }); } catch (err) { - if (PARSE_ERR_RE.test(String(err ?? ""))) { + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText)) { runtime.log?.( - `telegram markdown parse failed; retrying without formatting: ${String( - err, - )}`, + `telegram markdown parse failed; retrying without formatting: ${errText}`, ); await bot.api.sendMessage(chatId, text); return; diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index cfe6333b7..2426bc0c3 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -52,7 +52,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { // Long polling const stopOnAbort = () => { - if (opts.abortSignal?.aborted) bot.stop(); + if (opts.abortSignal?.aborted) void bot.stop(); }; opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); try { diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index 6c2225c6e..7217db477 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -3,6 +3,8 @@ import { ProxyAgent } from "undici"; export function makeProxyFetch(proxyUrl: string): typeof fetch { const agent = new ProxyAgent(proxyUrl); - return (input: RequestInfo | URL, init?: RequestInit) => - fetch(input, { ...(init ?? {}), dispatcher: agent }); + return (input: RequestInfo | URL, init?: RequestInit) => { + const base = init ? { ...init } : {}; + return fetch(input, { ...base, dispatcher: agent }); + }; } diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 1557a795e..ad9ac64e9 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -1,6 +1,6 @@ // @ts-nocheck import { Bot, InputFile } from "grammy"; - +import { formatErrorMessage } from "../infra/errors.js"; import { mediaKindFromMime } from "../media/constants.js"; import { loadWebMedia } from "../web/media.js"; @@ -76,16 +76,17 @@ export async function sendMessageTelegram( return await fn(); } catch (err) { lastErr = err; + const errText = formatErrorMessage(err); const terminal = attempt === 3 || !/429|timeout|connect|reset|closed|unavailable|temporarily/i.test( - String(err ?? ""), + errText, ); if (terminal) break; const backoff = 400 * attempt; if (opts.verbose) { console.warn( - `telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${String(err)}`, + `telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${errText}`, ); } await sleep(backoff); @@ -95,7 +96,7 @@ export async function sendMessageTelegram( }; const wrapChatNotFound = (err: unknown) => { - if (!/400: Bad Request: chat not found/i.test(String(err ?? ""))) + if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) return err; return new Error( [ @@ -161,10 +162,11 @@ export async function sendMessageTelegram( ).catch(async (err) => { // Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*'). // When that happens, fall back to plain text so the message still delivers. - if (PARSE_ERR_RE.test(String(err ?? ""))) { + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText)) { if (opts.verbose) { console.warn( - `telegram markdown parse failed, retrying as plain text: ${String(err)}`, + `telegram markdown parse failed, retrying as plain text: ${errText}`, ); } return await sendWithRetry( diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 430db3f6e..12607f29b 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import { webhookCallback } from "grammy"; - +import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createTelegramBot } from "./bot.js"; @@ -43,7 +43,16 @@ export async function startTelegramWebhook(opts: { res.end(); return; } - handler(req, res); + const handled = handler(req, res); + if (handled && typeof (handled as Promise).catch === "function") { + void (handled as Promise).catch((err) => { + runtime.log?.( + `Telegram webhook handler failed: ${formatErrorMessage(err)}`, + ); + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + } }); const publicUrl = @@ -59,7 +68,7 @@ export async function startTelegramWebhook(opts: { const shutdown = () => { server.close(); - bot.stop(); + void bot.stop(); }; if (opts.abortSignal) { opts.abortSignal.addEventListener("abort", shutdown, { once: true }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index ec14ff97e..cfb7200df 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -253,8 +253,9 @@ export async function runWebHeartbeatOnce(opts: { if (sessionId) { const storePath = resolveStorePath(cfg.inbound?.session?.store); const store = loadSessionStore(storePath); + const current = store[sessionKey] ?? {}; store[sessionKey] = { - ...(store[sessionKey] ?? {}), + ...current, sessionId, updatedAt: Date.now(), }; @@ -404,10 +405,10 @@ export async function runWebHeartbeatOnce(opts: { ); whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`); } catch (err) { - const reason = String(err); + const reason = formatError(err); heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); - emitHeartbeatEvent({ status: "failed", to, reason: String(err) }); + emitHeartbeatEvent({ status: "failed", to, reason }); throw err; } } @@ -561,18 +562,17 @@ async function deliverWebReply(params: { return await fn(); } catch (err) { lastErr = err; + const errText = formatError(err); const isLast = attempt === maxAttempts; const shouldRetry = /closed|reset|timed\s*out|disconnect/i.test( - String(err ?? ""), + errText, ); if (!shouldRetry || isLast) { throw err; } const backoffMs = 500 * attempt; logVerbose( - `Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${String( - err, - )}`, + `Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${errText}`, ); await sleep(backoffMs); } @@ -688,7 +688,7 @@ async function deliverWebReply(params: { ); } catch (err) { whatsappOutboundLog.error( - `Failed sending web media to ${msg.from}: ${String(err)}`, + `Failed sending web media to ${msg.from}: ${formatError(err)}`, ); replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); if (index === 0) { @@ -1043,12 +1043,12 @@ export async function monitorWebProvider( to, }).catch((err) => { replyLogger.warn( - { error: String(err), storePath, sessionKey: mainKey, to }, + { error: formatError(err), storePath, sessionKey: mainKey, to }, "failed updating last route", ); }); backgroundTasks.add(task); - task.finally(() => { + void task.finally(() => { backgroundTasks.delete(task); }); } @@ -1096,7 +1096,7 @@ export async function monitorWebProvider( }) .catch((err) => { whatsappOutboundLog.error( - `Failed sending web tool update to ${msg.from ?? conversationId}: ${String(err)}`, + `Failed sending web tool update to ${msg.from ?? conversationId}: ${formatError(err)}`, ); }); }; @@ -1201,7 +1201,7 @@ export async function monitorWebProvider( } } catch (err) { whatsappOutboundLog.error( - `Failed sending web auto-reply to ${msg.from ?? conversationId}: ${String(err)}`, + `Failed sending web auto-reply to ${msg.from ?? conversationId}: ${formatError(err)}`, ); } } @@ -1323,7 +1323,7 @@ export async function monitorWebProvider( try { await listener.close(); } catch (err) { - logVerbose(`Socket close failed: ${String(err)}`); + logVerbose(`Socket close failed: ${formatError(err)}`); } }; @@ -1378,7 +1378,9 @@ export async function monitorWebProvider( whatsappHeartbeatLog.warn( `No messages received in ${minutesSinceLastMessage}m - restarting connection`, ); - closeListener(); // Trigger reconnect + void closeListener().catch((err) => { + logVerbose(`Close listener failed: ${formatError(err)}`); + }); // Trigger reconnect } } }, WATCHDOG_CHECK_MS); @@ -1593,7 +1595,7 @@ export async function monitorWebProvider( heartbeatLogger.warn( { connectionId, - error: String(err), + error: formatError(err), durationMs, }, "reply heartbeat failed", @@ -1601,7 +1603,7 @@ export async function monitorWebProvider( whatsappHeartbeatLog.warn( `heartbeat failed (${formatDuration(durationMs)})`, ); - return { status: "failed", reason: String(err) }; + return { status: "failed", reason: formatError(err) }; } }; @@ -1630,7 +1632,7 @@ export async function monitorWebProvider( const reason = await Promise.race([ listener.onClose?.catch((err) => { reconnectLogger.error( - { error: String(err) }, + { error: formatError(err) }, "listener.onClose rejected", ); return { status: 500, isLoggedOut: false, error: err }; diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index a3177a704..2025eae9f 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -19,6 +19,7 @@ vi.mock("./session.js", () => { createWaSocket, waitForWaConnection, formatError, + resolveWebAuthDir: () => "/tmp/wa-creds", WA_WEB_AUTH_DIR: "/tmp/wa-creds", }; }); diff --git a/src/web/login.test.ts b/src/web/login.test.ts index ff8908495..633f2ff84 100644 --- a/src/web/login.test.ts +++ b/src/web/login.test.ts @@ -35,11 +35,12 @@ describe("web login", () => { it("loginWeb waits for connection and closes", async () => { const sock = await createWaSocket(); + const close = vi.spyOn(sock.ws, "close"); const waiter: typeof waitForWaConnection = vi .fn() .mockResolvedValue(undefined); await loginWeb(false, "web", waiter); await new Promise((resolve) => setTimeout(resolve, 550)); - expect(sock.ws.close).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); }); }); diff --git a/src/web/login.ts b/src/web/login.ts index 5537782e8..bd9034907 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -8,7 +8,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { createWaSocket, formatError, - WA_WEB_AUTH_DIR, + resolveWebAuthDir, waitForWaConnection, } from "./session.js"; @@ -56,7 +56,7 @@ export async function loginWeb( } } if (code === DisconnectReason.loggedOut) { - await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); + await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); console.error( danger( "WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdis login and scan the QR again.", diff --git a/src/web/reconnect.ts b/src/web/reconnect.ts index 33ca76fca..e6fd487c9 100644 --- a/src/web/reconnect.ts +++ b/src/web/reconnect.ts @@ -35,10 +35,12 @@ export function resolveReconnectPolicy( cfg: ClawdisConfig, overrides?: Partial, ): ReconnectPolicy { + const reconnectOverrides = cfg.web?.reconnect ?? {}; + const overrideConfig = overrides ?? {}; const merged = { ...DEFAULT_RECONNECT_POLICY, - ...(cfg.web?.reconnect ?? {}), - ...(overrides ?? {}), + ...reconnectOverrides, + ...overrideConfig, } as ReconnectPolicy; merged.initialMs = Math.max(250, merged.initialMs); diff --git a/src/web/session.ts b/src/web/session.ts index 21c1b4bb4..6e90182e5 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { DisconnectReason, @@ -19,9 +20,19 @@ import type { Provider } from "../utils.js"; import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js"; import { VERSION } from "../version.js"; +export function resolveWebAuthDir() { + return path.join(os.homedir(), ".clawdis", "credentials"); +} + +function resolveWebCredsPath() { + return path.join(resolveWebAuthDir(), "creds.json"); +} + +function resolveWebCredsBackupPath() { + return path.join(resolveWebAuthDir(), "creds.json.bak"); +} + export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials"); -const WA_CREDS_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json"); -const WA_CREDS_BACKUP_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json.bak"); let credsSaveQueue: Promise = Promise.resolve(); function enqueueSaveCreds( @@ -50,21 +61,23 @@ function maybeRestoreCredsFromBackup( logger: ReturnType, ): void { try { - const raw = readCredsJsonRaw(WA_CREDS_PATH); + const credsPath = resolveWebCredsPath(); + const backupPath = resolveWebCredsBackupPath(); + const raw = readCredsJsonRaw(credsPath); if (raw) { // Validate that creds.json is parseable. JSON.parse(raw); return; } - const backupRaw = readCredsJsonRaw(WA_CREDS_BACKUP_PATH); + const backupRaw = readCredsJsonRaw(backupPath); if (!backupRaw) return; // Ensure backup is parseable before restoring. JSON.parse(backupRaw); - fsSync.copyFileSync(WA_CREDS_BACKUP_PATH, WA_CREDS_PATH); + fsSync.copyFileSync(backupPath, credsPath); logger.warn( - { credsPath: WA_CREDS_PATH }, + { credsPath }, "restored corrupted WhatsApp creds.json from backup", ); } catch { @@ -79,11 +92,13 @@ async function safeSaveCreds( try { // Best-effort backup so we can recover after abrupt restarts. // Important: don't clobber a good backup with a corrupted/truncated creds.json. - const raw = readCredsJsonRaw(WA_CREDS_PATH); + const credsPath = resolveWebCredsPath(); + const backupPath = resolveWebCredsBackupPath(); + const raw = readCredsJsonRaw(credsPath); if (raw) { try { JSON.parse(raw); - fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH); + fsSync.copyFileSync(credsPath, backupPath); } catch { // keep existing backup } @@ -114,10 +129,11 @@ export async function createWaSocket( }, ); const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent"); - await ensureDir(WA_WEB_AUTH_DIR); + const authDir = resolveWebAuthDir(); + await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); maybeRestoreCredsFromBackup(sessionLogger); - const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR); + const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ auth: { @@ -283,6 +299,10 @@ export function formatError(err: unknown): string { const status = boom?.statusCode ?? getStatusCode(err); const code = (err as { code?: unknown })?.code; + const codeText = + typeof code === "string" || typeof code === "number" + ? String(code) + : undefined; const messageCandidates = [ boom?.message, @@ -300,7 +320,7 @@ export function formatError(err: unknown): string { if (typeof status === "number") pieces.push(`status=${status}`); if (boom?.error) pieces.push(boom.error); if (message) pieces.push(message); - if (code !== undefined && code !== null) pieces.push(`code=${String(code)}`); + if (codeText) pieces.push(`code=${codeText}`); if (pieces.length > 0) return pieces.join(" "); return safeStringify(err); @@ -309,15 +329,17 @@ export function formatError(err: unknown): string { export async function webAuthExists() { const sessionLogger = getChildLogger({ module: "web-session" }); maybeRestoreCredsFromBackup(sessionLogger); + const authDir = resolveWebAuthDir(); + const credsPath = resolveWebCredsPath(); try { - await fs.access(WA_WEB_AUTH_DIR); + await fs.access(authDir); } catch { return false; } try { - const stats = await fs.stat(WA_CREDS_PATH); + const stats = await fs.stat(credsPath); if (!stats.isFile() || stats.size <= 1) return false; - const raw = await fs.readFile(WA_CREDS_PATH, "utf-8"); + const raw = await fs.readFile(credsPath, "utf-8"); JSON.parse(raw); return true; } catch { @@ -331,7 +353,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { runtime.log(info("No WhatsApp Web session found; nothing to delete.")); return false; } - await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true }); + await fs.rm(resolveWebAuthDir(), { recursive: true, force: true }); // Also drop session store to clear lingering per-sender state after logout. await fs.rm(resolveDefaultSessionStorePath(), { force: true }); runtime.log(success("Cleared WhatsApp Web credentials.")); @@ -341,10 +363,11 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) { export function readWebSelfId() { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. try { - if (!fsSync.existsSync(WA_CREDS_PATH)) { + const credsPath = resolveWebCredsPath(); + if (!fsSync.existsSync(credsPath)) { return { e164: null, jid: null } as const; } - const raw = fsSync.readFileSync(WA_CREDS_PATH, "utf-8"); + const raw = fsSync.readFileSync(credsPath, "utf-8"); const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; const jid = parsed?.me?.id ?? null; const e164 = jid ? jidToE164(jid) : null; @@ -360,7 +383,7 @@ export function readWebSelfId() { */ export function getWebAuthAgeMs(): number | null { try { - const stats = fsSync.statSync(WA_CREDS_PATH); + const stats = fsSync.statSync(resolveWebCredsPath()); return Date.now() - stats.mtimeMs; } catch { return null; diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index d20a2cb3d..64b0d14f9 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -20,7 +20,7 @@ if (!(globalThis as Record)[CONFIG_KEY]) { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } -export function setLoadConfigMock(fn: (() => unknown) | unknown) { +export function setLoadConfigMock(fn: unknown) { (globalThis as Record)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn; } diff --git a/test/auto-reply.retry.test.ts b/test/auto-reply.retry.test.ts index 593fefeaf..9a7a7b623 100644 --- a/test/auto-reply.retry.test.ts +++ b/test/auto-reply.retry.test.ts @@ -9,22 +9,40 @@ vi.mock("../src/web/media.js", () => ({ })), })); -import { deliverWebReply } from "../src/web/auto-reply.js"; import { defaultRuntime } from "../src/runtime.js"; +import { deliverWebReply } from "../src/web/auto-reply.js"; +import type { WebInboundMessage } from "../src/web/inbound.js"; const noopLogger = { info: vi.fn(), warn: vi.fn(), }; -function makeMsg() { +function makeMsg(): WebInboundMessage { + const reply = vi.fn< + Parameters, + ReturnType + >(); + const sendMedia = vi.fn< + Parameters, + ReturnType + >(); + const sendComposing = vi.fn< + Parameters, + ReturnType + >(); return { from: "+10000000000", + conversationId: "+10000000000", to: "+20000000000", id: "abc", - reply: vi.fn(), - sendMedia: vi.fn(), - } as any; + body: "hello", + chatType: "direct", + chatId: "chat-1", + sendComposing, + reply, + sendMedia, + }; } describe("deliverWebReply retry", () => { @@ -54,7 +72,10 @@ describe("deliverWebReply retry", () => { await expect( deliverWebReply({ - replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" }, + replyResult: { + text: "caption", + mediaUrl: "http://example.com/img.jpg", + }, msg, maxMediaBytes: 5_000_000, replyLogger: noopLogger, @@ -66,4 +87,3 @@ describe("deliverWebReply retry", () => { expect(msg.sendMedia).toHaveBeenCalledTimes(2); }); }); - diff --git a/test/mocks/baileys.ts b/test/mocks/baileys.ts index 3c6b08591..aff4b9c48 100644 --- a/test/mocks/baileys.ts +++ b/test/mocks/baileys.ts @@ -1,57 +1,70 @@ +import { EventEmitter } from "node:events"; + import { vi } from "vitest"; export type MockBaileysSocket = { - ev: import("events").EventEmitter; - ws: { close: ReturnType }; - sendPresenceUpdate: ReturnType; - sendMessage: ReturnType; - readMessages: ReturnType; - user?: { id?: string }; + ev: EventEmitter; + ws: { close: ReturnType }; + sendPresenceUpdate: ReturnType; + sendMessage: ReturnType; + readMessages: ReturnType; + user?: { id?: string }; }; export type MockBaileysModule = { - DisconnectReason: { loggedOut: number }; - fetchLatestBaileysVersion: ReturnType; - makeCacheableSignalKeyStore: ReturnType; - makeWASocket: ReturnType; - useMultiFileAuthState: ReturnType; - jidToE164?: (jid: string) => string | null; - proto?: unknown; - downloadMediaMessage?: ReturnType; + DisconnectReason: { loggedOut: number }; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; + makeWASocket: ReturnType; + useMultiFileAuthState: ReturnType; + jidToE164?: (jid: string) => string | null; + proto?: unknown; + downloadMediaMessage?: ReturnType; }; -export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } { - const sockets: MockBaileysSocket[] = []; - const makeWASocket = vi.fn((opts: unknown) => { - const ev = new (require("events").EventEmitter)(); - const sock: MockBaileysSocket = { - ev, - ws: { close: vi.fn() }, - sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }), - readMessages: vi.fn().mockResolvedValue(undefined), - user: { id: "123@s.whatsapp.net" }, - }; - setImmediate(() => ev.emit("connection.update", { connection: "open" })); - sockets.push(sock); - return sock; - }); +export function createMockBaileys(): { + mod: MockBaileysModule; + lastSocket: () => MockBaileysSocket; +} { + const sockets: MockBaileysSocket[] = []; + const makeWASocket = vi.fn((_opts: unknown) => { + const ev = new EventEmitter(); + const sock: MockBaileysSocket = { + ev, + ws: { close: vi.fn() }, + sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }), + readMessages: vi.fn().mockResolvedValue(undefined), + user: { id: "123@s.whatsapp.net" }, + }; + setImmediate(() => ev.emit("connection.update", { connection: "open" })); + sockets.push(sock); + return sock; + }); - const mod: MockBaileysModule = { - DisconnectReason: { loggedOut: 401 }, - fetchLatestBaileysVersion: vi.fn().mockResolvedValue({ version: [1, 2, 3] }), - makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), - makeWASocket, - useMultiFileAuthState: vi.fn(async () => ({ - state: { creds: {}, keys: {} }, - saveCreds: vi.fn(), - })), - jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"), - downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")), - }; + const mod: MockBaileysModule = { + DisconnectReason: { loggedOut: 401 }, + fetchLatestBaileysVersion: vi + .fn() + .mockResolvedValue({ version: [1, 2, 3] }), + makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), + makeWASocket, + useMultiFileAuthState: vi.fn(async () => ({ + state: { creds: {}, keys: {} }, + saveCreds: vi.fn(), + })), + jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"), + downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")), + }; - return { - mod, - lastSocket: () => sockets[sockets.length - 1]!, - }; + return { + mod, + lastSocket: () => { + const last = sockets.at(-1); + if (!last) { + throw new Error("No Baileys sockets created"); + } + return last; + }, + }; }