feat: add send --json, logger cleanup, and resilient Claude parsing

This commit is contained in:
Peter Steinberger
2025-11-25 04:08:42 +01:00
parent d7cc94333a
commit d1923e6efe
8 changed files with 165 additions and 28 deletions

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -49,17 +49,18 @@ export function buildProgram() {
});
program
.command("send")
.description("Send a WhatsApp message")
.requiredOption(
"-t, --to <number>",
"Recipient number in E.164 (e.g. +15551234567)",
)
.requiredOption("-m, --message <text>", "Message body")
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.command("send")
.description("Send a WhatsApp message")
.requiredOption(
"-t, --to <number>",
"Recipient number in E.164 (e.g. +15551234567)",
)
.requiredOption("-m, --message <text>", "Message body")
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.option("--json", "Output result as JSON", false)
.addHelpText(
"after",
`

View File

@@ -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(

View File

@@ -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" });

View File

@@ -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

View File

@@ -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<pino.Logger>) 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();

View File

@@ -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,
);
}