mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
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:
parent
a05b64d677
commit
6fad6f1dd8
2 changed files with 102 additions and 11 deletions
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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]`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue