fix(whatsapp): contain and surface inbound media download failures (port nanoclaw#2895) (#59261)

Port from nanocoai/nanoclaw#2895's never-silently-drop guarantee.

Before: saveMedia() in scripts/whatsapp-bridge/bridge_helpers.js awaited
downloadMedia() with no try/catch. A failed CDN fetch (expired media URL,
transient network error — Baileys throws 'Failed to fetch stream from
https://mmg.whatsapp.net/...') rejected out of extractBridgeEvent, which
bridge.js awaits inside its messages.upsert for-loop with no per-message
guard — dropping the failed message AND every remaining message in the
same upsert batch, silently.

After:
- saveMedia catches download/write failures, records the media type, and
  logs a console.warn instead of rejecting.
- appendMediaFailureNote() (exported pure helper, mirroring the file's
  testable-helper convention) surfaces '[<type> could not be downloaded]'
  in the event body, so the agent learns media was sent rather than the
  attachment vanishing. Applied before the '[<type> received]' fallback
  so an uncaptioned failed image reads as a failure, not an arrival.

The reuploadRequest recovery half of nanoclaw#2895 is already wired in
bridge.js (downloadMediaMessage(..., { reuploadRequest:
sock.updateMediaMessage })); this ports the containment half hermes was
missing.

Tests: 3 new cases in bridge.native.test.mjs (note formatting, uncaptioned
failure containment, captioned failure note). All 5 bridge test files pass.
This commit is contained in:
Teknium 2026-07-05 17:21:28 -07:00 committed by GitHub
parent a05b64d677
commit 6fad6f1dd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 11 deletions

View file

@ -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.');

View file

@ -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 "[<type> 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]`;
}