From 37dbfd1ae8552ecc1208341eeb0bd737498cdd13 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Tue, 30 Jun 2026 16:23:29 +0100 Subject: [PATCH] fix(whatsapp): recover inbound media download via reuploadRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit downloadInboundMedia called downloadMediaMessage without the reuploadRequest recovery context, so a "Failed to fetch stream" from the direct CDN fetch (common around reconnects / on expired media URLs) was unrecoverable. The catch block then silently dropped the attachment — and uncaptioned media messages entirely (the empty-message guard skips them). Pass { reuploadRequest: sock.updateMediaMessage, logger: baileysLogger } so Baileys can ask WhatsApp to re-upload the media when the direct fetch fails, and surface a "[ could not be downloaded]" note when a download genuinely fails so the agent knows media was sent instead of dropping it silently. Fixes #2894 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KdUxK4bqbgGzFEeZhjC181 --- src/channels/whatsapp.test.ts | 32 +++++++++++++++++++++++++++- src/channels/whatsapp.ts | 39 +++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts index eddaa3b75..95a17245d 100644 --- a/src/channels/whatsapp.test.ts +++ b/src/channels/whatsapp.test.ts @@ -16,7 +16,12 @@ */ import { describe, it, expect } from 'vitest'; -import { computeIsMention, isBotMentionedInGroup, parseWhatsAppMentions } from './whatsapp.js'; +import { + appendMediaFailureNote, + computeIsMention, + isBotMentionedInGroup, + parseWhatsAppMentions, +} from './whatsapp.js'; const BOT_PHONE_JID = '15550009999@s.whatsapp.net'; const BOT_LID_USER = '987654321'; @@ -160,3 +165,28 @@ describe('parseWhatsAppMentions', () => { expect(mentions).toEqual(['15551234567@s.whatsapp.net']); }); }); + +describe('appendMediaFailureNote', () => { + it('returns content unchanged when nothing failed', () => { + expect(appendMediaFailureNote('hello', [])).toBe('hello'); + }); + + it('appends the note on its own line when a captioned message has a failed download', () => { + expect(appendMediaFailureNote('check this out', ['image'])).toBe( + 'check this out\n[image could not be downloaded]', + ); + }); + + it('uses the note as the content when an uncaptioned media message fails (would otherwise be dropped)', () => { + // Regression guard: an uncaptioned image whose download fails must still + // produce a non-empty message, or the empty-message guard skips it and the + // agent never learns media was sent. + expect(appendMediaFailureNote('', ['image'])).toBe('[image could not be downloaded]'); + }); + + it('lists each failed media type when several fail together', () => { + expect(appendMediaFailureNote('', ['image', 'document'])).toBe( + '[image could not be downloaded] [document could not be downloaded]', + ); + }); +}); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index cac5a9f16..a02639c1d 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -246,6 +246,18 @@ export function computeIsMention(isGroup: boolean, botMentionedInGroup: boolean) return botMentionedInGroup ? true : undefined; } +/** + * Append a visible note for media that failed to download, so the agent knows + * something was sent rather than silently losing the attachment — or the whole + * message, when an uncaptioned image would otherwise be dropped by the + * empty-message guard. Returns `content` unchanged when nothing failed. + */ +export function appendMediaFailureNote(content: string, failures: string[]): string { + if (failures.length === 0) return content; + const note = failures.map((t) => `[${t} could not be downloaded]`).join(' '); + return content ? `${content}\n${note}` : note; +} + /** Map file extension to Baileys media message type. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildMediaMessage(data: Buffer, filename: string, ext: string, caption?: string): any { @@ -428,7 +440,10 @@ registerChannelAdapter('whatsapp', { async function downloadInboundMedia( msg: WAMessage, normalized: any, - ): Promise> { + ): Promise<{ + attachments: Array<{ type: string; name: string; localPath: string }>; + failures: string[]; + }> { const mediaTypes: Array<{ key: string; type: string; ext: string }> = [ { key: 'imageMessage', type: 'image', ext: '.jpg' }, { key: 'videoMessage', type: 'video', ext: '.mp4' }, @@ -436,10 +451,20 @@ registerChannelAdapter('whatsapp', { { key: 'documentMessage', type: 'document', ext: '' }, ]; const results: Array<{ type: string; name: string; localPath: string }> = []; + const failures: string[] = []; for (const { key, type, ext } of mediaTypes) { if (!normalized[key]) continue; try { - const buffer = await downloadMediaMessage(msg, 'buffer', {}); + // Pass reuploadRequest so Baileys can ask WhatsApp to re-upload the + // media when the direct CDN fetch fails or the media URL has expired + // (common around reconnects). Without it, a "Failed to fetch stream" + // is unrecoverable and the attachment is silently lost. + const buffer = await downloadMediaMessage( + msg, + 'buffer', + {}, + { reuploadRequest: sock.updateMediaMessage, logger: baileysLogger }, + ); // documentMessage.fileName is attacker-controlled and rides through // WhatsApp's E2E channel — Meta can't sanitize it server-side. Without // this guard, a `..`-laden fileName escapes attachDir on path.join. @@ -460,9 +485,10 @@ registerChannelAdapter('whatsapp', { log.info('Media downloaded', { type, filename }); } catch (err) { log.warn('Failed to download media', { type, err }); + failures.push(type); } } - return results; + return { attachments: results, failures }; } async function sendRawMessage(jid: string, text: string, mentions?: string[]): Promise { @@ -699,7 +725,12 @@ registerChannelAdapter('whatsapp', { } // Download media attachments (images, video, audio, documents) - const attachments = await downloadInboundMedia(msg, normalized); + const { attachments, failures } = await downloadInboundMedia(msg, normalized); + + // Surface failed downloads as text so the agent knows media was + // sent even when it couldn't be fetched — instead of silently + // dropping the attachment (or the whole message, if uncaptioned). + content = appendMediaFailureNote(content, failures); // Skip empty protocol messages (no text and no attachments) if (!content && attachments.length === 0) continue;