


























@@ -31,6 +31,7 @@ vi.mock("../media.js", () => ({
3131}));
32323333let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
34+let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
34353536function makeMsg(): WebInboundMsg {
3637return {
@@ -69,6 +70,12 @@ function mockFirstReplyFailure(msg: WebInboundMsg, message: string) {
6970);
7071}
717273+function mockFirstReplyFailureWithWrappedError(msg: WebInboundMsg, message: string) {
74+(msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce({
75+error: { message },
76+});
77+}
78+7279function mockSecondReplySuccess(msg: WebInboundMsg) {
7380(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
7481undefined,
@@ -97,6 +104,7 @@ async function expectReplySuppressed(replyResult: { text: string; isReasoning?:
97104describe("deliverWebReply", () => {
98105beforeAll(async () => {
99106({ deliverWebReply } = await import("./deliver-reply.js"));
107+({ whatsappOutbound } = await import("../outbound-adapter.js"));
100108});
101109102110it("suppresses payloads flagged as reasoning", async () => {
@@ -217,6 +225,24 @@ describe("deliverWebReply", () => {
217225},
218226);
219227228+it("retries text send on wrapped transient failure", async () => {
229+const msg = makeMsg();
230+mockFirstReplyFailureWithWrappedError(msg, "connection closed");
231+mockSecondReplySuccess(msg);
232+233+await deliverWebReply({
234+replyResult: { text: "hi" },
235+ msg,
236+maxMediaBytes: 1024 * 1024,
237+textLimit: 200,
238+ replyLogger,
239+skipLog: true,
240+});
241+242+expect(msg.reply).toHaveBeenCalledTimes(2);
243+expect(sleep).toHaveBeenCalledWith(500);
244+});
245+220246it("sends image media with caption and then remaining text", async () => {
221247const msg = makeMsg();
222248const mediaLocalRoots = ["/tmp/workspace-work"];
@@ -250,6 +276,22 @@ describe("deliverWebReply", () => {
250276expect(logVerbose).toHaveBeenCalled();
251277});
252278279+it("preserves leading indentation after trimming only leading blank lines", async () => {
280+const msg = makeMsg();
281+282+await deliverWebReply({
283+replyResult: { text: "\n \n indented block" },
284+ msg,
285+maxMediaBytes: 1024 * 1024,
286+textLimit: 200,
287+ replyLogger,
288+skipLog: true,
289+});
290+291+expect(msg.reply).toHaveBeenCalledTimes(1);
292+expect(msg.reply).toHaveBeenCalledWith(" indented block", undefined);
293+});
294+253295it("keeps quote threading on media and trailing text chunks for a threaded reply", async () => {
254296const msg = makeMsg();
255297mockLoadedImageMedia();
@@ -343,12 +385,137 @@ describe("deliverWebReply", () => {
343385expect(
344386String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
345387).toContain("⚠️ Media failed");
388+expect(
389+String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
390+).not.toContain("boom");
346391expect(replyLogger.warn).toHaveBeenCalledWith(
347392expect.objectContaining({ mediaUrl: "http://example.com/img.jpg" }),
348393"failed to send web media reply",
349394);
350395});
351396397+it("still attempts later media after the first media fails", async () => {
398+vi.clearAllMocks();
399+const msg = makeMsg();
400+(
401+loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
402+).mockResolvedValueOnce({
403+buffer: Buffer.from("bad"),
404+contentType: "image/jpeg",
405+kind: "image",
406+});
407+(
408+loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
409+).mockResolvedValueOnce({
410+buffer: Buffer.from("good"),
411+contentType: "application/pdf",
412+kind: "file",
413+fileName: "good.pdf",
414+});
415+mockFirstSendMediaFailure(msg, "boom");
416+(
417+msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
418+).mockResolvedValueOnce(undefined);
419+420+await deliverWebReply({
421+replyResult: {
422+text: "caption",
423+mediaUrls: ["http://example.com/bad.jpg", "http://example.com/good.pdf"],
424+},
425+ msg,
426+maxMediaBytes: 1024 * 1024,
427+textLimit: 200,
428+ replyLogger,
429+skipLog: true,
430+});
431+432+expect(loadWebMedia).toHaveBeenNthCalledWith(1, "http://example.com/bad.jpg", {
433+maxBytes: 1024 * 1024,
434+localRoots: undefined,
435+});
436+expect(loadWebMedia).toHaveBeenNthCalledWith(2, "http://example.com/good.pdf", {
437+maxBytes: 1024 * 1024,
438+localRoots: undefined,
439+});
440+expect(msg.sendMedia).toHaveBeenCalledTimes(2);
441+expect(msg.sendMedia).toHaveBeenNthCalledWith(
442+2,
443+expect.objectContaining({
444+document: expect.any(Buffer),
445+fileName: "good.pdf",
446+caption: undefined,
447+mimetype: "application/pdf",
448+}),
449+undefined,
450+);
451+expect(msg.reply).toHaveBeenCalledTimes(1);
452+expect(
453+String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
454+).toContain("⚠️ Media failed");
455+expect(
456+String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
457+).not.toContain("boom");
458+});
459+460+it("keeps payload and auto-reply media normalization in parity", async () => {
461+const payload = {
462+text: "\n\ncaption",
463+mediaUrls: [" ", " /tmp/voice.ogg "],
464+};
465+const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
466+467+await whatsappOutbound.sendPayload!({
468+cfg: {},
469+to: "5511999999999@c.us",
470+text: "",
471+ payload,
472+deps: { sendWhatsApp },
473+});
474+475+const msg = makeMsg();
476+(
477+loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
478+).mockResolvedValueOnce({
479+buffer: Buffer.from("aud"),
480+contentType: "audio/ogg",
481+kind: "audio",
482+});
483+484+await deliverWebReply({
485+replyResult: payload,
486+ msg,
487+maxMediaBytes: 1024 * 1024,
488+textLimit: 200,
489+ replyLogger,
490+skipLog: true,
491+});
492+493+expect(sendWhatsApp).toHaveBeenCalledTimes(1);
494+expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "caption", {
495+verbose: false,
496+cfg: {},
497+mediaUrl: "/tmp/voice.ogg",
498+mediaLocalRoots: undefined,
499+accountId: undefined,
500+gifPlayback: undefined,
501+});
502+expect(loadWebMedia).toHaveBeenCalledWith("/tmp/voice.ogg", {
503+maxBytes: 1024 * 1024,
504+localRoots: undefined,
505+});
506+expect(msg.sendMedia).toHaveBeenCalledTimes(1);
507+expect(msg.sendMedia).toHaveBeenCalledWith(
508+expect.objectContaining({
509+audio: expect.any(Buffer),
510+ptt: true,
511+mimetype: "audio/ogg; codecs=opus",
512+caption: "caption",
513+}),
514+undefined,
515+);
516+expect(msg.reply).not.toHaveBeenCalled();
517+});
518+352519it("sends audio media as ptt voice note", async () => {
353520const msg = makeMsg();
354521(
@@ -372,7 +539,7 @@ describe("deliverWebReply", () => {
372539expect.objectContaining({
373540audio: expect.any(Buffer),
374541ptt: true,
375-mimetype: "audio/ogg",
542+mimetype: "audio/ogg; codecs=opus",
376543caption: "cap",
377544}),
378545undefined,
@@ -438,4 +605,37 @@ describe("deliverWebReply", () => {
438605undefined,
439606);
440607});
608+609+it("strips URL query and fragment data from derived document file names", async () => {
610+const msg = makeMsg();
611+(
612+loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
613+).mockResolvedValueOnce({
614+buffer: Buffer.from("pdf"),
615+contentType: "application/pdf",
616+kind: "file",
617+});
618+619+await deliverWebReply({
620+replyResult: {
621+text: "cap",
622+mediaUrl: "https://example.com/report.pdf?X-Amz-Signature=secret#frag",
623+},
624+ msg,
625+maxMediaBytes: 1024 * 1024,
626+textLimit: 200,
627+ replyLogger,
628+skipLog: true,
629+});
630+631+expect(msg.sendMedia).toHaveBeenCalledWith(
632+expect.objectContaining({
633+document: expect.any(Buffer),
634+fileName: "report.pdf",
635+caption: "cap",
636+mimetype: "application/pdf",
637+}),
638+undefined,
639+);
640+});
441641});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。