diff --git a/src/auto-reply/claude.test.ts b/src/auto-reply/claude.test.ts index 8ac25aa3a..4d7b582f5 100644 --- a/src/auto-reply/claude.test.ts +++ b/src/auto-reply/claude.test.ts @@ -28,5 +28,12 @@ describe("claude JSON parsing", () => { const parsed = parseClaudeJson(JSON.stringify(sample)); expect(parsed?.text).toBe("hello from result field"); expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 }); + expect(parsed?.valid).toBe(true); + }); + + it("marks invalid Claude JSON as invalid but still attempts text extraction", () => { + const parsed = parseClaudeJson('{"unexpected":1}'); + expect(parsed?.valid).toBe(false); + expect(parsed?.text).toBeUndefined(); }); }); diff --git a/src/auto-reply/claude.ts b/src/auto-reply/claude.ts index 7f57ae0c3..f4c9ce13a 100644 --- a/src/auto-reply/claude.ts +++ b/src/auto-reply/claude.ts @@ -1,4 +1,5 @@ // Helpers specific to Claude CLI output/argv handling. +import { z } from "zod"; // Preferred binary name for Claude CLI invocations. export const CLAUDE_BIN = "claude"; @@ -49,8 +50,42 @@ function extractClaudeText(payload: unknown): string | undefined { export type ClaudeJsonParseResult = { text?: string; parsed: unknown; + valid: boolean; }; +const ClaudeJsonSchema = z + .object({ + type: z.string().optional(), + subtype: z.string().optional(), + is_error: z.boolean().optional(), + result: z.string().optional(), + text: z.string().optional(), + completion: z.string().optional(), + output: z.string().optional(), + message: z.any().optional(), + messages: z.any().optional(), + content: z.any().optional(), + duration_ms: z.number().optional(), + duration_api_ms: z.number().optional(), + num_turns: z.number().optional(), + session_id: z.string().optional(), + total_cost_usd: z.number().optional(), + usage: z.record(z.any()).optional(), + modelUsage: z.record(z.any()).optional(), + }) + .passthrough() + .refine( + (obj) => + typeof obj.result === "string" || + typeof obj.text === "string" || + typeof obj.completion === "string" || + typeof obj.output === "string" || + obj.message !== undefined || + obj.messages !== undefined || + obj.content !== undefined, + { message: "Not a Claude JSON payload" }, + ); + export function parseClaudeJson( raw: string, ): ClaudeJsonParseResult | undefined { @@ -67,14 +102,52 @@ export function parseClaudeJson( try { const parsed = JSON.parse(candidate); if (firstParsed === undefined) firstParsed = parsed; - const text = extractClaudeText(parsed); - if (text) return { parsed, text }; + let validation; + try { + validation = ClaudeJsonSchema.safeParse(parsed); + } catch { + validation = { success: false } as const; + } + const validated = validation.success ? validation.data : parsed; + const isLikelyClaude = + typeof validated === "object" && + validated !== null && + ("result" in validated || + "text" in validated || + "completion" in validated || + "output" in validated); + const text = extractClaudeText(validated); + if (text) + return { + parsed: validated, + text, + // Treat parse as valid when schema passes or we still see Claude-like shape. + valid: Boolean(validation?.success || isLikelyClaude), + }; } catch { // ignore parse errors; try next candidate } } if (firstParsed !== undefined) { - return { parsed: firstParsed, text: extractClaudeText(firstParsed) }; + let validation; + try { + validation = ClaudeJsonSchema.safeParse(firstParsed); + } catch { + validation = { success: false } as const; + } + const validated = validation.success ? validation.data : firstParsed; + const isLikelyClaude = + typeof validated === "object" && + validated !== null && + ("result" in validated || + "text" in validated || + "completion" in validated || + "output" in validated); + return { + parsed: validated, + text: extractClaudeText(validated), + valid: Boolean(validation?.success || isLikelyClaude), + }; } return undefined; } diff --git a/src/cli/program.ts b/src/cli/program.ts index 54575f63a..1cd00de21 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -49,17 +49,18 @@ export function buildProgram() { }); program - .command("send") - .description("Send a WhatsApp message") - .requiredOption( - "-t, --to ", - "Recipient number in E.164 (e.g. +15551234567)", - ) - .requiredOption("-m, --message ", "Message body") - .option("-w, --wait ", "Wait for delivery status (0 to skip)", "20") - .option("-p, --poll ", "Polling interval while waiting", "2") - .option("--provider ", "Provider: twilio | web", "twilio") - .option("--dry-run", "Print payload and skip sending", false) + .command("send") + .description("Send a WhatsApp message") + .requiredOption( + "-t, --to ", + "Recipient number in E.164 (e.g. +15551234567)", + ) + .requiredOption("-m, --message ", "Message body") + .option("-w, --wait ", "Wait for delivery status (0 to skip)", "20") + .option("-p, --poll ", "Polling interval while waiting", "2") + .option("--provider ", "Provider: twilio | web", "twilio") + .option("--dry-run", "Print payload and skip sending", false) + .option("--json", "Output result as JSON", false) .addHelpText( "after", ` diff --git a/src/commands/send.ts b/src/commands/send.ts index 61e8b81a6..37959fdcb 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -10,6 +10,7 @@ export async function sendCommand( wait: string; poll: string; provider: Provider; + json?: boolean; dryRun?: boolean; }, deps: CliDeps, @@ -36,7 +37,18 @@ export async function sendCommand( if (waitSeconds !== 0) { runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web.")); } - await deps.sendMessageWeb(opts.to, opts.message, { verbose: false }); + const res = await deps.sendMessageWeb(opts.to, opts.message, { + verbose: false, + }); + if (opts.json) { + runtime.log( + JSON.stringify( + { provider: "web", to: opts.to, messageId: res.messageId }, + null, + 2, + ), + ); + } return; } @@ -48,6 +60,15 @@ export async function sendCommand( } const result = await deps.sendMessage(opts.to, opts.message, runtime); + if (opts.json) { + runtime.log( + JSON.stringify( + { provider: "twilio", to: opts.to, sid: result?.sid ?? null }, + null, + 2, + ), + ); + } if (!result) return; if (waitSeconds === 0) return; await deps.waitForFinalStatus( diff --git a/src/index.commands.test.ts b/src/index.commands.test.ts index e7b0e0468..5f5a70941 100644 --- a/src/index.commands.test.ts +++ b/src/index.commands.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockTwilio } from "../test/mocks/twilio.js"; import { statusCommand } from "./commands/status.js"; -import { createDefaultDeps, defaultRuntime } from "./index.js"; +import { createDefaultDeps } from "./index.js"; +import { defaultRuntime } from "./runtime.js"; import * as providerWeb from "./provider-web.js"; vi.mock("twilio", () => { @@ -82,6 +83,28 @@ describe("CLI commands", () => { expect(wait).not.toHaveBeenCalled(); }); + it("send command outputs JSON when requested", async () => { + const twilio = (await import("twilio")).default; + twilio._client.messages.create.mockResolvedValue({ sid: "SMJSON" }); + const logSpy = vi.spyOn(defaultRuntime, "log"); + await index.program.parseAsync( + [ + "send", + "--to", + "+1555", + "--message", + "hi", + "--wait", + "0", + "--json", + ], + { from: "user" }, + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("\"sid\": \"SMJSON\""), + ); + }); + it("login alias calls web login", async () => { const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue(); await index.program.parseAsync(["login"], { from: "user" }); diff --git a/src/provider-web.test.ts b/src/provider-web.test.ts index e4babd499..afb1c0d16 100644 --- a/src/provider-web.test.ts +++ b/src/provider-web.test.ts @@ -74,6 +74,10 @@ describe("provider-web", () => { expect(makeWASocket).toHaveBeenCalledWith( expect.objectContaining({ printQRInTerminal: false }), ); + const passed = makeWASocket.mock.calls[0][0]; + expect((passed as { logger?: { level?: string } }).logger?.level).toBe( + "silent", + ); const sock = getLastSocket(); const saveCreds = ( await baileys.useMultiFileAuthState.mock.results[0].value diff --git a/src/provider-web.ts b/src/provider-web.ts index 68053f9ab..d339dd82e 100644 --- a/src/provider-web.ts +++ b/src/provider-web.ts @@ -23,10 +23,21 @@ import { logInfo, logWarn } from "./logger.js"; const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials"); export async function createWaSocket(printQr: boolean, verbose: boolean) { + const logger = verbose + ? pino({ level: "info" }) + : ({ + level: "silent", + child: () => ({}) as pino.Logger, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + } satisfies Partial) as pino.Logger; await ensureDir(WA_WEB_AUTH_DIR); const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR); const { version } = await fetchLatestBaileysVersion(); - const logger = pino({ level: verbose ? "info" : "silent" }); const sock = makeWASocket({ auth: { creds: state.creds, @@ -97,7 +108,7 @@ export async function sendMessageWeb( to: string, body: string, options: { verbose: boolean }, -) { +): Promise<{ messageId: string; toJid: string }> { const sock = await createWaSocket(false, options.verbose); try { await waitForWaConnection(sock); @@ -109,9 +120,8 @@ export async function sendMessageWeb( } const result = await sock.sendMessage(jid, { text: body }); const messageId = result?.key?.id ?? "unknown"; - console.log( - success(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`), - ); + logInfo(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`); + return { messageId, toJid: jid }; } finally { try { sock.ws?.close(); diff --git a/src/twilio/send.ts b/src/twilio/send.ts index a40a6e374..3ff7df823 100644 --- a/src/twilio/send.ts +++ b/src/twilio/send.ts @@ -1,4 +1,5 @@ import { success } from "../globals.js"; +import { logInfo } from "../logger.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { withWhatsAppPrefix, sleep } from "../utils.js"; import { readEnv } from "../env.js"; @@ -26,11 +27,7 @@ export async function sendMessage( body, }); - console.log( - success( - `✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, - ), - ); + logInfo(`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, runtime); return { client, sid: message.sid }; } catch (err) { logTwilioSendError(err, toNumber, runtime); @@ -50,7 +47,7 @@ export async function waitForFinalStatus( const m = await client.messages(sid).fetch(); const status = m.status ?? "unknown"; if (successTerminalStatuses.has(status)) { - console.log(success(`✅ Delivered (status: ${status})`)); + logInfo(`✅ Delivered (status: ${status})`, runtime); return; } if (failureTerminalStatuses.has(status)) { @@ -61,7 +58,8 @@ export async function waitForFinalStatus( } await sleep(pollSeconds * 1000); } - console.log( + logInfo( "ℹ️ Timed out waiting for final status; message may still be in flight.", + runtime, ); }