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));
|
const parsed = parseClaudeJson(JSON.stringify(sample));
|
||||||
expect(parsed?.text).toBe("hello from result field");
|
expect(parsed?.text).toBe("hello from result field");
|
||||||
expect(parsed?.parsed).toMatchObject({ duration_ms: 1234 });
|
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.
|
// Helpers specific to Claude CLI output/argv handling.
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
// Preferred binary name for Claude CLI invocations.
|
// Preferred binary name for Claude CLI invocations.
|
||||||
export const CLAUDE_BIN = "claude";
|
export const CLAUDE_BIN = "claude";
|
||||||
@@ -49,8 +50,42 @@ function extractClaudeText(payload: unknown): string | undefined {
|
|||||||
export type ClaudeJsonParseResult = {
|
export type ClaudeJsonParseResult = {
|
||||||
text?: string;
|
text?: string;
|
||||||
parsed: unknown;
|
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(
|
export function parseClaudeJson(
|
||||||
raw: string,
|
raw: string,
|
||||||
): ClaudeJsonParseResult | undefined {
|
): ClaudeJsonParseResult | undefined {
|
||||||
@@ -67,14 +102,52 @@ export function parseClaudeJson(
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(candidate);
|
const parsed = JSON.parse(candidate);
|
||||||
if (firstParsed === undefined) firstParsed = parsed;
|
if (firstParsed === undefined) firstParsed = parsed;
|
||||||
const text = extractClaudeText(parsed);
|
let validation;
|
||||||
if (text) return { parsed, text };
|
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 {
|
} catch {
|
||||||
// ignore parse errors; try next candidate
|
// ignore parse errors; try next candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (firstParsed !== undefined) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,17 +49,18 @@ export function buildProgram() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a WhatsApp message")
|
.description("Send a WhatsApp message")
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
"-t, --to <number>",
|
"-t, --to <number>",
|
||||||
"Recipient number in E.164 (e.g. +15551234567)",
|
"Recipient number in E.164 (e.g. +15551234567)",
|
||||||
)
|
)
|
||||||
.requiredOption("-m, --message <text>", "Message body")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
|
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
|
||||||
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
|
||||||
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
.option("--provider <provider>", "Provider: twilio | web", "twilio")
|
||||||
.option("--dry-run", "Print payload and skip sending", false)
|
.option("--dry-run", "Print payload and skip sending", false)
|
||||||
|
.option("--json", "Output result as JSON", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function sendCommand(
|
|||||||
wait: string;
|
wait: string;
|
||||||
poll: string;
|
poll: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
},
|
},
|
||||||
deps: CliDeps,
|
deps: CliDeps,
|
||||||
@@ -36,7 +37,18 @@ export async function sendCommand(
|
|||||||
if (waitSeconds !== 0) {
|
if (waitSeconds !== 0) {
|
||||||
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +60,15 @@ export async function sendCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await deps.sendMessage(opts.to, opts.message, runtime);
|
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 (!result) return;
|
||||||
if (waitSeconds === 0) return;
|
if (waitSeconds === 0) return;
|
||||||
await deps.waitForFinalStatus(
|
await deps.waitForFinalStatus(
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createMockTwilio } from "../test/mocks/twilio.js";
|
import { createMockTwilio } from "../test/mocks/twilio.js";
|
||||||
import { statusCommand } from "./commands/status.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";
|
import * as providerWeb from "./provider-web.js";
|
||||||
|
|
||||||
vi.mock("twilio", () => {
|
vi.mock("twilio", () => {
|
||||||
@@ -82,6 +83,28 @@ describe("CLI commands", () => {
|
|||||||
expect(wait).not.toHaveBeenCalled();
|
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 () => {
|
it("login alias calls web login", async () => {
|
||||||
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
|
||||||
await index.program.parseAsync(["login"], { from: "user" });
|
await index.program.parseAsync(["login"], { from: "user" });
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ describe("provider-web", () => {
|
|||||||
expect(makeWASocket).toHaveBeenCalledWith(
|
expect(makeWASocket).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ printQRInTerminal: false }),
|
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 sock = getLastSocket();
|
||||||
const saveCreds = (
|
const saveCreds = (
|
||||||
await baileys.useMultiFileAuthState.mock.results[0].value
|
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");
|
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
||||||
|
|
||||||
export async function createWaSocket(printQr: boolean, verbose: boolean) {
|
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);
|
await ensureDir(WA_WEB_AUTH_DIR);
|
||||||
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
|
||||||
const { version } = await fetchLatestBaileysVersion();
|
const { version } = await fetchLatestBaileysVersion();
|
||||||
const logger = pino({ level: verbose ? "info" : "silent" });
|
|
||||||
const sock = makeWASocket({
|
const sock = makeWASocket({
|
||||||
auth: {
|
auth: {
|
||||||
creds: state.creds,
|
creds: state.creds,
|
||||||
@@ -97,7 +108,7 @@ export async function sendMessageWeb(
|
|||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
options: { verbose: boolean },
|
options: { verbose: boolean },
|
||||||
) {
|
): Promise<{ messageId: string; toJid: string }> {
|
||||||
const sock = await createWaSocket(false, options.verbose);
|
const sock = await createWaSocket(false, options.verbose);
|
||||||
try {
|
try {
|
||||||
await waitForWaConnection(sock);
|
await waitForWaConnection(sock);
|
||||||
@@ -109,9 +120,8 @@ export async function sendMessageWeb(
|
|||||||
}
|
}
|
||||||
const result = await sock.sendMessage(jid, { text: body });
|
const result = await sock.sendMessage(jid, { text: body });
|
||||||
const messageId = result?.key?.id ?? "unknown";
|
const messageId = result?.key?.id ?? "unknown";
|
||||||
console.log(
|
logInfo(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`);
|
||||||
success(`✅ Sent via web session. Message ID: ${messageId} -> ${jid}`),
|
return { messageId, toJid: jid };
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
sock.ws?.close();
|
sock.ws?.close();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { success } from "../globals.js";
|
import { success } from "../globals.js";
|
||||||
|
import { logInfo } from "../logger.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { withWhatsAppPrefix, sleep } from "../utils.js";
|
import { withWhatsAppPrefix, sleep } from "../utils.js";
|
||||||
import { readEnv } from "../env.js";
|
import { readEnv } from "../env.js";
|
||||||
@@ -26,11 +27,7 @@ export async function sendMessage(
|
|||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
logInfo(`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`, runtime);
|
||||||
success(
|
|
||||||
`✅ Request accepted. Message SID: ${message.sid} -> ${toNumber}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return { client, sid: message.sid };
|
return { client, sid: message.sid };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logTwilioSendError(err, toNumber, runtime);
|
logTwilioSendError(err, toNumber, runtime);
|
||||||
@@ -50,7 +47,7 @@ export async function waitForFinalStatus(
|
|||||||
const m = await client.messages(sid).fetch();
|
const m = await client.messages(sid).fetch();
|
||||||
const status = m.status ?? "unknown";
|
const status = m.status ?? "unknown";
|
||||||
if (successTerminalStatuses.has(status)) {
|
if (successTerminalStatuses.has(status)) {
|
||||||
console.log(success(`✅ Delivered (status: ${status})`));
|
logInfo(`✅ Delivered (status: ${status})`, runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (failureTerminalStatuses.has(status)) {
|
if (failureTerminalStatuses.has(status)) {
|
||||||
@@ -61,7 +58,8 @@ export async function waitForFinalStatus(
|
|||||||
}
|
}
|
||||||
await sleep(pollSeconds * 1000);
|
await sleep(pollSeconds * 1000);
|
||||||
}
|
}
|
||||||
console.log(
|
logInfo(
|
||||||
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
"ℹ️ Timed out waiting for final status; message may still be in flight.",
|
||||||
|
runtime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user