mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-03 18:45:07 +08:00
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:
@@ -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]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user