agent: deliver via rpc and voice forward

This commit is contained in:
Peter Steinberger
2025-12-07 06:05:00 +01:00
parent 1d38f5a4d5
commit 67fa82cf14
11 changed files with 105 additions and 45 deletions

View File

@@ -23,6 +23,7 @@ import {
} from "../config/sessions.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
import { sendViaIpc } from "../web/ipc.js";
type AgentCommandOpts = {
@@ -162,13 +163,13 @@ export async function agentCommand(
if (!opts.to && !opts.sessionId) {
throw new Error("Pass --to <E.164> or --session-id to choose a session");
}
if (opts.deliver && !opts.to) {
throw new Error("Delivering to WhatsApp requires --to <E.164>");
}
const cfg = loadConfig();
const replyCfg = assertCommandConfig(cfg);
const sessionCfg = replyCfg.session;
const allowFrom = (cfg.inbound?.allowFrom ?? [])
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
const thinkOverride = normalizeThinkLevel(opts.thinking);
if (opts.thinking && !thinkOverride) {
@@ -340,6 +341,12 @@ export async function agentCommand(
}
const deliver = opts.deliver === true;
const targetTo = opts.to ? normalizeE164(opts.to) : allowFrom[0];
if (deliver && !targetTo) {
throw new Error(
"Delivering to WhatsApp requires --to <E.164> or inbound.allowFrom[0]",
);
}
for (const payload of payloads) {
const lines: string[] = [];
@@ -351,29 +358,29 @@ export async function agentCommand(
}
runtime.log(lines.join("\n"));
if (deliver && opts.to) {
if (deliver && targetTo) {
const text = payload.text ?? "";
const media = mediaList;
// Prefer IPC to reuse the running relay; fall back to direct web send.
let sentViaIpc = false;
const ipcResult = await sendViaIpc(opts.to, text, media[0]);
const ipcResult = await sendViaIpc(targetTo, text, media[0]);
if (ipcResult) {
sentViaIpc = ipcResult.success;
if (ipcResult.success && media.length > 1) {
for (const extra of media.slice(1)) {
await sendViaIpc(opts.to, "", extra);
await sendViaIpc(targetTo, "", extra);
}
}
}
if (!sentViaIpc) {
if (text || media.length === 0) {
await deps.sendMessageWeb(opts.to, text, {
await deps.sendMessageWeb(targetTo, text, {
verbose: false,
mediaUrl: media[0],
});
}
for (const extra of media.slice(1)) {
await deps.sendMessageWeb(opts.to, "", {
await deps.sendMessageWeb(targetTo, "", {
verbose: false,
mediaUrl: extra,
});

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { healthCommand } from "./health.js";
@@ -23,7 +23,10 @@ const waitForWaConnection = vi.fn();
const webAuthExists = vi.fn();
vi.mock("../web/session.js", () => ({
createWaSocket: vi.fn(async () => ({ ws: { close: vi.fn() }, ev: { on: vi.fn() } })),
createWaSocket: vi.fn(async () => ({
ws: { close: vi.fn() },
ev: { on: vi.fn() },
})),
waitForWaConnection: (...args: unknown[]) => waitForWaConnection(...args),
webAuthExists: (...args: unknown[]) => webAuthExists(...args),
getStatusCode: vi.fn(() => 440),

View File

@@ -2,10 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
} from "../config/sessions.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
@@ -37,7 +34,11 @@ type HealthSummary = {
sessions: {
path: string;
count: number;
recent: Array<{ key: string; updatedAt: number | null; age: number | null }>;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
ipc: { path: string; exists: boolean };
};
@@ -54,7 +55,12 @@ async function probeWebConnect(timeoutMs: number): Promise<HealthConnect> {
setTimeout(() => reject(new Error("timeout")), timeoutMs),
),
]);
return { ok: true, status: null, error: null, elapsedMs: Date.now() - started };
return {
ok: true,
status: null,
error: null,
elapsedMs: Date.now() - started,
};
} catch (err) {
return {
ok: false,
@@ -126,18 +132,25 @@ export async function healthCommand(
}
if (connect) {
const base = connect.ok
? info(`Connect: ok (${connect.elapsedMs}ms)`) : `Connect: failed (${connect.status ?? "unknown"})`;
? info(`Connect: ok (${connect.elapsedMs}ms)`)
: `Connect: failed (${connect.status ?? "unknown"})`;
runtime.log(base + (connect.error ? ` - ${connect.error}` : ""));
}
runtime.log(info(`Heartbeat interval: ${heartbeatSeconds}s`));
runtime.log(info(`Session store: ${storePath} (${sessions.length} entries)`));
runtime.log(
info(`Session store: ${storePath} (${sessions.length} entries)`),
);
if (recent.length > 0) {
runtime.log("Recent sessions:");
for (const r of recent) {
runtime.log(`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`);
runtime.log(
`- ${r.key} (${r.updatedAt ? `${Math.round((Date.now() - r.updatedAt) / 60000)}m ago` : "no activity"})`,
);
}
}
runtime.log(info(`IPC socket: ${ipcExists ? "present" : "missing"} (${ipcPath})`));
runtime.log(
info(`IPC socket: ${ipcExists ? "present" : "missing"} (${ipcPath})`),
);
}
if (fatal) {