diff --git a/scripts/whatsapp-bridge/bridge.native.test.mjs b/scripts/whatsapp-bridge/bridge.native.test.mjs index f9afdf0d240..db0a8debead 100644 --- a/scripts/whatsapp-bridge/bridge.native.test.mjs +++ b/scripts/whatsapp-bridge/bridge.native.test.mjs @@ -16,6 +16,7 @@ import { buildPollPayload, buildTextSendPayload, createBoundedMessageStore, + appendMediaFailureNote, extractBridgeEvent, mediaPayloadForFile, pollCreationMessageFromPayload, @@ -324,4 +325,62 @@ import { console.log(' ✓ encrypted poll upserts are wrapped into Baileys aggregation shape'); } +// -- media download failure containment (port of nanoclaw#2895) ----------- +{ + assert.equal(appendMediaFailureNote('hello', []), 'hello'); + assert.equal( + appendMediaFailureNote('check this out', ['image']), + 'check this out\n[image could not be downloaded]', + ); + // Regression guard: an uncaptioned failed image must still produce a + // non-empty body, or the empty-message guard drops the whole message. + assert.equal(appendMediaFailureNote('', ['image']), '[image could not be downloaded]'); + assert.equal( + appendMediaFailureNote('', ['image', 'document']), + '[image could not be downloaded] [document could not be downloaded]', + ); + console.log(' ✓ appendMediaFailureNote formats failure notes'); +} + +{ + // A throwing downloadMedia (expired CDN URL) must not reject out of + // extractBridgeEvent — before this guard the whole upsert batch died and + // the message was silently dropped. + const event = await extractBridgeEvent({ + msg: { + key: { id: 'img-fail-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false }, + messageTimestamp: 123, + message: { imageMessage: { caption: '', mimetype: 'image/jpeg' } }, + }, + chatId: '15551234567@s.whatsapp.net', + senderId: '15551234567@s.whatsapp.net', + senderNumber: '15551234567', + downloadMedia: async () => { throw new Error('Failed to fetch stream from https://mmg.whatsapp.net/x'); }, + cacheDirs: { image: mkdtempSync(path.join(tmpdir(), 'wa-media-')) }, + }); + assert.equal(event.hasMedia, true); + assert.equal(event.mediaUrls.length, 0); + assert.equal(event.body, '[image could not be downloaded]'); + console.log(' ✓ failed media download is contained and surfaced in body'); +} + +{ + // Captioned message keeps the caption and appends the failure note. + const event = await extractBridgeEvent({ + msg: { + key: { id: 'doc-fail-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false }, + messageTimestamp: 123, + message: { documentMessage: { caption: 'see attached', fileName: 'q.pdf', mimetype: 'application/pdf' } }, + }, + chatId: '15551234567@s.whatsapp.net', + senderId: '15551234567@s.whatsapp.net', + senderNumber: '15551234567', + downloadMedia: async () => { throw new Error('boom'); }, + cacheDirs: { document: mkdtempSync(path.join(tmpdir(), 'wa-media-')) }, + }); + assert.equal(event.body, 'see attached\n[document could not be downloaded]'); + assert.equal(event.mediaUrls.length, 0); + console.log(' ✓ captioned failed download keeps caption and appends note'); +} + console.log('\n✅ All WhatsApp native bridge helper tests passed.'); diff --git a/scripts/whatsapp-bridge/bridge_helpers.js b/scripts/whatsapp-bridge/bridge_helpers.js index 50a5584b5e9..b99b761d3e8 100644 --- a/scripts/whatsapp-bridge/bridge_helpers.js +++ b/scripts/whatsapp-bridge/bridge_helpers.js @@ -285,6 +285,17 @@ function formatPollUpdateText(update) { return `[Poll update${target ? `: ${target}` : ''}]`; } +/** + * Append a visible note for media that failed to download, so the agent knows + * something was sent rather than silently losing the attachment. Returns + * `content` unchanged when nothing failed. (Port of nanoclaw#2895.) + */ +export function appendMediaFailureNote(content, failures) { + if (!failures || failures.length === 0) return content; + const note = failures.map((t) => `[${t} could not be downloaded]`).join(' '); + return content ? `${content}\n${note}` : note; +} + export async function extractBridgeEvent({ msg, chatId, @@ -314,13 +325,28 @@ export async function extractBridgeEvent({ const mediaUrls = []; const nativeMetadata = {}; - const saveMedia = async ({ mediaMessage, dir, prefix, fallbackExt, fileName: name }) => { + const mediaFailures = []; + + const saveMedia = async ({ mediaMessage, dir, prefix, fallbackExt, fileName: name, type }) => { if (!downloadMedia) return; - const buf = await downloadMedia(msg); - const ext = mediaExtForMime(mediaMessage?.mimetype, fallbackExt); - const writer = writeMediaFile || defaultWriteMediaFile; - const saved = await writer({ buffer: buf, dir, prefix, ext, fileName: name }); - if (saved) mediaUrls.push(saved); + try { + const buf = await downloadMedia(msg); + const ext = mediaExtForMime(mediaMessage?.mimetype, fallbackExt); + const writer = writeMediaFile || defaultWriteMediaFile; + const saved = await writer({ buffer: buf, dir, prefix, ext, fileName: name }); + if (saved) mediaUrls.push(saved); + } catch (err) { + // A failed CDN fetch (expired media URL, transient network error) must + // never reject out of extractBridgeEvent — that would drop this message + // AND every remaining message in the same upsert batch. Record the + // failure so the agent is told media was sent instead of losing it + // silently. (Port of nanoclaw#2895's never-silently-drop guarantee; the + // reuploadRequest recovery half is already wired in bridge.js.) + mediaFailures.push(type || 'media'); + try { + console.warn(`[bridge] failed to download inbound ${type || 'media'}:`, err?.message || err); + } catch {} + } }; if (messageContent.conversation) { @@ -336,7 +362,7 @@ export async function extractBridgeEvent({ mediaType = 'image'; nativeType = 'imageMessage'; mime = item.mimetype || 'image/jpeg'; - await saveMedia({ mediaMessage: item, dir: cacheDirs.image, prefix: 'img', fallbackExt: '.jpg' }); + await saveMedia({ mediaMessage: item, dir: cacheDirs.image, prefix: 'img', fallbackExt: '.jpg', type: 'image' }); } else if (messageContent.videoMessage) { const item = messageContent.videoMessage; body = item.caption || ''; @@ -345,7 +371,7 @@ export async function extractBridgeEvent({ nativeType = 'videoMessage'; mime = item.mimetype || 'video/mp4'; nativeMetadata.video = { gifPlayback: !!item.gifPlayback }; - await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'vid', fallbackExt: '.mp4' }); + await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'vid', fallbackExt: '.mp4', type: mediaType }); } else if (messageContent.audioMessage || messageContent.pttMessage) { const item = messageContent.pttMessage || messageContent.audioMessage; hasMedia = true; @@ -353,7 +379,7 @@ export async function extractBridgeEvent({ nativeType = messageContent.pttMessage ? 'pttMessage' : 'audioMessage'; mime = item.mimetype || 'audio/ogg'; nativeMetadata.audio = { ptt: mediaType === 'ptt' }; - await saveMedia({ mediaMessage: item, dir: cacheDirs.audio, prefix: 'aud', fallbackExt: '.ogg' }); + await saveMedia({ mediaMessage: item, dir: cacheDirs.audio, prefix: 'aud', fallbackExt: '.ogg', type: 'audio' }); } else if (messageContent.documentMessage) { const item = messageContent.documentMessage; body = item.caption || ''; @@ -362,7 +388,7 @@ export async function extractBridgeEvent({ nativeType = 'documentMessage'; mime = item.mimetype || 'application/octet-stream'; fileName = item.fileName || 'document'; - await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'doc', fallbackExt: '.bin', fileName }); + await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'doc', fallbackExt: '.bin', fileName, type: 'document' }); } else if (messageContent.stickerMessage) { hasMedia = true; mediaType = 'sticker'; @@ -373,7 +399,7 @@ export async function extractBridgeEvent({ animated: !!messageContent.stickerMessage.isAnimated, mimetype: mime, }; - await saveMedia({ mediaMessage: messageContent.stickerMessage, dir: cacheDirs.image, prefix: 'sticker', fallbackExt: '.webp' }); + await saveMedia({ mediaMessage: messageContent.stickerMessage, dir: cacheDirs.image, prefix: 'sticker', fallbackExt: '.webp', type: 'sticker' }); } else if (messageContent.locationMessage || messageContent.liveLocationMessage) { const isLive = !!messageContent.liveLocationMessage; const item = messageContent.liveLocationMessage || messageContent.locationMessage; @@ -425,6 +451,12 @@ export async function extractBridgeEvent({ nativeMetadata.pollUpdate = messageContent.pollUpdateMessage; } + // Surface failed downloads to the agent instead of silently losing the + // attachment. Applied before the generic "[ received]" fallback so an + // uncaptioned message whose download failed reads "[image could not be + // downloaded]" rather than claiming the media arrived. + body = appendMediaFailureNote(body, mediaFailures); + if (hasMedia && !body) { body = `[${mediaType} received]`; }