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

View File

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

View File

@@ -77,7 +77,7 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true);
expect(text?.startsWith("⚙️ Restarting")).toBe(true);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,12 @@ import type {
} from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
export type BrowserFormField = {
ref: string;
type: string;
value?: string | number | boolean;
};
export type BrowserActRequest =
| {
kind: "click";
@@ -28,7 +34,7 @@ export type BrowserActRequest =
| { kind: "select"; ref: string; values: string[]; targetId?: string }
| {
kind: "fill";
fields: Array<Record<string, unknown>>;
fields: BrowserFormField[];
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 {
if (!err || typeof err !== "object") return null;
const cause = (err as { cause?: unknown }).cause;
@@ -10,13 +12,7 @@ function enhanceBrowserFetchError(
timeoutMs: number,
): Error {
const cause = unwrapCause(err);
const code =
(cause && typeof cause === "object" && "code" in cause
? String((cause as { code?: unknown }).code ?? "")
: "") ||
(err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code ?? "")
: "");
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
const hint =
"Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again.";
@@ -32,7 +28,7 @@ function enhanceBrowserFetchError(
);
}
const msg = String(err);
const msg = formatErrorMessage(err);
if (msg.toLowerCase().includes("abort")) {
return new Error(
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,11 @@ export function jsonError(
}
export function toStringOrEmpty(value: unknown) {
return typeof value === "string" ? value.trim() : String(value ?? "").trim();
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
return "";
}
export function toNumber(value: unknown) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,5 +92,11 @@ if (isMain) {
process.exit(1);
});
program.parseAsync(process.argv);
void program.parseAsync(process.argv).catch((err) => {
console.error(
"[clawdis] CLI failed:",
err instanceof Error ? (err.stack ?? err.message) : err,
);
process.exit(1);
});
}

View File

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

View File

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

View File

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

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();
// prune expired
const now = Date.now();
for (const [k, v] of [...entries]) {
for (const [k, v] of entries) {
if (now - v.ts > TTL_MS) entries.delete(k);
}
// enforce max size (LRU by ts)

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { createServer } from "node:http";
import { webhookCallback } from "grammy";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createTelegramBot } from "./bot.js";
@@ -43,7 +43,16 @@ export async function startTelegramWebhook(opts: {
res.end();
return;
}
handler(req, res);
const handled = handler(req, res);
if (handled && typeof (handled as Promise<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 =
@@ -59,7 +68,7 @@ export async function startTelegramWebhook(opts: {
const shutdown = () => {
server.close();
bot.stop();
void bot.stop();
};
if (opts.abortSignal) {
opts.abortSignal.addEventListener("abort", shutdown, { once: true });

View File

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

View File

@@ -19,6 +19,7 @@ vi.mock("./session.js", () => {
createWaSocket,
waitForWaConnection,
formatError,
resolveWebAuthDir: () => "/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 () => {
const sock = await createWaSocket();
const close = vi.spyOn(sock.ws, "close");
const waiter: typeof waitForWaConnection = vi
.fn()
.mockResolvedValue(undefined);
await loginWeb(false, "web", waiter);
await new Promise((resolve) => setTimeout(resolve, 550));
expect(sock.ws.close).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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