feat: add send --json, logger cleanup, and resilient Claude parsing
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user