fix(whatsapp): recover inbound media download via reuploadRequest

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 "[<type> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdUxK4bqbgGzFEeZhjC181
This commit is contained in:
Ed Harrod
2026-06-30 16:23:29 +01:00
parent fdbfb6a8ff
commit 37dbfd1ae8
2 changed files with 66 additions and 5 deletions
+31 -1
View File
@@ -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]',
);
});
});
+35 -4
View File
@@ -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<Array<{ type: string; name: string; localPath: string }>> {
): 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<string | undefined> {
@@ -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;