refactor: lint cleanups and helpers

This commit is contained in:
Peter Steinberger 2025-12-23 00:28:40 +00:00
parent f5837dff9c
commit 918cbdcf03
39 changed files with 679 additions and 338 deletions

View File

@ -3,16 +3,6 @@ import fs from "node:fs/promises";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
import { type TSchema, Type } from "@sinclair/typebox"; import { type TSchema, Type } from "@sinclair/typebox";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "../browser/client-actions.js";
import { import {
browserCloseTab, browserCloseTab,
browserFocusTab, browserFocusTab,
@ -23,13 +13,22 @@ import {
browserStop, browserStop,
browserTabs, browserTabs,
} from "../browser/client.js"; } from "../browser/client.js";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "../browser/client-actions.js";
import { resolveBrowserConfig } from "../browser/config.js"; import { resolveBrowserConfig } from "../browser/config.js";
import { import {
type CameraFacing,
cameraTempPath, cameraTempPath,
parseCameraClipPayload, parseCameraClipPayload,
parseCameraSnapPayload, parseCameraSnapPayload,
writeBase64ToFile, writeBase64ToFile,
type CameraFacing,
} from "../cli/nodes-camera.js"; } from "../cli/nodes-camera.js";
import { import {
canvasSnapshotTempPath, canvasSnapshotTempPath,
@ -72,6 +71,31 @@ function resolveGatewayOptions(opts?: GatewayCallOptions) {
return { url, token, timeoutMs }; return { url, token, timeoutMs };
} }
type StringParamOptions = {
required?: boolean;
trim?: boolean;
label?: string;
};
function readStringParam(
params: Record<string, unknown>,
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<T = unknown>( async function callGatewayTool<T = unknown>(
method: string, method: string,
opts: GatewayCallOptions, opts: GatewayCallOptions,
@ -342,10 +366,22 @@ const BrowserActSchema = Type.Object({
}); });
const BrowserToolSchema = Type.Union([ const BrowserToolSchema = Type.Union([
Type.Object({ action: Type.Literal("status"), controlUrl: Type.Optional(Type.String()) }), Type.Object({
Type.Object({ action: Type.Literal("start"), controlUrl: Type.Optional(Type.String()) }), action: Type.Literal("status"),
Type.Object({ action: Type.Literal("stop"), controlUrl: Type.Optional(Type.String()) }), controlUrl: Type.Optional(Type.String()),
Type.Object({ action: Type.Literal("tabs"), 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({ Type.Object({
action: Type.Literal("open"), action: Type.Literal("open"),
controlUrl: Type.Optional(Type.String()), controlUrl: Type.Optional(Type.String()),
@ -364,7 +400,9 @@ const BrowserToolSchema = Type.Union([
Type.Object({ Type.Object({
action: Type.Literal("snapshot"), action: Type.Literal("snapshot"),
controlUrl: Type.Optional(Type.String()), 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()), targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
}), }),
@ -375,7 +413,9 @@ const BrowserToolSchema = Type.Union([
fullPage: Type.Optional(Type.Boolean()), fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()), ref: Type.Optional(Type.String()),
element: 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({ Type.Object({
action: Type.Literal("navigate"), action: Type.Literal("navigate"),
@ -425,9 +465,8 @@ function createBrowserTool(): AnyAgentTool {
parameters: BrowserToolSchema, parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = String(params.action ?? ""); const action = readStringParam(params, "action", { required: true });
const controlUrl = const controlUrl = readStringParam(params, "controlUrl");
typeof params.controlUrl === "string" ? params.controlUrl : undefined;
const baseUrl = resolveBrowserBaseUrl(controlUrl); const baseUrl = resolveBrowserBaseUrl(controlUrl);
switch (action) { switch (action) {
@ -442,19 +481,20 @@ function createBrowserTool(): AnyAgentTool {
case "tabs": case "tabs":
return jsonResult({ tabs: await browserTabs(baseUrl) }); return jsonResult({ tabs: await browserTabs(baseUrl) });
case "open": { case "open": {
const targetUrl = String(params.targetUrl ?? "").trim(); const targetUrl = readStringParam(params, "targetUrl", {
if (!targetUrl) throw new Error("targetUrl required"); required: true,
});
return jsonResult(await browserOpenTab(baseUrl, targetUrl)); return jsonResult(await browserOpenTab(baseUrl, targetUrl));
} }
case "focus": { case "focus": {
const targetId = String(params.targetId ?? "").trim(); const targetId = readStringParam(params, "targetId", {
if (!targetId) throw new Error("targetId required"); required: true,
});
await browserFocusTab(baseUrl, targetId); await browserFocusTab(baseUrl, targetId);
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "close": { case "close": {
const targetId = const targetId = readStringParam(params, "targetId");
typeof params.targetId === "string" ? params.targetId.trim() : "";
if (targetId) await browserCloseTab(baseUrl, targetId); if (targetId) await browserCloseTab(baseUrl, targetId);
else await browserAct(baseUrl, { kind: "close" }); else await browserAct(baseUrl, { kind: "close" });
return jsonResult({ ok: true }); return jsonResult({ ok: true });
@ -465,7 +505,9 @@ function createBrowserTool(): AnyAgentTool {
? (params.format as "ai" | "aria") ? (params.format as "ai" | "aria")
: "aria"; : "aria";
const targetId = const targetId =
typeof params.targetId === "string" ? params.targetId.trim() : undefined; typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const limit = const limit =
typeof params.limit === "number" && Number.isFinite(params.limit) typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit ? params.limit
@ -484,13 +526,10 @@ function createBrowserTool(): AnyAgentTool {
return jsonResult(snapshot); return jsonResult(snapshot);
} }
case "screenshot": { case "screenshot": {
const targetId = const targetId = readStringParam(params, "targetId");
typeof params.targetId === "string" ? params.targetId.trim() : undefined;
const fullPage = Boolean(params.fullPage); const fullPage = Boolean(params.fullPage);
const ref = const ref = readStringParam(params, "ref");
typeof params.ref === "string" ? params.ref.trim() : undefined; const element = readStringParam(params, "element");
const element =
typeof params.element === "string" ? params.element.trim() : undefined;
const type = params.type === "jpeg" ? "jpeg" : "png"; const type = params.type === "jpeg" ? "jpeg" : "png";
const result = await browserScreenshotAction(baseUrl, { const result = await browserScreenshotAction(baseUrl, {
targetId, targetId,
@ -506,10 +545,10 @@ function createBrowserTool(): AnyAgentTool {
}); });
} }
case "navigate": { case "navigate": {
const targetUrl = String(params.targetUrl ?? "").trim(); const targetUrl = readStringParam(params, "targetUrl", {
if (!targetUrl) throw new Error("targetUrl required"); required: true,
const targetId = });
typeof params.targetId === "string" ? params.targetId.trim() : undefined; const targetId = readStringParam(params, "targetId");
return jsonResult( return jsonResult(
await browserNavigate(baseUrl, { url: targetUrl, targetId }), await browserNavigate(baseUrl, { url: targetUrl, targetId }),
); );
@ -518,14 +557,18 @@ function createBrowserTool(): AnyAgentTool {
const level = const level =
typeof params.level === "string" ? params.level.trim() : undefined; typeof params.level === "string" ? params.level.trim() : undefined;
const targetId = const targetId =
typeof params.targetId === "string" ? params.targetId.trim() : undefined; typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
return jsonResult( return jsonResult(
await browserConsoleMessages(baseUrl, { level, targetId }), await browserConsoleMessages(baseUrl, { level, targetId }),
); );
} }
case "pdf": { case "pdf": {
const targetId = const targetId =
typeof params.targetId === "string" ? params.targetId.trim() : undefined; typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const result = await browserPdfSave(baseUrl, { targetId }); const result = await browserPdfSave(baseUrl, { targetId });
return { return {
content: [{ type: "text", text: `FILE:${result.path}` }], content: [{ type: "text", text: `FILE:${result.path}` }],
@ -538,23 +581,35 @@ function createBrowserTool(): AnyAgentTool {
: []; : [];
if (paths.length === 0) throw new Error("paths required"); if (paths.length === 0) throw new Error("paths required");
const targetId = const targetId =
typeof params.targetId === "string" ? params.targetId.trim() : undefined; typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs = const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs ? params.timeoutMs
: undefined; : undefined;
return jsonResult( return jsonResult(
await browserArmFileChooser(baseUrl, { paths, targetId, timeoutMs }), await browserArmFileChooser(baseUrl, {
paths,
targetId,
timeoutMs,
}),
); );
} }
case "dialog": { case "dialog": {
const accept = Boolean(params.accept); const accept = Boolean(params.accept);
const promptText = const promptText =
typeof params.promptText === "string" ? params.promptText : undefined; typeof params.promptText === "string"
? params.promptText
: undefined;
const targetId = const targetId =
typeof params.targetId === "string" ? params.targetId.trim() : undefined; typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs = const timeoutMs =
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs ? params.timeoutMs
: undefined; : undefined;
return jsonResult( return jsonResult(
@ -571,7 +626,10 @@ function createBrowserTool(): AnyAgentTool {
if (!request || typeof request !== "object") { if (!request || typeof request !== "object") {
throw new Error("request required"); throw new Error("request required");
} }
const result = await browserAct(baseUrl, request as Parameters<typeof browserAct>[1]); const result = await browserAct(
baseUrl,
request as Parameters<typeof browserAct>[1],
);
return jsonResult(result); return jsonResult(result);
} }
default: default:
@ -623,7 +681,13 @@ const CanvasToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()), 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()), maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()),
}), }),
@ -654,25 +718,24 @@ function createCanvasTool(): AnyAgentTool {
parameters: CanvasToolSchema, parameters: CanvasToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = String(params.action ?? ""); const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = { const gatewayOpts: GatewayCallOptions = {
gatewayUrl: gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
gatewayToken:
typeof params.gatewayToken === "string"
? params.gatewayToken
: undefined,
timeoutMs: timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
}; };
const nodeId = await resolveNodeId( const nodeId = await resolveNodeId(
gatewayOpts, gatewayOpts,
typeof params.node === "string" ? params.node : undefined, readStringParam(params, "node", { trim: true }),
true, true,
); );
const invoke = async (command: string, invokeParams?: Record<string, unknown>) => const invoke = async (
command: string,
invokeParams?: Record<string, unknown>,
) =>
await callGatewayTool("node.invoke", gatewayOpts, { await callGatewayTool("node.invoke", gatewayOpts, {
nodeId, nodeId,
command, command,
@ -686,7 +749,8 @@ function createCanvasTool(): AnyAgentTool {
x: typeof params.x === "number" ? params.x : undefined, x: typeof params.x === "number" ? params.x : undefined,
y: typeof params.y === "number" ? params.y : undefined, y: typeof params.y === "number" ? params.y : undefined,
width: typeof params.width === "number" ? params.width : 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<string, unknown> = {}; const invokeParams: Record<string, unknown> = {};
if (typeof params.target === "string" && params.target.trim()) { if (typeof params.target === "string" && params.target.trim()) {
@ -707,14 +771,14 @@ function createCanvasTool(): AnyAgentTool {
await invoke("canvas.hide", undefined); await invoke("canvas.hide", undefined);
return jsonResult({ ok: true }); return jsonResult({ ok: true });
case "navigate": { case "navigate": {
const url = String(params.url ?? "").trim(); const url = readStringParam(params, "url", { required: true });
if (!url) throw new Error("url required");
await invoke("canvas.navigate", { url }); await invoke("canvas.navigate", { url });
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "eval": { case "eval": {
const javaScript = String(params.javaScript ?? "").trim(); const javaScript = readStringParam(params, "javaScript", {
if (!javaScript) throw new Error("javaScript required"); required: true,
});
const raw = (await invoke("canvas.eval", { javaScript })) as { const raw = (await invoke("canvas.eval", { javaScript })) as {
payload?: { result?: string }; payload?: { result?: string };
}; };
@ -724,15 +788,19 @@ function createCanvasTool(): AnyAgentTool {
} }
case "snapshot": { case "snapshot": {
const formatRaw = const formatRaw =
typeof params.format === "string" ? params.format.toLowerCase() : "png"; typeof params.format === "string"
? params.format.toLowerCase()
: "png";
const format = const format =
formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth = const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth ? params.maxWidth
: undefined; : undefined;
const quality = const quality =
typeof params.quality === "number" && Number.isFinite(params.quality) typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality ? params.quality
: undefined; : undefined;
const raw = (await invoke("canvas.snapshot", { const raw = (await invoke("canvas.snapshot", {
@ -819,16 +887,20 @@ const NodesToolSchema = Type.Union([
title: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()), body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()), sound: Type.Optional(Type.String()),
priority: Type.Optional(Type.Union([ priority: Type.Optional(
Type.Literal("passive"), Type.Union([
Type.Literal("active"), Type.Literal("passive"),
Type.Literal("timeSensitive"), Type.Literal("active"),
])), Type.Literal("timeSensitive"),
delivery: Type.Optional(Type.Union([ ]),
Type.Literal("system"), ),
Type.Literal("overlay"), delivery: Type.Optional(
Type.Literal("auto"), Type.Union([
])), Type.Literal("system"),
Type.Literal("overlay"),
Type.Literal("auto"),
]),
),
}), }),
Type.Object({ Type.Object({
action: Type.Literal("camera_snap"), action: Type.Literal("camera_snap"),
@ -836,7 +908,13 @@ const NodesToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
node: Type.String(), 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()), maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()),
}), }),
@ -846,7 +924,9 @@ const NodesToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
node: Type.String(), 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()), duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()), durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()), includeAudio: Type.Optional(Type.Boolean()),
@ -875,24 +955,21 @@ function createNodesTool(): AnyAgentTool {
parameters: NodesToolSchema, parameters: NodesToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = String(params.action ?? ""); const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = { const gatewayOpts: GatewayCallOptions = {
gatewayUrl: gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
gatewayToken:
typeof params.gatewayToken === "string"
? params.gatewayToken
: undefined,
timeoutMs: timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
}; };
switch (action) { switch (action) {
case "status": case "status":
return jsonResult(await callGatewayTool("node.list", gatewayOpts, {})); return jsonResult(
await callGatewayTool("node.list", gatewayOpts, {}),
);
case "describe": { case "describe": {
const node = String(params.node ?? "").trim(); const node = readStringParam(params, "node", { required: true });
if (!node) throw new Error("node required");
const nodeId = await resolveNodeId(gatewayOpts, node); const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult( return jsonResult(
await callGatewayTool("node.describe", gatewayOpts, { nodeId }), await callGatewayTool("node.describe", gatewayOpts, { nodeId }),
@ -903,8 +980,9 @@ function createNodesTool(): AnyAgentTool {
await callGatewayTool("node.pair.list", gatewayOpts, {}), await callGatewayTool("node.pair.list", gatewayOpts, {}),
); );
case "approve": { case "approve": {
const requestId = String(params.requestId ?? "").trim(); const requestId = readStringParam(params, "requestId", {
if (!requestId) throw new Error("requestId required"); required: true,
});
return jsonResult( return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, { await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId, requestId,
@ -912,8 +990,9 @@ function createNodesTool(): AnyAgentTool {
); );
} }
case "reject": { case "reject": {
const requestId = String(params.requestId ?? "").trim(); const requestId = readStringParam(params, "requestId", {
if (!requestId) throw new Error("requestId required"); required: true,
});
return jsonResult( return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, { await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId, requestId,
@ -921,8 +1000,7 @@ function createNodesTool(): AnyAgentTool {
); );
} }
case "notify": { case "notify": {
const node = String(params.node ?? "").trim(); const node = readStringParam(params, "node", { required: true });
if (!node) throw new Error("node required");
const title = typeof params.title === "string" ? params.title : ""; const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : ""; const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) { if (!title.trim() && !body.trim()) {
@ -935,22 +1013,28 @@ function createNodesTool(): AnyAgentTool {
params: { params: {
title: title.trim() || undefined, title: title.trim() || undefined,
body: body.trim() || undefined, body: body.trim() || undefined,
sound: typeof params.sound === "string" ? params.sound : undefined, sound:
typeof params.sound === "string" ? params.sound : undefined,
priority: priority:
typeof params.priority === "string" ? params.priority : undefined, typeof params.priority === "string"
? params.priority
: undefined,
delivery: delivery:
typeof params.delivery === "string" ? params.delivery : undefined, typeof params.delivery === "string"
? params.delivery
: undefined,
}, },
idempotencyKey: crypto.randomUUID(), idempotencyKey: crypto.randomUUID(),
}); });
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "camera_snap": { case "camera_snap": {
const node = String(params.node ?? "").trim(); const node = readStringParam(params, "node", { required: true });
if (!node) throw new Error("node required");
const nodeId = await resolveNodeId(gatewayOpts, node); const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw = const facingRaw =
typeof params.facing === "string" ? params.facing.toLowerCase() : "both"; typeof params.facing === "string"
? params.facing.toLowerCase()
: "both";
const facings: CameraFacing[] = const facings: CameraFacing[] =
facingRaw === "both" facingRaw === "both"
? ["front", "back"] ? ["front", "back"]
@ -960,11 +1044,13 @@ function createNodesTool(): AnyAgentTool {
throw new Error("invalid facing (front|back|both)"); throw new Error("invalid facing (front|back|both)");
})(); })();
const maxWidth = const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth ? params.maxWidth
: undefined; : undefined;
const quality = const quality =
typeof params.quality === "number" && Number.isFinite(params.quality) typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality ? params.quality
: undefined; : undefined;
@ -994,8 +1080,7 @@ function createNodesTool(): AnyAgentTool {
content.push({ content.push({
type: "image", type: "image",
data: payload.base64, data: payload.base64,
mimeType: mimeType: payload.format === "jpeg" ? "image/jpeg" : "image/png",
payload.format === "jpeg" ? "image/jpeg" : "image/png",
}); });
details.push({ details.push({
facing, facing,
@ -1009,22 +1094,26 @@ function createNodesTool(): AnyAgentTool {
return await sanitizeToolResultImages(result, "nodes:camera_snap"); return await sanitizeToolResultImages(result, "nodes:camera_snap");
} }
case "camera_clip": { case "camera_clip": {
const node = String(params.node ?? "").trim(); const node = readStringParam(params, "node", { required: true });
if (!node) throw new Error("node required");
const nodeId = await resolveNodeId(gatewayOpts, node); const nodeId = await resolveNodeId(gatewayOpts, node);
const facing = const facing =
typeof params.facing === "string" ? params.facing.toLowerCase() : "front"; typeof params.facing === "string"
? params.facing.toLowerCase()
: "front";
if (facing !== "front" && facing !== "back") { if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)"); throw new Error("invalid facing (front|back)");
} }
const durationMs = const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs) typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs ? params.durationMs
: typeof params.duration === "string" : typeof params.duration === "string"
? parseDurationMs(params.duration) ? parseDurationMs(params.duration)
: 3000; : 3000;
const includeAudio = const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true; typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, { const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId, nodeId,
command: "camera.clip", command: "camera.clip",
@ -1054,11 +1143,11 @@ function createNodesTool(): AnyAgentTool {
}; };
} }
case "screen_record": { case "screen_record": {
const node = String(params.node ?? "").trim(); const node = readStringParam(params, "node", { required: true });
if (!node) throw new Error("node required");
const nodeId = await resolveNodeId(gatewayOpts, node); const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs = const durationMs =
typeof params.durationMs === "number" && Number.isFinite(params.durationMs) typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs ? params.durationMs
: typeof params.duration === "string" : typeof params.duration === "string"
? parseDurationMs(params.duration) ? parseDurationMs(params.duration)
@ -1068,11 +1157,14 @@ function createNodesTool(): AnyAgentTool {
? params.fps ? params.fps
: 10; : 10;
const screenIndex = const screenIndex =
typeof params.screenIndex === "number" && Number.isFinite(params.screenIndex) typeof params.screenIndex === "number" &&
Number.isFinite(params.screenIndex)
? params.screenIndex ? params.screenIndex
: 0; : 0;
const includeAudio = const includeAudio =
typeof params.includeAudio === "boolean" ? params.includeAudio : true; typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, { const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId, nodeId,
command: "screen.record", command: "screen.record",
@ -1168,7 +1260,9 @@ const CronToolSchema = Type.Union([
gatewayToken: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()), timeoutMs: Type.Optional(Type.Number()),
text: Type.String(), 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, parameters: CronToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = String(params.action ?? ""); const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = { const gatewayOpts: GatewayCallOptions = {
gatewayUrl: gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
typeof params.gatewayUrl === "string" ? params.gatewayUrl : undefined, gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
gatewayToken:
typeof params.gatewayToken === "string"
? params.gatewayToken
: undefined,
timeoutMs: timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined, typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
}; };
switch (action) { switch (action) {
case "status": case "status":
return jsonResult(await callGatewayTool("cron.status", gatewayOpts, {})); return jsonResult(
await callGatewayTool("cron.status", gatewayOpts, {}),
);
case "list": case "list":
return jsonResult( return jsonResult(
await callGatewayTool("cron.list", gatewayOpts, { await callGatewayTool("cron.list", gatewayOpts, {
@ -1211,8 +1303,7 @@ function createCronTool(): AnyAgentTool {
); );
} }
case "update": { case "update": {
const jobId = String(params.jobId ?? "").trim(); const jobId = readStringParam(params, "jobId", { required: true });
if (!jobId) throw new Error("jobId required");
if (!params.patch || typeof params.patch !== "object") { if (!params.patch || typeof params.patch !== "object") {
throw new Error("patch required"); throw new Error("patch required");
} }
@ -1224,29 +1315,25 @@ function createCronTool(): AnyAgentTool {
); );
} }
case "remove": { case "remove": {
const jobId = String(params.jobId ?? "").trim(); const jobId = readStringParam(params, "jobId", { required: true });
if (!jobId) throw new Error("jobId required");
return jsonResult( return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, { jobId }), await callGatewayTool("cron.remove", gatewayOpts, { jobId }),
); );
} }
case "run": { case "run": {
const jobId = String(params.jobId ?? "").trim(); const jobId = readStringParam(params, "jobId", { required: true });
if (!jobId) throw new Error("jobId required");
return jsonResult( return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, { jobId }), await callGatewayTool("cron.run", gatewayOpts, { jobId }),
); );
} }
case "runs": { case "runs": {
const jobId = String(params.jobId ?? "").trim(); const jobId = readStringParam(params, "jobId", { required: true });
if (!jobId) throw new Error("jobId required");
return jsonResult( return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, { jobId }), await callGatewayTool("cron.runs", gatewayOpts, { jobId }),
); );
} }
case "wake": { case "wake": {
const text = String(params.text ?? "").trim(); const text = readStringParam(params, "text", { required: true });
if (!text) throw new Error("text required");
const mode = const mode =
params.mode === "now" || params.mode === "next-heartbeat" params.mode === "now" || params.mode === "next-heartbeat"
? params.mode ? params.mode
@ -1268,5 +1355,10 @@ function createCronTool(): AnyAgentTool {
} }
export function createClawdisTools(): AnyAgentTool[] { export function createClawdisTools(): AnyAgentTool[] {
return [createBrowserTool(), createCanvasTool(), createNodesTool(), createCronTool()]; return [
createBrowserTool(),
createCanvasTool(),
createNodesTool(),
createCronTool(),
];
} }

View File

@ -418,7 +418,7 @@ export async function runEmbeddedPiAgent(params: {
params.abortSignal.addEventListener("abort", onAbort, { once: true }); params.abortSignal.addEventListener("abort", onAbort, { once: true });
} }
} }
let promptError: unknown | null = null; let promptError: unknown = null;
try { try {
try { try {
await session.prompt(params.prompt); await session.prompt(params.prompt);

View File

@ -77,7 +77,7 @@ describe("trigger handling", () => {
makeCfg(home), makeCfg(home),
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; 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(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@ -25,7 +25,7 @@ export type TemplateContext = MsgContext & {
export function applyTemplate(str: string | undefined, ctx: TemplateContext) { export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) return ""; if (!str) return "";
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = (ctx as Record<string, unknown>)[key]; const value = ctx[key as keyof TemplateContext];
return value == null ? "" : String(value); return value ?? "";
}); });
} }

View File

@ -2,7 +2,7 @@ import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js"; import { createTargetViaCdp, evaluateJavaScript, snapshotAria } from "./cdp.js";
describe("cdp", () => { describe("cdp", () => {
@ -29,7 +29,7 @@ describe("cdp", () => {
wsServer.on("connection", (socket) => { wsServer.on("connection", (socket) => {
socket.on("message", (data) => { socket.on("message", (data) => {
const msg = JSON.parse(String(data)) as { const msg = JSON.parse(rawDataToString(data)) as {
id?: number; id?: number;
method?: string; method?: string;
params?: { url?: string }; params?: { url?: string };
@ -78,7 +78,7 @@ describe("cdp", () => {
wsServer.on("connection", (socket) => { wsServer.on("connection", (socket) => {
socket.on("message", (data) => { socket.on("message", (data) => {
const msg = JSON.parse(String(data)) as { const msg = JSON.parse(rawDataToString(data)) as {
id?: number; id?: number;
method?: string; method?: string;
params?: { expression?: string }; params?: { expression?: string };
@ -115,7 +115,7 @@ describe("cdp", () => {
wsServer.on("connection", (socket) => { wsServer.on("connection", (socket) => {
socket.on("message", (data) => { socket.on("message", (data) => {
const msg = JSON.parse(String(data)) as { const msg = JSON.parse(rawDataToString(data)) as {
id?: number; id?: number;
method?: string; method?: string;
}; };

View File

@ -1,5 +1,7 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { rawDataToString } from "../infra/ws.js";
type CdpResponse = { type CdpResponse = {
id: number; id: number;
result?: unknown; result?: unknown;
@ -44,7 +46,7 @@ function createCdpSender(ws: WebSocket) {
ws.on("message", (data) => { ws.on("message", (data) => {
try { try {
const parsed = JSON.parse(String(data)) as CdpResponse; const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") return; if (typeof parsed.id !== "number") return;
const p = pending.get(parsed.id); const p = pending.get(parsed.id);
if (!p) return; if (!p) return;
@ -252,7 +254,11 @@ type RawAXNode = {
function axValue(v: unknown): string { function axValue(v: unknown): string {
if (!v || typeof v !== "object") return ""; if (!v || typeof v !== "object") return "";
const value = (v as { value?: unknown }).value; 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( function formatAriaSnapshot(
@ -444,7 +450,13 @@ export async function getDomText(opts: {
awaitPromise: true, awaitPromise: true,
returnByValue: 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 }; return { text };
} }

View File

@ -5,6 +5,12 @@ import type {
} from "./client-actions-types.js"; } from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js"; import { fetchBrowserJson } from "./client-fetch.js";
export type BrowserFormField = {
ref: string;
type: string;
value?: string | number | boolean;
};
export type BrowserActRequest = export type BrowserActRequest =
| { | {
kind: "click"; kind: "click";
@ -28,7 +34,7 @@ export type BrowserActRequest =
| { kind: "select"; ref: string; values: string[]; targetId?: string } | { kind: "select"; ref: string; values: string[]; targetId?: string }
| { | {
kind: "fill"; kind: "fill";
fields: Array<Record<string, unknown>>; fields: BrowserFormField[];
targetId?: string; targetId?: string;
} }
| { kind: "resize"; width: number; height: number; targetId?: string } | { kind: "resize"; width: number; height: number; targetId?: string }

View File

@ -1,3 +1,5 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
function unwrapCause(err: unknown): unknown { function unwrapCause(err: unknown): unknown {
if (!err || typeof err !== "object") return null; if (!err || typeof err !== "object") return null;
const cause = (err as { cause?: unknown }).cause; const cause = (err as { cause?: unknown }).cause;
@ -10,13 +12,7 @@ function enhanceBrowserFetchError(
timeoutMs: number, timeoutMs: number,
): Error { ): Error {
const cause = unwrapCause(err); const cause = unwrapCause(err);
const code = const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
(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 hint = const hint =
"Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again."; "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")) { if (msg.toLowerCase().includes("abort")) {
return new Error( return new Error(
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`, `Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,

View File

@ -128,9 +128,7 @@ describe("pw-ai", () => {
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" }); const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" });
const browser = createBrowser([p1.page]); const browser = createBrowser([p1.page]);
const connect = chromium.connectOverCDP as unknown as ReturnType< const connect = vi.spyOn(chromium, "connectOverCDP");
typeof vi.fn
>;
connect.mockResolvedValue(browser); connect.mockResolvedValue(browser);
const mod = await importModule(); const mod = await importModule();

View File

@ -1,3 +1,4 @@
import type { BrowserFormField } from "./client-actions-core.js";
import { import {
type BrowserConsoleMessage, type BrowserConsoleMessage,
ensurePageState, ensurePageState,
@ -168,18 +169,29 @@ export async function typeViaPlaywright(opts: {
export async function fillFormViaPlaywright(opts: { export async function fillFormViaPlaywright(opts: {
cdpPort: number; cdpPort: number;
targetId?: string; targetId?: string;
fields: Array<Record<string, unknown>>; fields: BrowserFormField[];
}): Promise<void> { }): Promise<void> {
const page = await getPageForTargetId(opts); const page = await getPageForTargetId(opts);
ensurePageState(page); ensurePageState(page);
for (const field of opts.fields) { for (const field of opts.fields) {
const ref = String(field.ref ?? "").trim(); const ref = field.ref.trim();
const type = String(field.type ?? "").trim(); const type = field.type.trim();
const value = String(field.value ?? ""); const rawValue = field.value;
const value =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!ref || !type) continue; if (!ref || !type) continue;
const locator = refLocator(page, ref); const locator = refLocator(page, ref);
if (type === "checkbox" || type === "radio") { 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; continue;
} }
await locator.fill(value); await locator.fill(value);
@ -199,18 +211,47 @@ export async function evaluateViaPlaywright(opts: {
if (opts.ref) { if (opts.ref) {
const locator = refLocator(page, opts.ref); const locator = refLocator(page, opts.ref);
return await locator.evaluate((el, fnBody) => { return await locator.evaluate((el, fnBody) => {
const runner = new Function( const compileRunner = (body: string) => {
"element", const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate(element) : candidate;`;
`"use strict"; const fn = ${fnBody}; return fn(element);`, // This intentionally evaluates user-supplied code in the browser context.
) as (element: Element) => unknown; // oxlint-disable-next-line typescript-eslint/no-implied-eval
return runner(el as Element); 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); }, fnText);
} }
return await page.evaluate((fnBody) => { return await page.evaluate((fnBody) => {
const runner = new Function( const compileRunner = (body: string) => {
`"use strict"; const fn = ${fnBody}; return fn();`, const inner = `"use strict"; const candidate = ${body}; return typeof candidate === "function" ? candidate() : candidate;`;
) as () => unknown; // This intentionally evaluates user-supplied code in the browser context.
return runner(); // 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); }, fnText);
} }

View File

@ -4,6 +4,7 @@ import type express from "express";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js"; import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js"; import { captureScreenshot, snapshotAria } from "../cdp.js";
import type { BrowserFormField } from "../client-actions-core.js";
import { import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
@ -236,11 +237,24 @@ export function registerBrowserAgentRoutes(
return res.json({ ok: true, targetId: tab.targetId }); return res.json({ ok: true, targetId: tab.targetId });
} }
case "fill": { case "fill": {
const fields = Array.isArray(body.fields) const rawFields = Array.isArray(body.fields) ? body.fields : [];
? (body.fields as Array<Record<string, unknown>>) const fields = rawFields
: null; .map((field) => {
if (!fields?.length) if (!field || typeof field !== "object") return null;
return jsonError(res, 400, "fields are required"); const rec = field as Record<string, unknown>;
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({ await pw.fillFormViaPlaywright({
cdpPort, cdpPort,
targetId: tab.targetId, targetId: tab.targetId,

View File

@ -9,7 +9,11 @@ export function jsonError(
} }
export function toStringOrEmpty(value: unknown) { 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) { export function toNumber(value: unknown) {

View File

@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { import {
CANVAS_HOST_PATH, CANVAS_HOST_PATH,
@ -146,7 +147,7 @@ describe("canvas host", () => {
); );
ws.on("message", (data) => { ws.on("message", (data) => {
clearTimeout(timer); clearTimeout(timer);
resolve(String(data)); resolve(rawDataToString(data));
}); });
}); });

View File

@ -25,7 +25,7 @@ describe("gateway --force helpers", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
originalKill = process.kill; originalKill = process.kill.bind(process);
}); });
afterEach(() => { afterEach(() => {

View File

@ -1,6 +1,7 @@
import { createServer } from "node:net"; import { createServer } from "node:net";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { GatewayClient } from "./client.js"; import { GatewayClient } from "./client.js";
// Find a free localhost port for ad-hoc WS servers. // Find a free localhost port for ad-hoc WS servers.
@ -30,7 +31,7 @@ describe("GatewayClient", () => {
wss.on("connection", (socket) => { wss.on("connection", (socket) => {
socket.once("message", (data) => { 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"; const id = first.id ?? "connect";
// Respond with tiny tick interval to trigger watchdog quickly. // Respond with tiny tick interval to trigger watchdog quickly.
const helloOk = { const helloOk = {

View File

@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { logDebug, logError } from "../logger.js"; import { logDebug, logError } from "../logger.js";
import { import {
type ConnectParams, type ConnectParams,
@ -57,14 +58,15 @@ export class GatewayClient {
this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 }); this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
this.ws.on("open", () => this.sendConnect()); 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) => { this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
this.ws = null; this.ws = null;
this.flushPendingErrors( this.flushPendingErrors(
new Error(`gateway closed (${code}): ${reason.toString()}`), new Error(`gateway closed (${code}): ${reasonText}`),
); );
this.scheduleReconnect(); this.scheduleReconnect();
this.opts.onClose?.(code, reason.toString()); this.opts.onClose?.(code, reasonText);
}); });
this.ws.on("error", (err) => { this.ws.on("error", (err) => {
logDebug(`gateway client error: ${String(err)}`); logDebug(`gateway client error: ${String(err)}`);

View File

@ -10,6 +10,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js"; import { GatewayLockError } from "../infra/gateway-lock.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { rawDataToString } from "../infra/ws.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
import { import {
__resetModelCatalogCacheForTest, __resetModelCatalogCacheForTest,
@ -298,7 +299,7 @@ function onceMessage<T = unknown>(
reject(new Error(`closed ${code}: ${reason.toString()}`)); reject(new Error(`closed ${code}: ${reason.toString()}`));
}; };
const handler = (data: WebSocket.RawData) => { const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(String(data)); const obj = JSON.parse(rawDataToString(data));
if (filter(obj)) { if (filter(obj)) {
clearTimeout(timer); clearTimeout(timer);
ws.off("message", handler); ws.off("message", handler);
@ -678,7 +679,7 @@ describe("gateway server", () => {
expect(res1.ok).toBe(true); expect(res1.ok).toBe(true);
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null) const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)
?.request; ?.request;
const requestId = String(req1?.requestId ?? ""); const requestId = typeof req1?.requestId === "string" ? req1.requestId : "";
expect(requestId.length).toBeGreaterThan(0); expect(requestId.length).toBeGreaterThan(0);
const evt1 = await requestedP; const evt1 = await requestedP;
@ -731,10 +732,10 @@ describe("gateway server", () => {
payload?: unknown; payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "pair-approve-1"); }>(ws, (o) => o.type === "res" && o.id === "pair-approve-1");
expect(approveRes.ok).toBe(true); expect(approveRes.ok).toBe(true);
const token = String( const tokenValue = (
(approveRes.payload as { node?: { token?: unknown } } | null)?.node approveRes.payload as { node?: { token?: unknown } } | null
?.token ?? "", )?.node?.token;
); const token = typeof tokenValue === "string" ? tokenValue : "";
expect(token.length).toBeGreaterThan(0); expect(token.length).toBeGreaterThan(0);
const evt2 = await resolvedP; const evt2 = await resolvedP;
@ -1235,7 +1236,8 @@ describe("gateway server", () => {
payload?: unknown; payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1"); }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-1");
expect(addRes.ok).toBe(true); 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); expect(jobId.length > 0).toBe(true);
ws.send( ws.send(
@ -1345,7 +1347,8 @@ describe("gateway server", () => {
payload?: unknown; payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2"); }>(ws, (o) => o.type === "res" && o.id === "cron-add-log-2");
expect(addRes.ok).toBe(true); 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); expect(jobId.length > 0).toBe(true);
ws.send( ws.send(
@ -1451,7 +1454,11 @@ describe("gateway server", () => {
| { enabled?: unknown; storePath?: unknown } | { enabled?: unknown; storePath?: unknown }
| undefined; | undefined;
expect(statusPayload?.enabled).toBe(true); 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; const atMs = Date.now() + 80;
ws.send( ws.send(
@ -1475,9 +1482,8 @@ describe("gateway server", () => {
payload?: unknown; payload?: unknown;
}>(ws, (o) => o.type === "res" && o.id === "cron-add-auto-1"); }>(ws, (o) => o.type === "res" && o.id === "cron-add-auto-1");
expect(addRes.ok).toBe(true); expect(addRes.ok).toBe(true);
const jobId = String( const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
(addRes.payload as { id?: unknown } | null)?.id ?? "", const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
);
expect(jobId.length > 0).toBe(true); expect(jobId.length > 0).toBe(true);
const finishedEvt = await onceMessage<{ const finishedEvt = await onceMessage<{

View File

@ -105,6 +105,7 @@ import {
WIDE_AREA_DISCOVERY_DOMAIN, WIDE_AREA_DISCOVERY_DOMAIN,
writeWideAreaBridgeZone, writeWideAreaBridgeZone,
} from "../infra/widearea-dns.js"; } from "../infra/widearea-dns.js";
import { rawDataToString } from "../infra/ws.js";
import { import {
createSubsystemLogger, createSubsystemLogger,
getChildLogger, getChildLogger,
@ -1144,10 +1145,18 @@ const wsInflightSince = new Map<string, number>();
function formatError(err: unknown): string { function formatError(err: unknown): string {
if (err instanceof Error) return err.message; if (err instanceof Error) return err.message;
if (typeof err === "string") return err; if (typeof err === "string") return err;
const status = (err as { status?: unknown })?.status; const statusValue = (err as { status?: unknown })?.status;
const code = (err as { code?: unknown })?.code; const codeValue = (err as { code?: unknown })?.code;
if (status || code) const statusText =
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`; 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); return JSON.stringify(err, null, 2);
} }
@ -1161,8 +1170,7 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) {
broadcastHealthUpdate(snap); broadcastHealthUpdate(snap);
} }
return snap; return snap;
})(); })().finally(() => {
healthRefresh.finally(() => {
healthRefresh = null; healthRefresh = null;
}); });
} }
@ -1183,13 +1191,17 @@ export async function startGatewayServer(
} }
const controlUiEnabled = const controlUiEnabled =
opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true; opts.controlUiEnabled ?? cfgAtStart.gateway?.controlUi?.enabled ?? true;
const authBase = cfgAtStart.gateway?.auth ?? {};
const authOverrides = opts.auth ?? {};
const authConfig = { const authConfig = {
...(cfgAtStart.gateway?.auth ?? {}), ...authBase,
...(opts.auth ?? {}), ...authOverrides,
}; };
const tailscaleBase = cfgAtStart.gateway?.tailscale ?? {};
const tailscaleOverrides = opts.tailscale ?? {};
const tailscaleConfig = { const tailscaleConfig = {
...(cfgAtStart.gateway?.tailscale ?? {}), ...tailscaleBase,
...(opts.tailscale ?? {}), ...tailscaleOverrides,
}; };
const tailscaleMode = tailscaleConfig.mode ?? "off"; const tailscaleMode = tailscaleConfig.mode ?? "off";
const token = getGatewayToken(); const token = getGatewayToken();
@ -1849,8 +1861,17 @@ export async function startGatewayServer(
}, },
}; };
} }
const raw = String((params as { raw?: unknown }).raw ?? ""); const rawValue = (params as { raw?: unknown }).raw;
const parsedRes = parseConfigJson5(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) { if (!parsedRes.ok) {
return { return {
ok: false, ok: false,
@ -2949,7 +2970,9 @@ export async function startGatewayServer(
const payload = { const payload = {
...base, ...base,
state: "error", state: "error",
errorMessage: evt.data.error ? String(evt.data.error) : undefined, errorMessage: evt.data.error
? formatForLog(evt.data.error)
: undefined,
}; };
broadcast("chat", payload); broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload); bridgeSendToSession(sessionKey, "chat", payload);
@ -3061,7 +3084,7 @@ export async function startGatewayServer(
socket.on("message", async (data) => { socket.on("message", async (data) => {
if (closed) return; if (closed) return;
const text = data.toString(); const text = rawDataToString(data);
try { try {
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
if (!client) { if (!client) {
@ -4034,8 +4057,19 @@ export async function startGatewayServer(
); );
break; break;
} }
const raw = String((params as { raw?: unknown }).raw ?? ""); const rawValue = (params as { raw?: unknown }).raw;
const parsedRes = parseConfigJson5(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) { if (!parsedRes.ok) {
respond( respond(
false, false,
@ -4147,8 +4181,10 @@ export async function startGatewayServer(
env?: Record<string, string>; env?: Record<string, string>;
}; };
const cfg = loadConfig(); const cfg = loadConfig();
const skills = { ...(cfg.skills ?? {}) }; const skills = cfg.skills ? { ...cfg.skills } : {};
const current = { ...(skills[p.skillKey] ?? {}) }; const current = skills[p.skillKey]
? { ...skills[p.skillKey] }
: {};
if (typeof p.enabled === "boolean") { if (typeof p.enabled === "boolean") {
current.enabled = p.enabled; current.enabled = p.enabled;
} }
@ -4158,11 +4194,11 @@ export async function startGatewayServer(
else delete current.apiKey; else delete current.apiKey;
} }
if (p.env && typeof p.env === "object") { 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)) { for (const [key, value] of Object.entries(p.env)) {
const trimmedKey = key.trim(); const trimmedKey = key.trim();
if (!trimmedKey) continue; if (!trimmedKey) continue;
const trimmedVal = String(value ?? "").trim(); const trimmedVal = value.trim();
if (!trimmedVal) delete nextEnv[trimmedKey]; if (!trimmedVal) delete nextEnv[trimmedKey];
else nextEnv[trimmedKey] = trimmedVal; else nextEnv[trimmedKey] = trimmedVal;
} }
@ -4541,7 +4577,8 @@ export async function startGatewayServer(
} }
case "system-event": { case "system-event": {
const params = (req.params ?? {}) as Record<string, unknown>; const params = (req.params ?? {}) as Record<string, unknown>;
const text = String(params.text ?? "").trim(); const text =
typeof params.text === "string" ? params.text.trim() : "";
if (!text) { if (!text) {
respond( respond(
false, false,

View File

@ -92,5 +92,11 @@ if (isMain) {
process.exit(1); 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);
});
} }

View File

@ -1,7 +1,14 @@
export type AgentEventStream =
| "job"
| "tool"
| "assistant"
| "error"
| (string & {});
export type AgentEventPayload = { export type AgentEventPayload = {
runId: string; runId: string;
seq: number; seq: number;
stream: "job" | "tool" | string; stream: AgentEventStream;
ts: number; ts: number;
data: Record<string, unknown>; data: Record<string, unknown>;
}; };

View File

@ -9,6 +9,9 @@ const logWarn = vi.fn();
const logDebug = vi.fn(); const logDebug = vi.fn();
const getLoggerInfo = vi.fn(); const getLoggerInfo = vi.fn();
const asString = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
vi.mock("../logger.js", () => { vi.mock("../logger.js", () => {
return { return {
logWarn: (message: string) => logWarn(message), logWarn: (message: string) => logWarn(message),
@ -86,8 +89,8 @@ describe("gateway bonjour advertiser", () => {
serviceState: "announced", serviceState: "announced",
on: vi.fn(), on: vi.fn(),
getFQDN: () => getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"), getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1), getPort: () => Number(options.port ?? -1),
}; };
}); });
@ -153,8 +156,8 @@ describe("gateway bonjour advertiser", () => {
serviceState: "announced", serviceState: "announced",
on, on,
getFQDN: () => getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"), getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1), getPort: () => Number(options.port ?? -1),
}; };
}); });
@ -195,8 +198,8 @@ describe("gateway bonjour advertiser", () => {
serviceState: "unannounced", serviceState: "unannounced",
on: vi.fn(), on: vi.fn(),
getFQDN: () => getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"), getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1), getPort: () => Number(options.port ?? -1),
}; };
}); });
@ -245,8 +248,8 @@ describe("gateway bonjour advertiser", () => {
serviceState: "unannounced", serviceState: "unannounced",
on: vi.fn(), on: vi.fn(),
getFQDN: () => getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"), getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1), getPort: () => Number(options.port ?? -1),
}; };
}); });
@ -281,8 +284,8 @@ describe("gateway bonjour advertiser", () => {
serviceState: "announced", serviceState: "announced",
on: vi.fn(), on: vi.fn(),
getFQDN: () => getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`, `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"), getHostname: () => asString(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1), getPort: () => Number(options.port ?? -1),
}; };
}); });

View File

@ -329,8 +329,9 @@ export async function startNodeBridgeServer(
? hello.commands.map((c) => String(c)).filter(Boolean) ? hello.commands.map((c) => String(c)).filter(Boolean)
: verified.node.commands; : verified.node.commands;
const helloPermissions = normalizePermissions(hello.permissions); const helloPermissions = normalizePermissions(hello.permissions);
const basePermissions = verified.node.permissions ?? {};
const permissions = helloPermissions const permissions = helloPermissions
? { ...(verified.node.permissions ?? {}), ...helloPermissions } ? { ...basePermissions, ...helloPermissions }
: verified.node.permissions; : verified.node.permissions;
isAuthenticated = true; isAuthenticated = true;

26
src/infra/errors.ts Normal file
View File

@ -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);
}
}

View File

@ -240,7 +240,7 @@ export function listSystemPresence(): SystemPresence[] {
ensureSelfPresence(); ensureSelfPresence();
// prune expired // prune expired
const now = Date.now(); 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); if (now - v.ts > TTL_MS) entries.delete(k);
} }
// enforce max size (LRU by ts) // enforce max size (LRU by ts)

13
src/infra/ws.ts Normal file
View File

@ -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);
}

View File

@ -12,6 +12,7 @@ import type { ReplyPayload } from "../auto-reply/types.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose } from "../globals.js"; import { danger, logVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js"; import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";
@ -341,11 +342,10 @@ async function sendTelegramText(
try { try {
await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown" }); await bot.api.sendMessage(chatId, text, { parse_mode: "Markdown" });
} catch (err) { } catch (err) {
if (PARSE_ERR_RE.test(String(err ?? ""))) { const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText)) {
runtime.log?.( runtime.log?.(
`telegram markdown parse failed; retrying without formatting: ${String( `telegram markdown parse failed; retrying without formatting: ${errText}`,
err,
)}`,
); );
await bot.api.sendMessage(chatId, text); await bot.api.sendMessage(chatId, text);
return; return;

View File

@ -52,7 +52,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
// Long polling // Long polling
const stopOnAbort = () => { const stopOnAbort = () => {
if (opts.abortSignal?.aborted) bot.stop(); if (opts.abortSignal?.aborted) void bot.stop();
}; };
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try { try {

View File

@ -3,6 +3,8 @@ import { ProxyAgent } from "undici";
export function makeProxyFetch(proxyUrl: string): typeof fetch { export function makeProxyFetch(proxyUrl: string): typeof fetch {
const agent = new ProxyAgent(proxyUrl); const agent = new ProxyAgent(proxyUrl);
return (input: RequestInfo | URL, init?: RequestInit) => return (input: RequestInfo | URL, init?: RequestInit) => {
fetch(input, { ...(init ?? {}), dispatcher: agent }); const base = init ? { ...init } : {};
return fetch(input, { ...base, dispatcher: agent });
};
} }

View File

@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import { Bot, InputFile } from "grammy"; import { Bot, InputFile } from "grammy";
import { formatErrorMessage } from "../infra/errors.js";
import { mediaKindFromMime } from "../media/constants.js"; import { mediaKindFromMime } from "../media/constants.js";
import { loadWebMedia } from "../web/media.js"; import { loadWebMedia } from "../web/media.js";
@ -76,16 +76,17 @@ export async function sendMessageTelegram(
return await fn(); return await fn();
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
const errText = formatErrorMessage(err);
const terminal = const terminal =
attempt === 3 || attempt === 3 ||
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test( !/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(
String(err ?? ""), errText,
); );
if (terminal) break; if (terminal) break;
const backoff = 400 * attempt; const backoff = 400 * attempt;
if (opts.verbose) { if (opts.verbose) {
console.warn( 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); await sleep(backoff);
@ -95,7 +96,7 @@ export async function sendMessageTelegram(
}; };
const wrapChatNotFound = (err: unknown) => { 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 err;
return new Error( return new Error(
[ [
@ -161,10 +162,11 @@ export async function sendMessageTelegram(
).catch(async (err) => { ).catch(async (err) => {
// Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*'). // Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*').
// When that happens, fall back to plain text so the message still delivers. // 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) { if (opts.verbose) {
console.warn( console.warn(
`telegram markdown parse failed, retrying as plain text: ${String(err)}`, `telegram markdown parse failed, retrying as plain text: ${errText}`,
); );
} }
return await sendWithRetry( return await sendWithRetry(

View File

@ -1,7 +1,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { webhookCallback } from "grammy"; import { webhookCallback } from "grammy";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { createTelegramBot } from "./bot.js"; import { createTelegramBot } from "./bot.js";
@ -43,7 +43,16 @@ export async function startTelegramWebhook(opts: {
res.end(); res.end();
return; return;
} }
handler(req, res); const handled = handler(req, res);
if (handled && typeof (handled as Promise<void>).catch === "function") {
void (handled as Promise<void>).catch((err) => {
runtime.log?.(
`Telegram webhook handler failed: ${formatErrorMessage(err)}`,
);
if (!res.headersSent) res.writeHead(500);
res.end();
});
}
}); });
const publicUrl = const publicUrl =
@ -59,7 +68,7 @@ export async function startTelegramWebhook(opts: {
const shutdown = () => { const shutdown = () => {
server.close(); server.close();
bot.stop(); void bot.stop();
}; };
if (opts.abortSignal) { if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true }); opts.abortSignal.addEventListener("abort", shutdown, { once: true });

View File

@ -253,8 +253,9 @@ export async function runWebHeartbeatOnce(opts: {
if (sessionId) { if (sessionId) {
const storePath = resolveStorePath(cfg.inbound?.session?.store); const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const current = store[sessionKey] ?? {};
store[sessionKey] = { store[sessionKey] = {
...(store[sessionKey] ?? {}), ...current,
sessionId, sessionId,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@ -404,10 +405,10 @@ export async function runWebHeartbeatOnce(opts: {
); );
whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`); whatsappHeartbeatLog.info(`heartbeat alert sent to ${to}`);
} catch (err) { } catch (err) {
const reason = String(err); const reason = formatError(err);
heartbeatLogger.warn({ to, error: reason }, "heartbeat failed"); heartbeatLogger.warn({ to, error: reason }, "heartbeat failed");
whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`); whatsappHeartbeatLog.warn(`heartbeat failed (${reason})`);
emitHeartbeatEvent({ status: "failed", to, reason: String(err) }); emitHeartbeatEvent({ status: "failed", to, reason });
throw err; throw err;
} }
} }
@ -561,18 +562,17 @@ async function deliverWebReply(params: {
return await fn(); return await fn();
} catch (err) { } catch (err) {
lastErr = err; lastErr = err;
const errText = formatError(err);
const isLast = attempt === maxAttempts; const isLast = attempt === maxAttempts;
const shouldRetry = /closed|reset|timed\s*out|disconnect/i.test( const shouldRetry = /closed|reset|timed\s*out|disconnect/i.test(
String(err ?? ""), errText,
); );
if (!shouldRetry || isLast) { if (!shouldRetry || isLast) {
throw err; throw err;
} }
const backoffMs = 500 * attempt; const backoffMs = 500 * attempt;
logVerbose( logVerbose(
`Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${String( `Retrying ${label} to ${msg.from} after failure (${attempt}/${maxAttempts - 1}) in ${backoffMs}ms: ${errText}`,
err,
)}`,
); );
await sleep(backoffMs); await sleep(backoffMs);
} }
@ -688,7 +688,7 @@ async function deliverWebReply(params: {
); );
} catch (err) { } catch (err) {
whatsappOutboundLog.error( 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"); replyLogger.warn({ err, mediaUrl }, "failed to send web media reply");
if (index === 0) { if (index === 0) {
@ -1043,12 +1043,12 @@ export async function monitorWebProvider(
to, to,
}).catch((err) => { }).catch((err) => {
replyLogger.warn( replyLogger.warn(
{ error: String(err), storePath, sessionKey: mainKey, to }, { error: formatError(err), storePath, sessionKey: mainKey, to },
"failed updating last route", "failed updating last route",
); );
}); });
backgroundTasks.add(task); backgroundTasks.add(task);
task.finally(() => { void task.finally(() => {
backgroundTasks.delete(task); backgroundTasks.delete(task);
}); });
} }
@ -1096,7 +1096,7 @@ export async function monitorWebProvider(
}) })
.catch((err) => { .catch((err) => {
whatsappOutboundLog.error( 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) { } catch (err) {
whatsappOutboundLog.error( 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 { try {
await listener.close(); await listener.close();
} catch (err) { } catch (err) {
logVerbose(`Socket close failed: ${String(err)}`); logVerbose(`Socket close failed: ${formatError(err)}`);
} }
}; };
@ -1378,7 +1378,9 @@ export async function monitorWebProvider(
whatsappHeartbeatLog.warn( whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`, `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); }, WATCHDOG_CHECK_MS);
@ -1593,7 +1595,7 @@ export async function monitorWebProvider(
heartbeatLogger.warn( heartbeatLogger.warn(
{ {
connectionId, connectionId,
error: String(err), error: formatError(err),
durationMs, durationMs,
}, },
"reply heartbeat failed", "reply heartbeat failed",
@ -1601,7 +1603,7 @@ export async function monitorWebProvider(
whatsappHeartbeatLog.warn( whatsappHeartbeatLog.warn(
`heartbeat failed (${formatDuration(durationMs)})`, `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([ const reason = await Promise.race([
listener.onClose?.catch((err) => { listener.onClose?.catch((err) => {
reconnectLogger.error( reconnectLogger.error(
{ error: String(err) }, { error: formatError(err) },
"listener.onClose rejected", "listener.onClose rejected",
); );
return { status: 500, isLoggedOut: false, error: err }; return { status: 500, isLoggedOut: false, error: err };

View File

@ -19,6 +19,7 @@ vi.mock("./session.js", () => {
createWaSocket, createWaSocket,
waitForWaConnection, waitForWaConnection,
formatError, formatError,
resolveWebAuthDir: () => "/tmp/wa-creds",
WA_WEB_AUTH_DIR: "/tmp/wa-creds", WA_WEB_AUTH_DIR: "/tmp/wa-creds",
}; };
}); });

View File

@ -35,11 +35,12 @@ describe("web login", () => {
it("loginWeb waits for connection and closes", async () => { it("loginWeb waits for connection and closes", async () => {
const sock = await createWaSocket(); const sock = await createWaSocket();
const close = vi.spyOn(sock.ws, "close");
const waiter: typeof waitForWaConnection = vi const waiter: typeof waitForWaConnection = vi
.fn() .fn()
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
await loginWeb(false, "web", waiter); await loginWeb(false, "web", waiter);
await new Promise((resolve) => setTimeout(resolve, 550)); await new Promise((resolve) => setTimeout(resolve, 550));
expect(sock.ws.close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
}); });
}); });

View File

@ -8,7 +8,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { import {
createWaSocket, createWaSocket,
formatError, formatError,
WA_WEB_AUTH_DIR, resolveWebAuthDir,
waitForWaConnection, waitForWaConnection,
} from "./session.js"; } from "./session.js";
@ -56,7 +56,7 @@ export async function loginWeb(
} }
} }
if (code === DisconnectReason.loggedOut) { 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( console.error(
danger( danger(
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdis login and scan the QR again.", "WhatsApp reported the session is logged out. Cleared cached web session; please rerun clawdis login and scan the QR again.",

View File

@ -35,10 +35,12 @@ export function resolveReconnectPolicy(
cfg: ClawdisConfig, cfg: ClawdisConfig,
overrides?: Partial<ReconnectPolicy>, overrides?: Partial<ReconnectPolicy>,
): ReconnectPolicy { ): ReconnectPolicy {
const reconnectOverrides = cfg.web?.reconnect ?? {};
const overrideConfig = overrides ?? {};
const merged = { const merged = {
...DEFAULT_RECONNECT_POLICY, ...DEFAULT_RECONNECT_POLICY,
...(cfg.web?.reconnect ?? {}), ...reconnectOverrides,
...(overrides ?? {}), ...overrideConfig,
} as ReconnectPolicy; } as ReconnectPolicy;
merged.initialMs = Math.max(250, merged.initialMs); merged.initialMs = Math.max(250, merged.initialMs);

View File

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fsSync from "node:fs"; import fsSync from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import {
DisconnectReason, DisconnectReason,
@ -19,9 +20,19 @@ import type { Provider } from "../utils.js";
import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js"; import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js";
import { VERSION } from "../version.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"); 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<void> = Promise.resolve(); let credsSaveQueue: Promise<void> = Promise.resolve();
function enqueueSaveCreds( function enqueueSaveCreds(
@ -50,21 +61,23 @@ function maybeRestoreCredsFromBackup(
logger: ReturnType<typeof getChildLogger>, logger: ReturnType<typeof getChildLogger>,
): void { ): void {
try { try {
const raw = readCredsJsonRaw(WA_CREDS_PATH); const credsPath = resolveWebCredsPath();
const backupPath = resolveWebCredsBackupPath();
const raw = readCredsJsonRaw(credsPath);
if (raw) { if (raw) {
// Validate that creds.json is parseable. // Validate that creds.json is parseable.
JSON.parse(raw); JSON.parse(raw);
return; return;
} }
const backupRaw = readCredsJsonRaw(WA_CREDS_BACKUP_PATH); const backupRaw = readCredsJsonRaw(backupPath);
if (!backupRaw) return; if (!backupRaw) return;
// Ensure backup is parseable before restoring. // Ensure backup is parseable before restoring.
JSON.parse(backupRaw); JSON.parse(backupRaw);
fsSync.copyFileSync(WA_CREDS_BACKUP_PATH, WA_CREDS_PATH); fsSync.copyFileSync(backupPath, credsPath);
logger.warn( logger.warn(
{ credsPath: WA_CREDS_PATH }, { credsPath },
"restored corrupted WhatsApp creds.json from backup", "restored corrupted WhatsApp creds.json from backup",
); );
} catch { } catch {
@ -79,11 +92,13 @@ async function safeSaveCreds(
try { try {
// Best-effort backup so we can recover after abrupt restarts. // Best-effort backup so we can recover after abrupt restarts.
// Important: don't clobber a good backup with a corrupted/truncated creds.json. // 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) { if (raw) {
try { try {
JSON.parse(raw); JSON.parse(raw);
fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH); fsSync.copyFileSync(credsPath, backupPath);
} catch { } catch {
// keep existing backup // keep existing backup
} }
@ -114,10 +129,11 @@ export async function createWaSocket(
}, },
); );
const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent"); 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" }); const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger); maybeRestoreCredsFromBackup(sessionLogger);
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR); const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion(); const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({ const sock = makeWASocket({
auth: { auth: {
@ -283,6 +299,10 @@ export function formatError(err: unknown): string {
const status = boom?.statusCode ?? getStatusCode(err); const status = boom?.statusCode ?? getStatusCode(err);
const code = (err as { code?: unknown })?.code; const code = (err as { code?: unknown })?.code;
const codeText =
typeof code === "string" || typeof code === "number"
? String(code)
: undefined;
const messageCandidates = [ const messageCandidates = [
boom?.message, boom?.message,
@ -300,7 +320,7 @@ export function formatError(err: unknown): string {
if (typeof status === "number") pieces.push(`status=${status}`); if (typeof status === "number") pieces.push(`status=${status}`);
if (boom?.error) pieces.push(boom.error); if (boom?.error) pieces.push(boom.error);
if (message) pieces.push(message); 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(" "); if (pieces.length > 0) return pieces.join(" ");
return safeStringify(err); return safeStringify(err);
@ -309,15 +329,17 @@ export function formatError(err: unknown): string {
export async function webAuthExists() { export async function webAuthExists() {
const sessionLogger = getChildLogger({ module: "web-session" }); const sessionLogger = getChildLogger({ module: "web-session" });
maybeRestoreCredsFromBackup(sessionLogger); maybeRestoreCredsFromBackup(sessionLogger);
const authDir = resolveWebAuthDir();
const credsPath = resolveWebCredsPath();
try { try {
await fs.access(WA_WEB_AUTH_DIR); await fs.access(authDir);
} catch { } catch {
return false; return false;
} }
try { try {
const stats = await fs.stat(WA_CREDS_PATH); const stats = await fs.stat(credsPath);
if (!stats.isFile() || stats.size <= 1) return false; 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); JSON.parse(raw);
return true; return true;
} catch { } catch {
@ -331,7 +353,7 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
runtime.log(info("No WhatsApp Web session found; nothing to delete.")); runtime.log(info("No WhatsApp Web session found; nothing to delete."));
return false; 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. // Also drop session store to clear lingering per-sender state after logout.
await fs.rm(resolveDefaultSessionStorePath(), { force: true }); await fs.rm(resolveDefaultSessionStorePath(), { force: true });
runtime.log(success("Cleared WhatsApp Web credentials.")); runtime.log(success("Cleared WhatsApp Web credentials."));
@ -341,10 +363,11 @@ export async function logoutWeb(runtime: RuntimeEnv = defaultRuntime) {
export function readWebSelfId() { export function readWebSelfId() {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present. // Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try { try {
if (!fsSync.existsSync(WA_CREDS_PATH)) { const credsPath = resolveWebCredsPath();
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const; 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 parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null; const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid) : null; const e164 = jid ? jidToE164(jid) : null;
@ -360,7 +383,7 @@ export function readWebSelfId() {
*/ */
export function getWebAuthAgeMs(): number | null { export function getWebAuthAgeMs(): number | null {
try { try {
const stats = fsSync.statSync(WA_CREDS_PATH); const stats = fsSync.statSync(resolveWebCredsPath());
return Date.now() - stats.mtimeMs; return Date.now() - stats.mtimeMs;
} catch { } catch {
return null; return null;

View File

@ -20,7 +20,7 @@ if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG; (globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
} }
export function setLoadConfigMock(fn: (() => unknown) | unknown) { export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = (globalThis as Record<symbol, unknown>)[CONFIG_KEY] =
typeof fn === "function" ? fn : () => fn; typeof fn === "function" ? fn : () => fn;
} }

View File

@ -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 { defaultRuntime } from "../src/runtime.js";
import { deliverWebReply } from "../src/web/auto-reply.js";
import type { WebInboundMessage } from "../src/web/inbound.js";
const noopLogger = { const noopLogger = {
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
}; };
function makeMsg() { function makeMsg(): WebInboundMessage {
const reply = vi.fn<
Parameters<WebInboundMessage["reply"]>,
ReturnType<WebInboundMessage["reply"]>
>();
const sendMedia = vi.fn<
Parameters<WebInboundMessage["sendMedia"]>,
ReturnType<WebInboundMessage["sendMedia"]>
>();
const sendComposing = vi.fn<
Parameters<WebInboundMessage["sendComposing"]>,
ReturnType<WebInboundMessage["sendComposing"]>
>();
return { return {
from: "+10000000000", from: "+10000000000",
conversationId: "+10000000000",
to: "+20000000000", to: "+20000000000",
id: "abc", id: "abc",
reply: vi.fn(), body: "hello",
sendMedia: vi.fn(), chatType: "direct",
} as any; chatId: "chat-1",
sendComposing,
reply,
sendMedia,
};
} }
describe("deliverWebReply retry", () => { describe("deliverWebReply retry", () => {
@ -54,7 +72,10 @@ describe("deliverWebReply retry", () => {
await expect( await expect(
deliverWebReply({ deliverWebReply({
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" }, replyResult: {
text: "caption",
mediaUrl: "http://example.com/img.jpg",
},
msg, msg,
maxMediaBytes: 5_000_000, maxMediaBytes: 5_000_000,
replyLogger: noopLogger, replyLogger: noopLogger,
@ -66,4 +87,3 @@ describe("deliverWebReply retry", () => {
expect(msg.sendMedia).toHaveBeenCalledTimes(2); expect(msg.sendMedia).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@ -1,57 +1,70 @@
import { EventEmitter } from "node:events";
import { vi } from "vitest"; import { vi } from "vitest";
export type MockBaileysSocket = { export type MockBaileysSocket = {
ev: import("events").EventEmitter; ev: EventEmitter;
ws: { close: ReturnType<typeof vi.fn> }; ws: { close: ReturnType<typeof vi.fn> };
sendPresenceUpdate: ReturnType<typeof vi.fn>; sendPresenceUpdate: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>; sendMessage: ReturnType<typeof vi.fn>;
readMessages: ReturnType<typeof vi.fn>; readMessages: ReturnType<typeof vi.fn>;
user?: { id?: string }; user?: { id?: string };
}; };
export type MockBaileysModule = { export type MockBaileysModule = {
DisconnectReason: { loggedOut: number }; DisconnectReason: { loggedOut: number };
fetchLatestBaileysVersion: ReturnType<typeof vi.fn>; fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>; makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
makeWASocket: ReturnType<typeof vi.fn>; makeWASocket: ReturnType<typeof vi.fn>;
useMultiFileAuthState: ReturnType<typeof vi.fn>; useMultiFileAuthState: ReturnType<typeof vi.fn>;
jidToE164?: (jid: string) => string | null; jidToE164?: (jid: string) => string | null;
proto?: unknown; proto?: unknown;
downloadMediaMessage?: ReturnType<typeof vi.fn>; downloadMediaMessage?: ReturnType<typeof vi.fn>;
}; };
export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } { export function createMockBaileys(): {
const sockets: MockBaileysSocket[] = []; mod: MockBaileysModule;
const makeWASocket = vi.fn((opts: unknown) => { lastSocket: () => MockBaileysSocket;
const ev = new (require("events").EventEmitter)(); } {
const sock: MockBaileysSocket = { const sockets: MockBaileysSocket[] = [];
ev, const makeWASocket = vi.fn((_opts: unknown) => {
ws: { close: vi.fn() }, const ev = new EventEmitter();
sendPresenceUpdate: vi.fn().mockResolvedValue(undefined), const sock: MockBaileysSocket = {
sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }), ev,
readMessages: vi.fn().mockResolvedValue(undefined), ws: { close: vi.fn() },
user: { id: "123@s.whatsapp.net" }, sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
}; sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }),
setImmediate(() => ev.emit("connection.update", { connection: "open" })); readMessages: vi.fn().mockResolvedValue(undefined),
sockets.push(sock); user: { id: "123@s.whatsapp.net" },
return sock; };
}); setImmediate(() => ev.emit("connection.update", { connection: "open" }));
sockets.push(sock);
return sock;
});
const mod: MockBaileysModule = { const mod: MockBaileysModule = {
DisconnectReason: { loggedOut: 401 }, DisconnectReason: { loggedOut: 401 },
fetchLatestBaileysVersion: vi.fn().mockResolvedValue({ version: [1, 2, 3] }), fetchLatestBaileysVersion: vi
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys), .fn()
makeWASocket, .mockResolvedValue({ version: [1, 2, 3] }),
useMultiFileAuthState: vi.fn(async () => ({ makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
state: { creds: {}, keys: {} }, makeWASocket,
saveCreds: vi.fn(), useMultiFileAuthState: vi.fn(async () => ({
})), state: { creds: {}, keys: {} },
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"), saveCreds: vi.fn(),
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")), })),
}; jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")),
};
return { return {
mod, mod,
lastSocket: () => sockets[sockets.length - 1]!, lastSocket: () => {
}; const last = sockets.at(-1);
if (!last) {
throw new Error("No Baileys sockets created");
}
return last;
},
};
} }