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