mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
refactor(nodes): dedupe camera payload and node resolve helpers
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
readFileUtf8AndCleanup,
|
||||
stubFetchTextResponse,
|
||||
} from "../test-utils/camera-url-test-helpers.js";
|
||||
|
||||
const { callGateway } = vi.hoisted(() => ({
|
||||
callGateway: vi.fn(),
|
||||
@@ -206,10 +209,7 @@ describe("nodes camera_snap", () => {
|
||||
});
|
||||
|
||||
it("downloads camera_snap url payloads when node remoteIp is available", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-image", { status: 200 })),
|
||||
);
|
||||
stubFetchTextResponse("url-image");
|
||||
setupNodeInvokeMock({
|
||||
remoteIp: "198.51.100.42",
|
||||
invokePayload: {
|
||||
@@ -230,18 +230,11 @@ describe("nodes camera_snap", () => {
|
||||
const mediaPath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
|
||||
.replace(/^MEDIA:/, "")
|
||||
.trim();
|
||||
try {
|
||||
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-image");
|
||||
} finally {
|
||||
await fs.unlink(mediaPath).catch(() => {});
|
||||
}
|
||||
await expect(readFileUtf8AndCleanup(mediaPath)).resolves.toBe("url-image");
|
||||
});
|
||||
|
||||
it("rejects camera_snap url payloads when node remoteIp is missing", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-image", { status: 200 })),
|
||||
);
|
||||
stubFetchTextResponse("url-image");
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: {
|
||||
format: "jpg",
|
||||
@@ -263,10 +256,7 @@ describe("nodes camera_snap", () => {
|
||||
|
||||
describe("nodes camera_clip", () => {
|
||||
it("downloads camera_clip url payloads when node remoteIp is available", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-clip", { status: 200 })),
|
||||
);
|
||||
stubFetchTextResponse("url-clip");
|
||||
setupNodeInvokeMock({
|
||||
remoteIp: "198.51.100.42",
|
||||
invokePayload: {
|
||||
@@ -285,18 +275,11 @@ describe("nodes camera_clip", () => {
|
||||
const filePath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "")
|
||||
.replace(/^FILE:/, "")
|
||||
.trim();
|
||||
try {
|
||||
await expect(fs.readFile(filePath, "utf8")).resolves.toBe("url-clip");
|
||||
} finally {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
await expect(readFileUtf8AndCleanup(filePath)).resolves.toBe("url-clip");
|
||||
});
|
||||
|
||||
it("rejects camera_clip url payloads when node remoteIp is missing", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("url-clip", { status: 200 })),
|
||||
);
|
||||
stubFetchTextResponse("url-clip");
|
||||
setupNodeInvokeMock({
|
||||
invokePayload: {
|
||||
format: "mp4",
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
parseCameraClipPayload,
|
||||
parseCameraSnapPayload,
|
||||
writeCameraClipPayloadToFile,
|
||||
writeBase64ToFile,
|
||||
writeUrlToFile,
|
||||
writeCameraPayloadToFile,
|
||||
} from "../../cli/nodes-camera.js";
|
||||
import { parseEnvPairs, parseTimeoutMs } from "../../cli/nodes-run.js";
|
||||
import {
|
||||
@@ -295,16 +294,12 @@ export function createNodesTool(options?: {
|
||||
facing,
|
||||
ext: isJpeg ? "jpg" : "png",
|
||||
});
|
||||
if (payload.url) {
|
||||
if (!resolvedNode.remoteIp) {
|
||||
throw new Error("camera URL payload requires node remoteIp");
|
||||
}
|
||||
await writeUrlToFile(filePath, payload.url, {
|
||||
expectedHost: resolvedNode.remoteIp,
|
||||
});
|
||||
} else if (payload.base64) {
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
}
|
||||
await writeCameraPayloadToFile({
|
||||
filePath,
|
||||
payload,
|
||||
expectedHost: resolvedNode.remoteIp,
|
||||
invalidPayloadMessage: "invalid camera.snap payload",
|
||||
});
|
||||
content.push({ type: "text", text: `MEDIA:${filePath}` });
|
||||
if (payload.base64) {
|
||||
content.push({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js";
|
||||
import type { NodeListNode } from "../../shared/node-list-types.js";
|
||||
import { resolveNodeIdFromCandidates } from "../../shared/node-match.js";
|
||||
import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "../../shared/node-resolve.js";
|
||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||
|
||||
export type { NodeListNode };
|
||||
@@ -142,17 +142,10 @@ export function resolveNodeIdFromList(
|
||||
query?: string,
|
||||
allowDefault = false,
|
||||
): string {
|
||||
const q = String(query ?? "").trim();
|
||||
if (!q) {
|
||||
if (allowDefault) {
|
||||
const picked = pickDefaultNode(nodes);
|
||||
if (picked) {
|
||||
return picked.nodeId;
|
||||
}
|
||||
}
|
||||
throw new Error("node required");
|
||||
}
|
||||
return resolveNodeIdFromCandidates(nodes, q);
|
||||
return resolveNodeIdFromNodeList(nodes, query, {
|
||||
allowDefault,
|
||||
pickDefaultNode: pickDefaultNode,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveNodeId(
|
||||
@@ -169,6 +162,8 @@ export async function resolveNode(
|
||||
allowDefault = false,
|
||||
): Promise<NodeListNode> {
|
||||
const nodes = await loadNodes(opts);
|
||||
const nodeId = resolveNodeIdFromList(nodes, query, allowDefault);
|
||||
return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId };
|
||||
return resolveNodeFromNodeList(nodes, query, {
|
||||
allowDefault,
|
||||
pickDefaultNode: pickDefaultNode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
readFileUtf8AndCleanup,
|
||||
stubFetchResponse,
|
||||
} from "../test-utils/camera-url-test-helpers.js";
|
||||
import { withTempDir } from "../test-utils/temp-dir.js";
|
||||
import {
|
||||
cameraTempPath,
|
||||
@@ -17,13 +21,6 @@ async function withCameraTempDir<T>(run: (dir: string) => Promise<T>): Promise<T
|
||||
}
|
||||
|
||||
describe("nodes camera helpers", () => {
|
||||
function stubFetchResponse(response: Response) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response),
|
||||
);
|
||||
}
|
||||
|
||||
it("parses camera.snap payload", () => {
|
||||
expect(
|
||||
parseCameraSnapPayload({
|
||||
@@ -88,7 +85,7 @@ describe("nodes camera helpers", () => {
|
||||
id: "clip1",
|
||||
});
|
||||
expect(out).toBe(path.join(dir, "openclaw-camera-clip-front-clip1.mp4"));
|
||||
await expect(fs.readFile(out, "utf8")).resolves.toBe("hi");
|
||||
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +106,7 @@ describe("nodes camera helpers", () => {
|
||||
expectedHost,
|
||||
});
|
||||
expect(out).toBe(path.join(dir, "openclaw-camera-clip-back-clip2.mp4"));
|
||||
await expect(fs.readFile(out, "utf8")).resolves.toBe("url-clip");
|
||||
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-clip");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +129,7 @@ describe("nodes camera helpers", () => {
|
||||
await withCameraTempDir(async (dir) => {
|
||||
const out = path.join(dir, "x.bin");
|
||||
await writeBase64ToFile(out, "aGk=");
|
||||
await expect(fs.readFile(out, "utf8")).resolves.toBe("hi");
|
||||
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +144,7 @@ describe("nodes camera helpers", () => {
|
||||
await writeUrlToFile(out, "https://198.51.100.42/clip.mp4", {
|
||||
expectedHost: "198.51.100.42",
|
||||
});
|
||||
await expect(fs.readFile(out, "utf8")).resolves.toBe("url-content");
|
||||
await expect(readFileUtf8AndCleanup(out)).resolves.toBe("url-content");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -182,6 +182,33 @@ export async function writeBase64ToFile(filePath: string, base64: string) {
|
||||
return { path: filePath, bytes: buf.length };
|
||||
}
|
||||
|
||||
export function requireNodeRemoteIp(remoteIp?: string): string {
|
||||
const normalized = remoteIp?.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("camera URL payload requires node remoteIp");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function writeCameraPayloadToFile(params: {
|
||||
filePath: string;
|
||||
payload: { url?: string; base64?: string };
|
||||
expectedHost?: string;
|
||||
invalidPayloadMessage?: string;
|
||||
}) {
|
||||
if (params.payload.url) {
|
||||
await writeUrlToFile(params.filePath, params.payload.url, {
|
||||
expectedHost: requireNodeRemoteIp(params.expectedHost),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (params.payload.base64) {
|
||||
await writeBase64ToFile(params.filePath, params.payload.base64);
|
||||
return;
|
||||
}
|
||||
throw new Error(params.invalidPayloadMessage ?? "invalid camera payload");
|
||||
}
|
||||
|
||||
export async function writeCameraClipPayloadToFile(params: {
|
||||
payload: CameraClipPayload;
|
||||
facing: CameraFacing;
|
||||
@@ -196,15 +223,11 @@ export async function writeCameraClipPayloadToFile(params: {
|
||||
tmpDir: params.tmpDir,
|
||||
id: params.id,
|
||||
});
|
||||
if (params.payload.url) {
|
||||
if (!params.expectedHost) {
|
||||
throw new Error("camera URL payload requires node remoteIp");
|
||||
}
|
||||
await writeUrlToFile(filePath, params.payload.url, { expectedHost: params.expectedHost });
|
||||
} else if (params.payload.base64) {
|
||||
await writeBase64ToFile(filePath, params.payload.base64);
|
||||
} else {
|
||||
throw new Error("invalid camera.clip payload");
|
||||
}
|
||||
await writeCameraPayloadToFile({
|
||||
filePath,
|
||||
payload: params.payload,
|
||||
expectedHost: params.expectedHost,
|
||||
invalidPayloadMessage: "invalid camera.clip payload",
|
||||
});
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
cameraTempPath,
|
||||
parseCameraClipPayload,
|
||||
parseCameraSnapPayload,
|
||||
writeBase64ToFile,
|
||||
writeCameraPayloadToFile,
|
||||
writeCameraClipPayloadToFile,
|
||||
writeUrlToFile,
|
||||
} from "../nodes-camera.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
@@ -166,14 +165,12 @@ export function registerNodesCameraCommands(nodes: Command) {
|
||||
facing,
|
||||
ext: payload.format === "jpeg" ? "jpg" : payload.format,
|
||||
});
|
||||
if (payload.url) {
|
||||
if (!node.remoteIp) {
|
||||
throw new Error("camera URL payload requires node remoteIp");
|
||||
}
|
||||
await writeUrlToFile(filePath, payload.url, { expectedHost: node.remoteIp });
|
||||
} else if (payload.base64) {
|
||||
await writeBase64ToFile(filePath, payload.base64);
|
||||
}
|
||||
await writeCameraPayloadToFile({
|
||||
filePath,
|
||||
payload,
|
||||
expectedHost: node.remoteIp,
|
||||
invalidPayloadMessage: "invalid camera.snap payload",
|
||||
});
|
||||
results.push({
|
||||
facing,
|
||||
path: filePath,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { callGateway, randomIdempotencyKey } from "../../gateway/call.js";
|
||||
import { resolveNodeIdFromCandidates } from "../../shared/node-match.js";
|
||||
import { resolveNodeFromNodeList } from "../../shared/node-resolve.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
import { parseNodeList, parsePairingList } from "./format.js";
|
||||
@@ -77,11 +77,6 @@ export async function resolveNodeId(opts: NodesRpcOpts, query: string) {
|
||||
}
|
||||
|
||||
export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<NodeListNode> {
|
||||
const q = String(query ?? "").trim();
|
||||
if (!q) {
|
||||
throw new Error("node required");
|
||||
}
|
||||
|
||||
let nodes: NodeListNode[] = [];
|
||||
try {
|
||||
const res = await callGatewayCli("node.list", opts, {});
|
||||
@@ -97,6 +92,5 @@ export async function resolveNode(opts: NodesRpcOpts, query: string): Promise<No
|
||||
remoteIp: n.remoteIp,
|
||||
}));
|
||||
}
|
||||
const nodeId = resolveNodeIdFromCandidates(nodes, q);
|
||||
return nodes.find((node) => node.nodeId === nodeId) ?? { nodeId };
|
||||
return resolveNodeFromNodeList(nodes, query);
|
||||
}
|
||||
|
||||
33
src/shared/node-resolve.ts
Normal file
33
src/shared/node-resolve.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type NodeMatchCandidate, resolveNodeIdFromCandidates } from "./node-match.js";
|
||||
|
||||
type ResolveNodeFromListOptions<TNode extends NodeMatchCandidate> = {
|
||||
allowDefault?: boolean;
|
||||
pickDefaultNode?: (nodes: TNode[]) => TNode | null;
|
||||
};
|
||||
|
||||
export function resolveNodeIdFromNodeList<TNode extends NodeMatchCandidate>(
|
||||
nodes: TNode[],
|
||||
query?: string,
|
||||
options: ResolveNodeFromListOptions<TNode> = {},
|
||||
): string {
|
||||
const q = String(query ?? "").trim();
|
||||
if (!q) {
|
||||
if (options.allowDefault === true && options.pickDefaultNode) {
|
||||
const picked = options.pickDefaultNode(nodes);
|
||||
if (picked) {
|
||||
return picked.nodeId;
|
||||
}
|
||||
}
|
||||
throw new Error("node required");
|
||||
}
|
||||
return resolveNodeIdFromCandidates(nodes, q);
|
||||
}
|
||||
|
||||
export function resolveNodeFromNodeList<TNode extends NodeMatchCandidate>(
|
||||
nodes: TNode[],
|
||||
query?: string,
|
||||
options: ResolveNodeFromListOptions<TNode> = {},
|
||||
): TNode {
|
||||
const nodeId = resolveNodeIdFromNodeList(nodes, query, options);
|
||||
return nodes.find((node) => node.nodeId === nodeId) ?? ({ nodeId } as TNode);
|
||||
}
|
||||
21
src/test-utils/camera-url-test-helpers.ts
Normal file
21
src/test-utils/camera-url-test-helpers.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function stubFetchResponse(response: Response) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response),
|
||||
);
|
||||
}
|
||||
|
||||
export function stubFetchTextResponse(text: string, init?: ResponseInit) {
|
||||
stubFetchResponse(new Response(text, { status: 200, ...init }));
|
||||
}
|
||||
|
||||
export async function readFileUtf8AndCleanup(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} finally {
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user