refactor: lint cleanups and helpers
This commit is contained in:
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("gateway --force helpers", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalKill = process.kill;
|
||||
originalKill = process.kill.bind(process);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
26
src/infra/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
13
src/infra/ws.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("./session.js", () => {
|
||||
createWaSocket,
|
||||
waitForWaConnection,
|
||||
formatError,
|
||||
resolveWebAuthDir: () => "/tmp/wa-creds",
|
||||
WA_WEB_AUTH_DIR: "/tmp/wa-creds",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user