hermes-agent/scripts/whatsapp-bridge/bridge.native.test.mjs
Teknium 6fad6f1dd8
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.
2026-07-05 17:21:28 -07:00

386 lines
13 KiB
JavaScript

/**
* Unit tests for WhatsApp-native bridge payload helpers.
*
* These tests avoid importing bridge.js because that file starts an HTTP
* server and Baileys socket at module load. Keep the helper module pure.
*/
import { strict as assert } from 'node:assert';
import { createHash } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { getAggregateVotesInPollMessage } from '@whiskeysockets/baileys';
import {
buildPollPayload,
buildTextSendPayload,
createBoundedMessageStore,
appendMediaFailureNote,
extractBridgeEvent,
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
} from './bridge_helpers.js';
// -- quoted outbound text -------------------------------------------------
{
const store = createBoundedMessageStore(2);
store.remember({
key: {
id: 'inbound-1',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
},
message: { conversation: 'original text' },
});
const { content, options } = buildTextSendPayload('reply text', {
chatId: '15551234567@s.whatsapp.net',
replyTo: 'inbound-1',
messageStore: store,
});
assert.deepEqual(content, { text: 'reply text' });
assert.equal(options.quoted.key.id, 'inbound-1');
assert.equal(options.quoted.message.conversation, 'original text');
console.log(' ✓ text replies include Baileys quoted message when resolvable');
}
{
const store = createBoundedMessageStore(2);
const { content, options } = buildTextSendPayload('plain text', {
chatId: '15551234567@s.whatsapp.net',
replyTo: 'missing-id',
messageStore: store,
});
assert.deepEqual(content, { text: 'plain text' });
assert.deepEqual(options, {});
console.log(' ✓ unresolved replyTo falls back to plain text');
}
// -- inbound quote/media/native metadata --------------------------------
{
const event = await extractBridgeEvent({
msg: {
key: {
id: 'incoming-1',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
},
pushName: 'Tester',
messageTimestamp: 123,
message: {
extendedTextMessage: {
text: 'approved',
contextInfo: {
stanzaId: 'outbound-1',
participant: '15559998888@s.whatsapp.net',
remoteJid: '15551234567@s.whatsapp.net',
quotedMessage: { conversation: 'approve deploy?' },
},
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
botIds: ['15559998888@s.whatsapp.net'],
downloadMedia: async () => Buffer.from(''),
});
assert.equal(event.quotedMessageId, 'outbound-1');
assert.equal(event.quotedParticipant, '15559998888@s.whatsapp.net');
assert.equal(event.quotedRemoteJid, '15551234567@s.whatsapp.net');
assert.equal(event.quotedText, 'approve deploy?');
assert.equal(event.hasQuotedMessage, true);
assert.equal(event.body, 'approved');
console.log(' ✓ inbound quoted metadata includes quoted text');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'doc-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
documentMessage: {
caption: 'see attached',
fileName: 'report.pdf',
mimetype: 'application/pdf',
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
downloadMedia: async () => Buffer.from('pdf'),
writeMediaFile: async () => '/tmp/report.pdf',
});
assert.equal(event.hasMedia, true);
assert.equal(event.mediaType, 'document');
assert.equal(event.mime, 'application/pdf');
assert.equal(event.fileName, 'report.pdf');
assert.equal(event.nativeType, 'documentMessage');
assert.deepEqual(event.mediaUrls, ['/tmp/report.pdf']);
console.log(' ✓ inbound document metadata preserves MIME and filename');
}
{
const cacheDir = mkdtempSync(path.join(tmpdir(), 'hermes-wa-doc-'));
const event = await extractBridgeEvent({
msg: {
key: { id: 'doc-2', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
documentMessage: {
caption: 'see attached',
fileName: 'report',
mimetype: 'application/pdf',
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
downloadMedia: async () => Buffer.from('pdf'),
cacheDirs: { document: cacheDir },
});
assert.equal(event.mediaUrls.length, 1);
assert.ok(event.mediaUrls[0].endsWith('_report.pdf'), event.mediaUrls[0]);
console.log(' ✓ MIME extension is preserved when document filename has none');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'loc-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
locationMessage: {
name: 'HQ',
degreesLatitude: 41.015,
degreesLongitude: 28.979,
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
});
assert.equal(event.mediaType, 'location');
assert.equal(event.body, '[Location: HQ 41.015,28.979]');
assert.deepEqual(event.nativeMetadata.location, {
name: 'HQ',
address: '',
latitude: 41.015,
longitude: 28.979,
isLive: false,
});
console.log(' ✓ native location messages get text fallback and metadata');
}
{
const event = await extractBridgeEvent({
msg: {
key: { id: 'poll-1', remoteJid: '15551234567@s.whatsapp.net', fromMe: false },
messageTimestamp: 123,
message: {
pollCreationMessage: {
name: 'Approve deploy?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
},
},
chatId: '15551234567@s.whatsapp.net',
senderId: '15550001111@s.whatsapp.net',
senderNumber: '15550001111',
});
assert.equal(event.mediaType, 'poll');
assert.equal(event.body, '[Poll: Approve deploy? Options: Approve, Deny]');
assert.deepEqual(event.nativeMetadata.poll.options, ['Approve', 'Deny']);
console.log(' ✓ poll creation messages get text fallback and metadata');
}
// -- outbound media/poll helpers -----------------------------------------
{
const payload = mediaPayloadForFile({
buffer: Buffer.from('gif89a'),
filePath: '/tmp/loop.gif',
mediaType: 'image',
caption: 'loop',
});
assert.ok(payload.image, 'pure helper fallback keeps raw GIF as image bytes');
assert.equal(payload.gifPlayback, undefined);
assert.equal(payload.mimetype, 'image/gif');
assert.equal(payload.caption, 'loop');
console.log(' ✓ local GIF helper fallback stays truthful; live bridge converts to gifPlayback when possible');
}
{
const payload = buildPollPayload({
question: 'Proceed?',
options: ['Approve', 'Deny'],
selectableCount: 1,
});
assert.equal(payload.poll.name, 'Proceed?');
assert.deepEqual(payload.poll.values, ['Approve', 'Deny']);
assert.equal(payload.poll.selectableCount, 1);
assert.equal(Buffer.isBuffer(payload.poll.messageSecret), true);
assert.equal(payload.poll.messageSecret.length, 32);
assert.deepEqual(pollCreationMessageFromPayload(payload), {
messageContextInfo: {
messageSecret: payload.poll.messageSecret,
},
pollCreationMessageV3: {
name: 'Proceed?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
});
console.log(' ✓ poll payload primitive carries a cacheable vote secret');
}
{
const pollCreation = {
key: {
id: 'poll-creation',
remoteJid: '15551234567@s.whatsapp.net',
fromMe: true,
},
message: {
messageContextInfo: {
messageSecret: Buffer.from('0123456789abcdef0123456789abcdef'),
},
pollCreationMessageV3: {
name: 'Proceed?',
options: [{ optionName: 'Approve' }, { optionName: 'Deny' }],
selectableOptionsCount: 1,
},
},
};
const voteKey = {
id: 'vote-message',
remoteJid: '15551234567@s.whatsapp.net',
participant: '15550001111@s.whatsapp.net',
fromMe: false,
};
const encryptedVote = {
encPayload: Buffer.from('payload'),
encIv: Buffer.from('iv'),
};
const attempts = [];
const pollUpdate = pollUpdateForAggregation({
pollUpdateMessage: {
pollCreationMessageKey: pollCreation.key,
vote: encryptedVote,
senderTimestampMs: 123,
},
pollUpdateMessageKey: voteKey,
pollCreation,
decryptPollVote: (vote, ctx) => {
attempts.push({ pollCreatorJid: ctx.pollCreatorJid, voterJid: ctx.voterJid });
assert.equal(vote, encryptedVote);
assert.equal(ctx.pollMsgId, 'poll-creation');
assert.equal(ctx.pollEncKey, pollCreation.message.messageContextInfo.messageSecret);
if (ctx.pollCreatorJid !== 'creator-lid@lid') {
throw new Error('wrong creator jid');
}
assert.equal(ctx.voterJid, '15550001111@s.whatsapp.net');
return {
selectedOptions: [createHash('sha256').update(Buffer.from('Approve')).digest()],
};
},
getKeyAuthor: (key, meId = 'me') => (key?.fromMe ? meId : key?.participant || key?.remoteJid || ''),
meId: 'classic-me@s.whatsapp.net',
pollCreatorJids: ['classic-me@s.whatsapp.net', 'creator-lid@lid'],
});
assert.deepEqual(attempts.map(item => item.pollCreatorJid), ['classic-me@s.whatsapp.net', 'creator-lid@lid']);
assert.equal(pollUpdate.pollUpdateMessageKey.id, 'vote-message');
assert.equal(pollUpdate.senderTimestampMs, 123);
const aggregation = getAggregateVotesInPollMessage({
message: pollCreation.message,
pollUpdates: [pollUpdate],
});
assert.deepEqual(
aggregation.map(option => ({ name: option.name, voters: option.voters })),
[
{ name: 'Approve', voters: ['15550001111@s.whatsapp.net'] },
{ name: 'Deny', voters: [] },
],
);
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.');