import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; import { sleep } from "openclaw/plugin-sdk"; import type { VoiceCallConfig } from "./config.js"; import type { VoiceCallRuntime } from "./runtime.js"; import { resolveUserPath } from "./utils.js"; import { cleanupTailscaleExposureRoute, getTailscaleSelfInfo, setupTailscaleExposureRoute, } from "./webhook.js"; type Logger = { info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; }; function resolveMode(input: string): "off" | "serve" | "funnel" { const raw = input.trim().toLowerCase(); if (raw === "serve" || raw === "off") { return raw; } return "funnel"; } function resolveDefaultStorePath(config: VoiceCallConfig): string { const preferred = path.join(os.homedir(), ".openclaw", "voice-calls"); const resolvedPreferred = resolveUserPath(preferred); const existing = [resolvedPreferred].find((dir) => { try { return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir); } catch { return false; } }) ?? resolvedPreferred; const base = config.store?.trim() ? resolveUserPath(config.store) : existing; return path.join(base, "calls.jsonl"); } function percentile(values: number[], p: number): number { if (values.length === 0) { return 0; } const sorted = [...values].sort((a, b) => a - b); const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); return sorted[idx] ?? 0; } function summarizeSeries(values: number[]): { count: number; minMs: number; maxMs: number; avgMs: number; p50Ms: number; p95Ms: number; } { if (values.length === 0) { return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 }; } const minMs = values.reduce( (min, value) => (value < min ? value : min), Number.POSITIVE_INFINITY, ); const maxMs = values.reduce( (max, value) => (value > max ? value : max), Number.NEGATIVE_INFINITY, ); const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length; return { count: values.length, minMs, maxMs, avgMs, p50Ms: percentile(values, 50), p95Ms: percentile(values, 95), }; } export function registerVoiceCallCli(params: { program: Command; config: VoiceCallConfig; ensureRuntime: () => Promise; logger: Logger; }) { const { program, config, ensureRuntime, logger } = params; const root = program .command("voicecall") .description("Voice call utilities") .addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`); root .command("call") .description("Initiate an outbound voice call") .requiredOption("-m, --message ", "Message to speak when call connects") .option( "-t, --to ", "Phone number to call (E.164 format, uses config toNumber if not set)", ) .option( "--mode ", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation", ) .action(async (options: { message: string; to?: string; mode?: string }) => { const rt = await ensureRuntime(); const to = options.to ?? rt.config.toNumber; if (!to) { throw new Error("Missing --to and no toNumber configured"); } const result = await rt.manager.initiateCall(to, undefined, { message: options.message, mode: options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, }); if (!result.success) { throw new Error(result.error || "initiate failed"); } // eslint-disable-next-line no-console console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root .command("start") .description("Alias for voicecall call") .requiredOption("--to ", "Phone number to call") .option("--message ", "Message to speak when call connects") .option( "--mode ", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation", ) .action(async (options: { to: string; message?: string; mode?: string }) => { const rt = await ensureRuntime(); const result = await rt.manager.initiateCall(options.to, undefined, { message: options.message, mode: options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined, }); if (!result.success) { throw new Error(result.error || "initiate failed"); } // eslint-disable-next-line no-console console.log(JSON.stringify({ callId: result.callId }, null, 2)); }); root .command("continue") .description("Speak a message and wait for a response") .requiredOption("--call-id ", "Call ID") .requiredOption("--message ", "Message to speak") .action(async (options: { callId: string; message: string }) => { const rt = await ensureRuntime(); const result = await rt.manager.continueCall(options.callId, options.message); if (!result.success) { throw new Error(result.error || "continue failed"); } // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); }); root .command("speak") .description("Speak a message without waiting for response") .requiredOption("--call-id ", "Call ID") .requiredOption("--message ", "Message to speak") .action(async (options: { callId: string; message: string }) => { const rt = await ensureRuntime(); const result = await rt.manager.speak(options.callId, options.message); if (!result.success) { throw new Error(result.error || "speak failed"); } // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); }); root .command("end") .description("Hang up an active call") .requiredOption("--call-id ", "Call ID") .action(async (options: { callId: string }) => { const rt = await ensureRuntime(); const result = await rt.manager.endCall(options.callId); if (!result.success) { throw new Error(result.error || "end failed"); } // eslint-disable-next-line no-console console.log(JSON.stringify(result, null, 2)); }); root .command("status") .description("Show call status") .requiredOption("--call-id ", "Call ID") .action(async (options: { callId: string }) => { const rt = await ensureRuntime(); const call = rt.manager.getCall(options.callId); // eslint-disable-next-line no-console console.log(JSON.stringify(call ?? { found: false }, null, 2)); }); root .command("tail") .description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)") .option("--file ", "Path to calls.jsonl", resolveDefaultStorePath(config)) .option("--since ", "Print last N lines first", "25") .option("--poll ", "Poll interval in ms", "250") .action(async (options: { file: string; since?: string; poll?: string }) => { const file = options.file; const since = Math.max(0, Number(options.since ?? 0)); const pollMs = Math.max(50, Number(options.poll ?? 250)); if (!fs.existsSync(file)) { logger.error(`No log file at ${file}`); process.exit(1); } const initial = fs.readFileSync(file, "utf8"); const lines = initial.split("\n").filter(Boolean); for (const line of lines.slice(Math.max(0, lines.length - since))) { // eslint-disable-next-line no-console console.log(line); } let offset = Buffer.byteLength(initial, "utf8"); for (;;) { try { const stat = fs.statSync(file); if (stat.size < offset) { offset = 0; } if (stat.size > offset) { const fd = fs.openSync(file, "r"); try { const buf = Buffer.alloc(stat.size - offset); fs.readSync(fd, buf, 0, buf.length, offset); offset = stat.size; const text = buf.toString("utf8"); for (const line of text.split("\n").filter(Boolean)) { // eslint-disable-next-line no-console console.log(line); } } finally { fs.closeSync(fd); } } } catch { // ignore and retry } await sleep(pollMs); } }); root .command("latency") .description("Summarize turn latency metrics from voice-call JSONL logs") .option("--file ", "Path to calls.jsonl", resolveDefaultStorePath(config)) .option("--last ", "Analyze last N records", "200") .action(async (options: { file: string; last?: string }) => { const file = options.file; const last = Math.max(1, Number(options.last ?? 200)); if (!fs.existsSync(file)) { throw new Error("No log file at " + file); } const content = fs.readFileSync(file, "utf8"); const lines = content.split("\n").filter(Boolean).slice(-last); const turnLatencyMs: number[] = []; const listenWaitMs: number[] = []; for (const line of lines) { try { const parsed = JSON.parse(line) as { metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown }; }; const latency = parsed.metadata?.lastTurnLatencyMs; const listenWait = parsed.metadata?.lastTurnListenWaitMs; if (typeof latency === "number" && Number.isFinite(latency)) { turnLatencyMs.push(latency); } if (typeof listenWait === "number" && Number.isFinite(listenWait)) { listenWaitMs.push(listenWait); } } catch { // ignore malformed JSON lines } } // eslint-disable-next-line no-console console.log( JSON.stringify( { recordsScanned: lines.length, turnLatency: summarizeSeries(turnLatencyMs), listenWait: summarizeSeries(listenWaitMs), }, null, 2, ), ); }); root .command("expose") .description("Enable/disable Tailscale serve/funnel for the webhook") .option("--mode ", "off | serve (tailnet) | funnel (public)", "funnel") .option("--path ", "Tailscale path to expose (recommend matching serve.path)") .option("--port ", "Local webhook port") .option("--serve-path ", "Local webhook path") .action( async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => { const mode = resolveMode(options.mode ?? "funnel"); const servePort = Number(options.port ?? config.serve.port ?? 3334); const servePath = String(options.servePath ?? config.serve.path ?? "/voice/webhook"); const tsPath = String(options.path ?? config.tailscale?.path ?? servePath); const localUrl = `http://127.0.0.1:${servePort}`; if (mode === "off") { await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath }); await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath }); // eslint-disable-next-line no-console console.log(JSON.stringify({ ok: true, mode: "off", path: tsPath }, null, 2)); return; } const publicUrl = await setupTailscaleExposureRoute({ mode, path: tsPath, localUrl, }); const tsInfo = publicUrl ? null : await getTailscaleSelfInfo(); const enableUrl = tsInfo?.nodeId ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}` : null; // eslint-disable-next-line no-console console.log( JSON.stringify( { ok: Boolean(publicUrl), mode, path: tsPath, localUrl, publicUrl, hint: publicUrl ? undefined : { note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).", enableUrl, }, }, null, 2, ), ); }, ); }