Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
export function normalizeWhatsAppIdentifier(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/:.*@/, '@')
|
||||
.replace(/@.*/, '')
|
||||
.replace(/^\+/, '');
|
||||
}
|
||||
|
||||
export function parseAllowedUsers(rawValue) {
|
||||
return new Set(
|
||||
String(rawValue || '')
|
||||
.split(',')
|
||||
.map((value) => normalizeWhatsAppIdentifier(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function readMappingFile(sessionDir, identifier, suffix = '') {
|
||||
const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`);
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
|
||||
const normalized = normalizeWhatsAppIdentifier(parsed);
|
||||
return normalized || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function expandWhatsAppIdentifiers(identifier, sessionDir) {
|
||||
const normalized = normalizeWhatsAppIdentifier(identifier);
|
||||
if (!normalized) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Walk both phone->LID and LID->phone mapping files so allowlists can use
|
||||
// either form transparently in bot mode.
|
||||
const resolved = new Set();
|
||||
const queue = [normalized];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || resolved.has(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.add(current);
|
||||
|
||||
for (const suffix of ['', '_reverse']) {
|
||||
const mapped = readMappingFile(sessionDir, current, suffix);
|
||||
if (mapped && !resolved.has(mapped)) {
|
||||
queue.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
|
||||
// Empty allowlist = NO ONE allowed (secure default, #8389). Operators
|
||||
// who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
|
||||
// Previous behaviour (empty → return true) let any stranger DM the
|
||||
// bridge and trigger a Python-side pairing-code reply.
|
||||
if (!allowedUsers || allowedUsers.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)
|
||||
if (allowedUsers.has('*')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers(senderId, sessionDir);
|
||||
for (const alias of aliases) {
|
||||
if (allowedUsers.has(alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
expandWhatsAppIdentifiers,
|
||||
matchesAllowedUser,
|
||||
normalizeWhatsAppIdentifier,
|
||||
parseAllowedUsers,
|
||||
} from './allowlist.js';
|
||||
|
||||
test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => {
|
||||
assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595');
|
||||
assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914');
|
||||
assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595');
|
||||
});
|
||||
|
||||
test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir);
|
||||
assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914'));
|
||||
writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595'));
|
||||
|
||||
const allowedUsers = parseAllowedUsers('+19175395595');
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser treats * as allow-all wildcard', () => {
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
const allowedUsers = parseAllowedUsers('*');
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', allowedUsers, sessionDir), true);
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
|
||||
// Regression guard: empty allowlist used to return true (allow-everyone),
|
||||
// which let any stranger DM the bridge and trigger a Python-side
|
||||
// pairing-code reply. Secure default is now "reject unless explicitly
|
||||
// configured"; operators who want an open bot must set `*`.
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
|
||||
|
||||
try {
|
||||
const empty = parseAllowedUsers('');
|
||||
assert.equal(empty.size, 0);
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
|
||||
assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
|
||||
|
||||
// Null/undefined allowlist (defensive) also rejects.
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
|
||||
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
|
||||
} finally {
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* 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,
|
||||
inboundReadReceiptKeys,
|
||||
mediaPayloadForFile,
|
||||
pollCreationMessageFromPayload,
|
||||
pollUpdateForAggregation,
|
||||
} from './bridge_helpers.js';
|
||||
|
||||
// -- inbound read receipts ------------------------------------------------
|
||||
{
|
||||
const groupKey = {
|
||||
id: 'incoming-group-1',
|
||||
remoteJid: '120363001234567890@g.us',
|
||||
participant: '15550001111@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
};
|
||||
|
||||
assert.deepEqual(inboundReadReceiptKeys({ key: groupKey, enabled: false }), []);
|
||||
assert.deepEqual(
|
||||
inboundReadReceiptKeys({ key: { ...groupKey, fromMe: true }, enabled: true }),
|
||||
[],
|
||||
);
|
||||
const receiptKeys = inboundReadReceiptKeys({ key: groupKey, enabled: true });
|
||||
assert.equal(receiptKeys.length, 1);
|
||||
assert.equal(receiptKeys[0], groupKey);
|
||||
assert.equal(receiptKeys[0].participant, groupKey.participant);
|
||||
console.log(' ✓ inbound read receipts preserve the original group message key');
|
||||
}
|
||||
|
||||
// -- 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.deepEqual(event.readReceiptKey, {
|
||||
id: 'incoming-1',
|
||||
remoteJid: '15551234567@s.whatsapp.net',
|
||||
participant: '15550001111@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
});
|
||||
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.');
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Unit tests for the reconnect scheduling and version resolution guards.
|
||||
*
|
||||
* Regression tests for the reconnect-wedge trap: startSocket() awaits
|
||||
* network I/O (fetchLatestBaileysVersion has no AbortSignal) before it
|
||||
* creates a socket, and the close handler used to re-enter it via a bare
|
||||
* `setTimeout(startSocket, ...)`. A rejection was unhandled and a stalled
|
||||
* fetch left the bridge permanently disconnected while its HTTP server
|
||||
* kept answering 503 — observed in the field as a bridge that logged
|
||||
* "Reconnecting in 3s..." once and then went silent for 27+ hours.
|
||||
*
|
||||
* 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 {
|
||||
createReconnectScheduler,
|
||||
createVersionResolver,
|
||||
} from './bridge_helpers.js';
|
||||
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// -- createReconnectScheduler ---------------------------------------------
|
||||
|
||||
// A rejecting start function is caught and rescheduled at the retry delay;
|
||||
// a subsequent success stops the retry chain.
|
||||
{
|
||||
const timers = [];
|
||||
const logs = [];
|
||||
let attempts = 0;
|
||||
const startFn = async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error('boom');
|
||||
};
|
||||
|
||||
const schedule = createReconnectScheduler(startFn, {
|
||||
retryDelayMs: 5000,
|
||||
log: line => logs.push(line),
|
||||
setTimeoutFn: (fn, ms) => timers.push({ fn, ms }),
|
||||
});
|
||||
|
||||
schedule(3000);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(timers[0].ms, 3000);
|
||||
|
||||
timers[0].fn();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0], /Reconnect failed \(boom\)/);
|
||||
assert.equal(timers.length, 2, 'rejection must schedule a retry');
|
||||
assert.equal(timers[1].ms, 5000);
|
||||
|
||||
timers[1].fn();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
assert.equal(timers.length, 2, 'success must not schedule another attempt');
|
||||
assert.equal(logs.length, 1);
|
||||
}
|
||||
|
||||
// A synchronous throw from the start function is contained the same way as
|
||||
// an async rejection.
|
||||
{
|
||||
const timers = [];
|
||||
const logs = [];
|
||||
const schedule = createReconnectScheduler(
|
||||
() => { throw new Error('sync boom'); },
|
||||
{
|
||||
retryDelayMs: 1000,
|
||||
log: line => logs.push(line),
|
||||
setTimeoutFn: (fn, ms) => timers.push({ fn, ms }),
|
||||
},
|
||||
);
|
||||
|
||||
schedule(0);
|
||||
timers[0].fn();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0], /sync boom/);
|
||||
assert.equal(timers.length, 2);
|
||||
}
|
||||
|
||||
// -- createVersionResolver ------------------------------------------------
|
||||
|
||||
// A successful fetch returns and caches the version.
|
||||
{
|
||||
const resolveVersion = createVersionResolver(
|
||||
async () => ({ version: [2, 3000, 99] }),
|
||||
{ log: () => {} },
|
||||
);
|
||||
assert.deepEqual(await resolveVersion(), [2, 3000, 99]);
|
||||
}
|
||||
|
||||
// A fetch that never settles resolves within the timeout bound instead of
|
||||
// pending forever; before any success there is no cache, so the resolver
|
||||
// yields null (callers fall back to the Baileys default).
|
||||
{
|
||||
const logs = [];
|
||||
const resolveVersion = createVersionResolver(
|
||||
() => new Promise(() => {}),
|
||||
{ timeoutMs: 20, log: line => logs.push(line) },
|
||||
);
|
||||
assert.equal(await resolveVersion(), null);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0], /version fetch timed out/);
|
||||
assert.match(logs[0], /library default/);
|
||||
}
|
||||
|
||||
// After one success, later failures fall back to the cached version.
|
||||
{
|
||||
const logs = [];
|
||||
let calls = 0;
|
||||
const resolveVersion = createVersionResolver(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return { version: [2, 3000, 42] };
|
||||
throw new Error('network down');
|
||||
},
|
||||
{ timeoutMs: 20, log: line => logs.push(line) },
|
||||
);
|
||||
assert.deepEqual(await resolveVersion(), [2, 3000, 42]);
|
||||
assert.deepEqual(await resolveVersion(), [2, 3000, 42]);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0], /network down/);
|
||||
assert.match(logs[0], /cached version/);
|
||||
}
|
||||
|
||||
// The losing timeout timer is cleared after a fast success, so the resolver
|
||||
// does not hold the event loop open for the full timeout window.
|
||||
{
|
||||
const resolveVersion = createVersionResolver(
|
||||
async () => ({ version: [2, 3000, 1] }),
|
||||
{ timeoutMs: 60_000, log: () => {} },
|
||||
);
|
||||
const before = Date.now();
|
||||
await resolveVersion();
|
||||
await sleep(10);
|
||||
assert.ok(Date.now() - before < 1000);
|
||||
}
|
||||
|
||||
console.log('bridge.reconnect.test.mjs: all assertions passed');
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Regression tests for the WhatsApp bridge send queue (#33360).
|
||||
*
|
||||
* The bridge must serialise all sock.sendMessage() calls through a
|
||||
* promise-based queue so that concurrent HTTP /send requests never
|
||||
* produce overlapping Baileys socket writes. Overlapping writes are
|
||||
* the confirmed root cause of cross-chat contamination.
|
||||
*
|
||||
* These tests exercise the queue itself — they do NOT require a live
|
||||
* WhatsApp socket.
|
||||
*/
|
||||
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Unit test for the queue primitives
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replicate the queue logic from bridge.js so we can test it in
|
||||
* isolation without importing the full module (which would trigger
|
||||
* Baileys / express side effects).
|
||||
*/
|
||||
function createSendQueue() {
|
||||
let _sendQueue = Promise.resolve();
|
||||
|
||||
function enqueueSend(fn) {
|
||||
const task = _sendQueue.then(() => fn(), () => fn());
|
||||
_sendQueue = task.catch(() => {});
|
||||
return task;
|
||||
}
|
||||
|
||||
return { enqueueSend };
|
||||
}
|
||||
|
||||
// -- serial ordering -------------------------------------------------
|
||||
{
|
||||
const { enqueueSend } = createSendQueue();
|
||||
const order = [];
|
||||
|
||||
const a = enqueueSend(async () => {
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
order.push('a');
|
||||
return 'A';
|
||||
});
|
||||
const b = enqueueSend(async () => {
|
||||
order.push('b');
|
||||
return 'B';
|
||||
});
|
||||
const c = enqueueSend(async () => {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
order.push('c');
|
||||
return 'C';
|
||||
});
|
||||
|
||||
const results = await Promise.all([a, b, c]);
|
||||
assert.deepStrictEqual(results, ['A', 'B', 'C'], 'all tasks resolve');
|
||||
assert.deepStrictEqual(order, ['a', 'b', 'c'], 'tasks execute in FIFO order');
|
||||
console.log(' ✓ serial ordering');
|
||||
}
|
||||
|
||||
// -- error isolation (one rejection does not stall the queue) --------
|
||||
{
|
||||
const { enqueueSend } = createSendQueue();
|
||||
const order = [];
|
||||
|
||||
const bad = enqueueSend(async () => {
|
||||
order.push('bad');
|
||||
throw new Error('boom');
|
||||
});
|
||||
const good = enqueueSend(async () => {
|
||||
order.push('good');
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
await assert.rejects(() => bad, /boom/, 'bad task rejects');
|
||||
const g = await good;
|
||||
assert.strictEqual(g, 'ok', 'good task still resolves');
|
||||
assert.deepStrictEqual(order, ['bad', 'good'], 'good runs after bad');
|
||||
console.log(' ✓ error isolation');
|
||||
}
|
||||
|
||||
// -- timeout still fires (wrapped inside enqueueSend) ----------------
|
||||
{
|
||||
const { enqueueSend } = createSendQueue();
|
||||
const timedOut = enqueueSend(async () => {
|
||||
await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 20));
|
||||
});
|
||||
await assert.rejects(() => timedOut, /timeout/, 'inner timeout propagates');
|
||||
console.log(' ✓ timeout propagation');
|
||||
}
|
||||
|
||||
// -- concurrent enqueues maintain single-consumer semantics ----------
|
||||
{
|
||||
const { enqueueSend } = createSendQueue();
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
async function tracked() {
|
||||
concurrent += 1;
|
||||
if (concurrent > maxConcurrent) maxConcurrent = concurrent;
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
concurrent -= 1;
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: 20 }, () => enqueueSend(tracked)));
|
||||
assert.strictEqual(maxConcurrent, 1, 'never more than one in-flight');
|
||||
assert.strictEqual(concurrent, 0, 'all finished');
|
||||
console.log(' ✓ single-consumer concurrency');
|
||||
}
|
||||
|
||||
console.log('\n✅ All send-queue tests passed.');
|
||||
@@ -0,0 +1,626 @@
|
||||
import path from 'path';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
webp: 'image/webp', gif: 'image/gif',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska', '3gp': 'video/3gpp',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
export function normalizeWhatsAppId(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(':', '@');
|
||||
}
|
||||
|
||||
export function getMessageContent(msg) {
|
||||
const content = msg?.message || {};
|
||||
if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
|
||||
if (content.viewOnceMessage?.message) return content.viewOnceMessage.message;
|
||||
if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message;
|
||||
if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message;
|
||||
if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate;
|
||||
if (content.buttonsMessage) return content.buttonsMessage;
|
||||
if (content.listMessage) return content.listMessage;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function getContextInfo(messageContent) {
|
||||
if (!messageContent || typeof messageContent !== 'object') return {};
|
||||
for (const value of Object.values(messageContent)) {
|
||||
if (value && typeof value === 'object' && value.contextInfo) {
|
||||
return value.contextInfo;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function createBoundedMessageStore(limit = 512) {
|
||||
const byId = new Map();
|
||||
|
||||
function remember(msg) {
|
||||
const id = msg?.key?.id;
|
||||
if (!id) return;
|
||||
byId.delete(id);
|
||||
byId.set(id, msg);
|
||||
while (byId.size > limit) {
|
||||
const oldest = byId.keys().next().value;
|
||||
byId.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function get(id) {
|
||||
if (!id || !byId.has(id)) return null;
|
||||
const msg = byId.get(id);
|
||||
byId.delete(id);
|
||||
byId.set(id, msg);
|
||||
return msg;
|
||||
}
|
||||
|
||||
return { remember, get };
|
||||
}
|
||||
|
||||
export function pollCreationMessageSecret(pollCreation) {
|
||||
return pollCreation?.message?.messageContextInfo?.messageSecret
|
||||
|| pollCreation?.messageContextInfo?.messageSecret
|
||||
|| null;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const value of values || []) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
out.push(text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pollUpdateForAggregation({
|
||||
pollUpdateMessage,
|
||||
pollUpdateMessageKey,
|
||||
pollCreation,
|
||||
decryptPollVote,
|
||||
getKeyAuthor,
|
||||
meId = 'me',
|
||||
pollCreatorJids = [],
|
||||
voterJids = [],
|
||||
}) {
|
||||
if (!pollUpdateMessage) return null;
|
||||
const updateKey = pollUpdateMessage.pollUpdateMessageKey
|
||||
|| pollUpdateMessageKey
|
||||
|| pollUpdateMessage.key;
|
||||
if (!updateKey) return null;
|
||||
|
||||
if (pollUpdateMessage.vote?.selectedOptions) {
|
||||
return {
|
||||
pollUpdateMessageKey: updateKey,
|
||||
vote: pollUpdateMessage.vote,
|
||||
senderTimestampMs: pollUpdateMessage.senderTimestampMs,
|
||||
};
|
||||
}
|
||||
|
||||
const creationKey = pollUpdateMessage.pollCreationMessageKey;
|
||||
const secret = pollCreationMessageSecret(pollCreation);
|
||||
if (
|
||||
!creationKey?.id
|
||||
|| !secret
|
||||
|| !pollUpdateMessage.vote?.encPayload
|
||||
|| !pollUpdateMessage.vote?.encIv
|
||||
|| typeof decryptPollVote !== 'function'
|
||||
|| typeof getKeyAuthor !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Baileys poll decryption keys include both creator and voter JIDs. On
|
||||
// WhatsApp LID chats, the poll creator can be the linked-device LID even
|
||||
// when sock.user.id is the classic @s.whatsapp.net JID. Try the exact
|
||||
// candidates the live bridge knows before falling back to the generic helper.
|
||||
const creatorCandidates = uniqueStrings([
|
||||
...pollCreatorJids,
|
||||
getKeyAuthor(creationKey, meId),
|
||||
]);
|
||||
const voterCandidates = uniqueStrings([
|
||||
...voterJids,
|
||||
getKeyAuthor(updateKey, meId),
|
||||
]);
|
||||
|
||||
let lastError = null;
|
||||
for (const pollCreatorJid of creatorCandidates) {
|
||||
for (const voterJid of voterCandidates) {
|
||||
try {
|
||||
const vote = decryptPollVote(pollUpdateMessage.vote, {
|
||||
pollCreatorJid,
|
||||
pollMsgId: creationKey.id,
|
||||
pollEncKey: secret,
|
||||
voterJid,
|
||||
});
|
||||
return {
|
||||
pollUpdateMessageKey: updateKey,
|
||||
vote,
|
||||
senderTimestampMs: pollUpdateMessage.senderTimestampMs,
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTextSendPayload(text, { replyTo, messageStore } = {}) {
|
||||
const content = { text };
|
||||
const options = {};
|
||||
const quoted = messageStore?.get(replyTo);
|
||||
if (quoted?.key && quoted?.message) {
|
||||
// Baileys expects quoted messages as sendMessage options, not inside the
|
||||
// message content payload. Keeping this split avoids silently sending a
|
||||
// literal/ignored `quoted` field instead of a native WhatsApp reply.
|
||||
options.quoted = quoted;
|
||||
}
|
||||
return { content, options };
|
||||
}
|
||||
|
||||
export function buildLocationPayload({ latitude, longitude, name, address } = {}) {
|
||||
const lat = Number(latitude);
|
||||
const lon = Number(longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new Error('latitude and longitude must be numbers');
|
||||
}
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
||||
throw new Error('latitude/longitude out of range');
|
||||
}
|
||||
|
||||
const location = {
|
||||
degreesLatitude: lat,
|
||||
degreesLongitude: lon,
|
||||
};
|
||||
if (name) location.name = String(name);
|
||||
if (address) location.address = String(address);
|
||||
return { location };
|
||||
}
|
||||
|
||||
function textFromQuotedMessage(quotedMessage) {
|
||||
if (!quotedMessage) return '';
|
||||
if (quotedMessage.conversation) return quotedMessage.conversation;
|
||||
if (quotedMessage.extendedTextMessage?.text) return quotedMessage.extendedTextMessage.text;
|
||||
if (quotedMessage.imageMessage?.caption) return quotedMessage.imageMessage.caption;
|
||||
if (quotedMessage.videoMessage?.caption) return quotedMessage.videoMessage.caption;
|
||||
if (quotedMessage.documentMessage?.caption) return quotedMessage.documentMessage.caption;
|
||||
if (quotedMessage.documentMessage?.fileName) return `[Document: ${quotedMessage.documentMessage.fileName}]`;
|
||||
if (quotedMessage.locationMessage) return formatLocationText(quotedMessage.locationMessage, false);
|
||||
if (quotedMessage.contactMessage) return formatContactText(quotedMessage.contactMessage);
|
||||
if (quotedMessage.pollCreationMessage) return formatPollText(quotedMessage.pollCreationMessage);
|
||||
return '';
|
||||
}
|
||||
|
||||
function mediaExtForMime(mime, fallback) {
|
||||
const normalized = String(mime || '').split(';', 1)[0].toLowerCase();
|
||||
const extMap = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
'video/mp4': '.mp4',
|
||||
'video/quicktime': '.mov',
|
||||
'video/x-matroska': '.mkv',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/mp4': '.m4a',
|
||||
'audio/mpeg': '.mp3',
|
||||
'application/pdf': '.pdf',
|
||||
};
|
||||
return extMap[normalized] || fallback;
|
||||
}
|
||||
|
||||
function defaultWriteMediaFile({ buffer, dir, prefix, ext, fileName }) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
let safeName = fileName ? `_${path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_')}` : '';
|
||||
if (safeName && ext && !path.extname(safeName)) {
|
||||
safeName = `${safeName}${ext}`;
|
||||
}
|
||||
const filePath = path.join(dir, `${prefix}_${randomBytes(6).toString('hex')}${safeName || ext}`);
|
||||
writeFileSync(filePath, buffer);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function formatLocationText(location, isLive) {
|
||||
const name = location.name || location.address || '';
|
||||
const lat = location.degreesLatitude ?? location.latitude;
|
||||
const lng = location.degreesLongitude ?? location.longitude;
|
||||
const kind = isLive ? 'Live location' : 'Location';
|
||||
const coords = lat !== undefined && lng !== undefined ? `${lat},${lng}` : '';
|
||||
return `[${kind}: ${[name, coords].filter(Boolean).join(' ')}]`;
|
||||
}
|
||||
|
||||
function locationMetadata(location, isLive) {
|
||||
return {
|
||||
name: location.name || '',
|
||||
address: location.address || '',
|
||||
latitude: location.degreesLatitude ?? location.latitude ?? null,
|
||||
longitude: location.degreesLongitude ?? location.longitude ?? null,
|
||||
isLive,
|
||||
};
|
||||
}
|
||||
|
||||
function formatContactText(contact) {
|
||||
const name = contact.displayName || contact.vcard?.match(/FN:(.+)/)?.[1] || 'unknown';
|
||||
const phone = contact.vcard?.match(/TEL[^:]*:(.+)/)?.[1] || '';
|
||||
return `[Contact: ${[name, phone].filter(Boolean).join(' ')}]`;
|
||||
}
|
||||
|
||||
function formatContactsText(contacts) {
|
||||
const names = contacts.map(c => c.displayName).filter(Boolean);
|
||||
return `[Contacts: ${names.join(', ') || contacts.length}]`;
|
||||
}
|
||||
|
||||
function formatReactionText(reaction) {
|
||||
const emoji = reaction.text || '';
|
||||
const target = reaction.key?.id || '';
|
||||
return `[Reaction: ${emoji}${target ? ` to ${target}` : ''}]`;
|
||||
}
|
||||
|
||||
function pollOptions(poll) {
|
||||
return (poll.options || [])
|
||||
.map(option => option.optionName || option.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatPollText(poll) {
|
||||
const question = poll.name || poll.title || 'poll';
|
||||
const options = pollOptions(poll);
|
||||
return `[Poll: ${question}${options.length ? ` Options: ${options.join(', ')}` : ''}]`;
|
||||
}
|
||||
|
||||
function formatPollUpdateText(update) {
|
||||
const target = update.pollCreationMessageKey?.id || update.key?.id || '';
|
||||
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,
|
||||
senderId,
|
||||
senderNumber,
|
||||
botIds = [],
|
||||
isGroup = false,
|
||||
downloadMedia,
|
||||
writeMediaFile,
|
||||
cacheDirs = {},
|
||||
}) {
|
||||
const messageContent = getMessageContent(msg);
|
||||
const contextInfo = getContextInfo(messageContent);
|
||||
const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeWhatsAppId).filter(Boolean)));
|
||||
const quotedMessageId = contextInfo?.stanzaId || null;
|
||||
const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null;
|
||||
const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null;
|
||||
const hasQuotedMessage = !!contextInfo?.quotedMessage;
|
||||
const quotedText = textFromQuotedMessage(contextInfo?.quotedMessage);
|
||||
|
||||
let body = '';
|
||||
let hasMedia = false;
|
||||
let mediaType = '';
|
||||
let mime = '';
|
||||
let fileName = '';
|
||||
let nativeType = '';
|
||||
const mediaUrls = [];
|
||||
const nativeMetadata = {};
|
||||
|
||||
const mediaFailures = [];
|
||||
|
||||
const saveMedia = async ({ mediaMessage, dir, prefix, fallbackExt, fileName: name, type }) => {
|
||||
if (!downloadMedia) return;
|
||||
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) {
|
||||
body = messageContent.conversation;
|
||||
nativeType = 'conversation';
|
||||
} else if (messageContent.extendedTextMessage?.text) {
|
||||
body = messageContent.extendedTextMessage.text;
|
||||
nativeType = 'extendedTextMessage';
|
||||
} else if (messageContent.imageMessage) {
|
||||
const item = messageContent.imageMessage;
|
||||
body = item.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'image';
|
||||
nativeType = 'imageMessage';
|
||||
mime = item.mimetype || 'image/jpeg';
|
||||
await saveMedia({ mediaMessage: item, dir: cacheDirs.image, prefix: 'img', fallbackExt: '.jpg', type: 'image' });
|
||||
} else if (messageContent.videoMessage) {
|
||||
const item = messageContent.videoMessage;
|
||||
body = item.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = item.gifPlayback ? 'gif' : 'video';
|
||||
nativeType = 'videoMessage';
|
||||
mime = item.mimetype || 'video/mp4';
|
||||
nativeMetadata.video = { gifPlayback: !!item.gifPlayback };
|
||||
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;
|
||||
mediaType = item.ptt || messageContent.pttMessage ? 'ptt' : 'audio';
|
||||
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', type: 'audio' });
|
||||
} else if (messageContent.documentMessage) {
|
||||
const item = messageContent.documentMessage;
|
||||
body = item.caption || '';
|
||||
hasMedia = true;
|
||||
mediaType = 'document';
|
||||
nativeType = 'documentMessage';
|
||||
mime = item.mimetype || 'application/octet-stream';
|
||||
fileName = item.fileName || 'document';
|
||||
await saveMedia({ mediaMessage: item, dir: cacheDirs.document, prefix: 'doc', fallbackExt: '.bin', fileName, type: 'document' });
|
||||
} else if (messageContent.stickerMessage) {
|
||||
hasMedia = true;
|
||||
mediaType = 'sticker';
|
||||
nativeType = 'stickerMessage';
|
||||
mime = messageContent.stickerMessage.mimetype || 'image/webp';
|
||||
body = '[Sticker]';
|
||||
nativeMetadata.sticker = {
|
||||
animated: !!messageContent.stickerMessage.isAnimated,
|
||||
mimetype: mime,
|
||||
};
|
||||
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;
|
||||
mediaType = isLive ? 'live_location' : 'location';
|
||||
nativeType = isLive ? 'liveLocationMessage' : 'locationMessage';
|
||||
body = formatLocationText(item, isLive);
|
||||
nativeMetadata.location = locationMetadata(item, isLive);
|
||||
} else if (messageContent.contactMessage) {
|
||||
mediaType = 'contact';
|
||||
nativeType = 'contactMessage';
|
||||
body = formatContactText(messageContent.contactMessage);
|
||||
nativeMetadata.contact = {
|
||||
displayName: messageContent.contactMessage.displayName || '',
|
||||
vcard: messageContent.contactMessage.vcard || '',
|
||||
};
|
||||
} else if (messageContent.contactsArrayMessage) {
|
||||
const contacts = messageContent.contactsArrayMessage.contacts || [];
|
||||
mediaType = 'contacts';
|
||||
nativeType = 'contactsArrayMessage';
|
||||
body = formatContactsText(contacts);
|
||||
nativeMetadata.contacts = contacts.map(contact => ({
|
||||
displayName: contact.displayName || '',
|
||||
vcard: contact.vcard || '',
|
||||
}));
|
||||
} else if (messageContent.reactionMessage) {
|
||||
mediaType = 'reaction';
|
||||
nativeType = 'reactionMessage';
|
||||
body = formatReactionText(messageContent.reactionMessage);
|
||||
nativeMetadata.reaction = {
|
||||
text: messageContent.reactionMessage.text || '',
|
||||
messageId: messageContent.reactionMessage.key?.id || '',
|
||||
remoteJid: normalizeWhatsAppId(messageContent.reactionMessage.key?.remoteJid || ''),
|
||||
participant: normalizeWhatsAppId(messageContent.reactionMessage.key?.participant || ''),
|
||||
};
|
||||
} else if (messageContent.pollCreationMessage || messageContent.pollCreationMessageV2 || messageContent.pollCreationMessageV3) {
|
||||
const item = messageContent.pollCreationMessage || messageContent.pollCreationMessageV2 || messageContent.pollCreationMessageV3;
|
||||
mediaType = 'poll';
|
||||
nativeType = messageContent.pollCreationMessage ? 'pollCreationMessage' : messageContent.pollCreationMessageV2 ? 'pollCreationMessageV2' : 'pollCreationMessageV3';
|
||||
body = formatPollText(item);
|
||||
nativeMetadata.poll = {
|
||||
question: item.name || item.title || '',
|
||||
options: pollOptions(item),
|
||||
selectableCount: item.selectableOptionsCount || item.selectableCount || 1,
|
||||
};
|
||||
} else if (messageContent.pollUpdateMessage) {
|
||||
mediaType = 'poll_update';
|
||||
nativeType = 'pollUpdateMessage';
|
||||
body = formatPollUpdateText(messageContent.pollUpdateMessage);
|
||||
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]`;
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: msg.key.id,
|
||||
chatId,
|
||||
senderId,
|
||||
senderName: msg.pushName || senderNumber,
|
||||
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
|
||||
isGroup,
|
||||
body,
|
||||
hasMedia,
|
||||
mediaType,
|
||||
mime,
|
||||
fileName,
|
||||
nativeType,
|
||||
nativeMetadata,
|
||||
mediaUrls,
|
||||
mentionedIds,
|
||||
quotedMessageId,
|
||||
quotedParticipant,
|
||||
quotedRemoteJid,
|
||||
quotedText,
|
||||
hasQuotedMessage,
|
||||
botIds,
|
||||
readReceiptKey: {
|
||||
remoteJid: msg.key.remoteJid || chatId,
|
||||
id: msg.key.id,
|
||||
participant: msg.key.participant || senderId,
|
||||
fromMe: Boolean(msg.key.fromMe),
|
||||
},
|
||||
timestamp: msg.messageTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function inferMediaType(ext) {
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'mov', 'avi', 'mkv', '3gp'].includes(ext)) return 'video';
|
||||
if (['ogg', 'opus', 'mp3', 'wav', 'm4a'].includes(ext)) return 'audio';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function inboundReadReceiptKeys({ key, enabled }) {
|
||||
if (!enabled || !key || key.fromMe || !key.id || !key.remoteJid) return [];
|
||||
// Preserve participant for group messages: Baileys needs the original key.
|
||||
return [key];
|
||||
}
|
||||
|
||||
export function mediaPayloadForFile({ buffer, filePath, mediaType, caption, fileName }) {
|
||||
const ext = filePath.toLowerCase().split('.').pop();
|
||||
const type = mediaType || inferMediaType(ext);
|
||||
if (type === 'image' && ext === 'gif') {
|
||||
// Pure helper fallback: do not lie and label raw GIF bytes as mp4.
|
||||
// The live bridge tries ffmpeg conversion to WhatsApp gifPlayback video
|
||||
// before it falls back to this regular image payload.
|
||||
return { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/gif' };
|
||||
}
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
|
||||
case 'video':
|
||||
return { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
|
||||
case 'document':
|
||||
return {
|
||||
document: buffer,
|
||||
fileName: fileName || path.basename(filePath),
|
||||
caption: caption || undefined,
|
||||
mimetype: MIME_MAP[ext] || 'application/octet-stream',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPollPayload({ question, options, selectableCount = 1 }) {
|
||||
const cleanQuestion = String(question || '').trim();
|
||||
const cleanOptions = (options || []).map(option => String(option || '').trim()).filter(Boolean);
|
||||
if (!cleanQuestion) throw new Error('question is required');
|
||||
if (cleanOptions.length < 2) throw new Error('at least two poll options are required');
|
||||
if (cleanOptions.length > 12) throw new Error('at most 12 poll options are supported');
|
||||
const count = Math.max(1, Math.min(Number(selectableCount) || 1, cleanOptions.length));
|
||||
return {
|
||||
poll: {
|
||||
name: cleanQuestion,
|
||||
values: cleanOptions,
|
||||
selectableCount: count,
|
||||
messageSecret: randomBytes(32),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pollCreationMessageFromPayload(payload) {
|
||||
const poll = payload?.poll;
|
||||
if (!poll) return null;
|
||||
const values = Array.isArray(poll.values) ? poll.values : [];
|
||||
const options = values.map(value => String(value || '').trim()).filter(Boolean);
|
||||
if (!poll.name || options.length < 2) return null;
|
||||
const selectableOptionsCount = Math.max(1, Math.min(Number(poll.selectableCount) || 1, options.length));
|
||||
const message = {};
|
||||
if (poll.messageSecret) {
|
||||
message.messageContextInfo = { messageSecret: poll.messageSecret };
|
||||
}
|
||||
message[selectableOptionsCount === 1 ? 'pollCreationMessageV3' : 'pollCreationMessage'] = {
|
||||
name: String(poll.name),
|
||||
options: options.map(optionName => ({ optionName })),
|
||||
selectableOptionsCount,
|
||||
};
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect scheduling guard. startSocket() awaits network I/O before it
|
||||
* creates a socket or registers event handlers, so a bare
|
||||
* `setTimeout(startSocket, ...)` has two unrecoverable failure modes: a
|
||||
* rejection is unhandled (crashes the process on modern Node), and a hang
|
||||
* leaves the bridge permanently disconnected with nothing left to retry.
|
||||
* Every (re)connect must go through the scheduler this returns.
|
||||
*/
|
||||
export function createReconnectScheduler(startFn, {
|
||||
retryDelayMs = 5000,
|
||||
log = console.log,
|
||||
setTimeoutFn = setTimeout,
|
||||
} = {}) {
|
||||
function scheduleReconnect(delayMs) {
|
||||
setTimeoutFn(() => {
|
||||
Promise.resolve()
|
||||
.then(startFn)
|
||||
.catch((err) => {
|
||||
log(`⚠️ Reconnect failed (${err?.message || err}). Retrying in ${Math.round(retryDelayMs / 1000)}s...`);
|
||||
scheduleReconnect(retryDelayMs);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
return scheduleReconnect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version resolution guard. fetchLatestBaileysVersion() is a plain fetch to
|
||||
* raw.githubusercontent.com with no AbortSignal; a stalled connection can
|
||||
* pend forever and wedge the reconnect path (the scheduler above cannot
|
||||
* retry past an await that never settles). Bound the fetch and fall back to
|
||||
* the last known-good version, or the Baileys default before first success.
|
||||
*/
|
||||
export function createVersionResolver(fetchVersionFn, {
|
||||
timeoutMs = 15000,
|
||||
log = console.log,
|
||||
} = {}) {
|
||||
let cachedVersion = null;
|
||||
return async function resolveVersion() {
|
||||
let timer = null;
|
||||
try {
|
||||
const { version } = await Promise.race([
|
||||
fetchVersionFn(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('version fetch timed out')), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
cachedVersion = version;
|
||||
} catch (err) {
|
||||
log(`⚠️ Baileys version fetch failed (${err?.message || err}); using ${cachedVersion ? 'cached version' : 'library default'}.`);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
return cachedVersion;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Bounded FIFO set of outbound message IDs.
|
||||
*
|
||||
* Used by the WhatsApp bridge to distinguish "echo of our own /send" from
|
||||
* "owner-typed message on the linked device" when forwarding `fromMe`
|
||||
* inbound events back to the Python adapter.
|
||||
*
|
||||
* Eviction drops the oldest insertion-order entry when the cap is exceeded.
|
||||
* Re-remembering an existing id is a no-op for ordering (not LRU refresh).
|
||||
*
|
||||
* Heuristic limitation (intentional, documented for future debugging):
|
||||
* the set is in-memory only. On bridge restart it is empty, so for the
|
||||
* brief window between restart and the first new outbound, any in-flight
|
||||
* delivery receipts of pre-restart sends would be classified as
|
||||
* owner-typed. The TTL on owner-driven plugin actions (e.g. handover
|
||||
* sliding TTL) bounds blast radius; persisting would not be worth the
|
||||
* extra complexity / disk churn.
|
||||
*/
|
||||
|
||||
export function createOutboundIdTracker(maxSize = 512) {
|
||||
if (!Number.isInteger(maxSize) || maxSize < 1) {
|
||||
throw new RangeError('createOutboundIdTracker: maxSize must be a positive integer');
|
||||
}
|
||||
const ids = new Set();
|
||||
|
||||
function remember(id) {
|
||||
if (!id) return;
|
||||
ids.add(id);
|
||||
while (ids.size > maxSize) {
|
||||
// Set iteration order is insertion order, so values().next() is the
|
||||
// oldest entry — drop it to keep memory flat under sustained sending.
|
||||
ids.delete(ids.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return Boolean(id) && ids.has(id);
|
||||
}
|
||||
|
||||
function size() {
|
||||
return ids.size;
|
||||
}
|
||||
|
||||
return { remember, has, size };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createOutboundIdTracker } from './outbound_ids.js';
|
||||
|
||||
test('remembers and recognises an outbound id', () => {
|
||||
const tracker = createOutboundIdTracker();
|
||||
tracker.remember('msg-1');
|
||||
assert.equal(tracker.has('msg-1'), true);
|
||||
assert.equal(tracker.has('msg-2'), false);
|
||||
});
|
||||
|
||||
test('ignores empty / falsy ids', () => {
|
||||
const tracker = createOutboundIdTracker();
|
||||
tracker.remember(undefined);
|
||||
tracker.remember('');
|
||||
tracker.remember(null);
|
||||
assert.equal(tracker.size(), 0);
|
||||
assert.equal(tracker.has(''), false);
|
||||
assert.equal(tracker.has(undefined), false);
|
||||
});
|
||||
|
||||
test('evicts oldest entry once the cap is exceeded', () => {
|
||||
const tracker = createOutboundIdTracker(3);
|
||||
tracker.remember('a');
|
||||
tracker.remember('b');
|
||||
tracker.remember('c');
|
||||
tracker.remember('d'); // cap=3 → 'a' should be evicted
|
||||
assert.equal(tracker.has('a'), false);
|
||||
assert.equal(tracker.has('b'), true);
|
||||
assert.equal(tracker.has('c'), true);
|
||||
assert.equal(tracker.has('d'), true);
|
||||
assert.equal(tracker.size(), 3);
|
||||
});
|
||||
|
||||
test('cap holds across many inserts (bounded memory)', () => {
|
||||
const tracker = createOutboundIdTracker(8);
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
tracker.remember(`id-${i}`);
|
||||
}
|
||||
assert.equal(tracker.size(), 8);
|
||||
// Oldest (id-0..id-91) should be gone, latest 8 retained.
|
||||
assert.equal(tracker.has('id-0'), false);
|
||||
assert.equal(tracker.has('id-91'), false);
|
||||
assert.equal(tracker.has('id-92'), true);
|
||||
assert.equal(tracker.has('id-99'), true);
|
||||
});
|
||||
|
||||
test('re-remembering an existing id does not promote it (FIFO, not LRU)', () => {
|
||||
// Insertion-order semantics: re-adding doesn't move it forward in
|
||||
// Set iteration order. This is intentional — we don't need recency,
|
||||
// just bounded membership. Pin the actual behaviour so future
|
||||
// refactors don't accidentally introduce LRU refresh semantics.
|
||||
const tracker = createOutboundIdTracker(2);
|
||||
tracker.remember('a');
|
||||
tracker.remember('b');
|
||||
tracker.remember('a'); // no-op for ordering
|
||||
tracker.remember('c'); // evicts 'a' (oldest by insertion)
|
||||
assert.equal(tracker.has('a'), false);
|
||||
assert.equal(tracker.has('b'), true);
|
||||
assert.equal(tracker.has('c'), true);
|
||||
});
|
||||
|
||||
test('rejects non-positive maxSize', () => {
|
||||
assert.throws(() => createOutboundIdTracker(0), RangeError);
|
||||
assert.throws(() => createOutboundIdTracker(-1), RangeError);
|
||||
assert.throws(() => createOutboundIdTracker(1.5), RangeError);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Pure classifier for the WhatsApp bridge's bot-mode dispatch loop.
|
||||
*
|
||||
* Centralises the "should this fromMe message be forwarded as fromOwner?"
|
||||
* decision so the gate can be unit-tested without spinning up Baileys or
|
||||
* the Express server.
|
||||
*
|
||||
* Lives next to `outbound_ids.js` rather than inline in `bridge.js`
|
||||
* because the previous implementation accidentally bypassed the
|
||||
* customer-side allowlist when forwarding owner-typed messages — see
|
||||
* the regression test in `owner_message_gate.test.mjs`.
|
||||
*
|
||||
* Caller responsibilities:
|
||||
* - Only invoke in bot mode. Self-chat mode has its own self-chat
|
||||
* pinning logic and must not delegate here.
|
||||
* - Pre-filter group / status JIDs (the gate doesn't know about them).
|
||||
* - On `drop_allowlist`, log the rejection so operators can audit
|
||||
* accidental allowlist mismatches.
|
||||
*
|
||||
* Returned actions:
|
||||
* - 'pass' : non-fromMe, fall through to existing handling
|
||||
* - 'drop_echo' : fromMe and matches a recently-sent /send id
|
||||
* - 'drop_disabled' : fromMe but operator hasn't opted into forwarding
|
||||
* - 'drop_allowlist' : fromMe and the *customer chatId* isn't on the
|
||||
* allowlist (owner-typed reply to a stranger)
|
||||
* - 'forward_owner' : fromMe, owner-typed, allowlisted — forward with
|
||||
* fromOwner: true
|
||||
*/
|
||||
|
||||
export function classifyOwnerMessageGate({
|
||||
fromMe,
|
||||
fromOwnerEnabled,
|
||||
recentlySent,
|
||||
allowlistMatches,
|
||||
messageId,
|
||||
chatId,
|
||||
}) {
|
||||
if (!fromMe) {
|
||||
return { action: 'pass' };
|
||||
}
|
||||
if (recentlySent && recentlySent.has(messageId)) {
|
||||
return { action: 'drop_echo' };
|
||||
}
|
||||
if (!fromOwnerEnabled) {
|
||||
return { action: 'drop_disabled' };
|
||||
}
|
||||
// Allowlist gate: check the *customer* chatId, not the sender. The
|
||||
// sender is the owner's own number/LID and won't be on the allowlist
|
||||
// by construction. Without this check, any contact the owner happens
|
||||
// to reply to leaks into Hermes and triggers implicit handover in the
|
||||
// gateway-policy plugin.
|
||||
if (typeof allowlistMatches === 'function' && !allowlistMatches(chatId)) {
|
||||
return { action: 'drop_allowlist' };
|
||||
}
|
||||
return { action: 'forward_owner' };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { classifyOwnerMessageGate } from './owner_message_gate.js';
|
||||
|
||||
function makeRecentlySent(ids = []) {
|
||||
const set = new Set(ids);
|
||||
return { has: (id) => set.has(id) };
|
||||
}
|
||||
|
||||
function makeAllowlist(allowedChatIds) {
|
||||
if (allowedChatIds === '*') {
|
||||
return () => true;
|
||||
}
|
||||
const set = new Set(allowedChatIds);
|
||||
return (id) => set.has(id);
|
||||
}
|
||||
|
||||
test('non-fromMe messages always pass through', () => {
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: false,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: makeAllowlist([]),
|
||||
messageId: 'M1',
|
||||
chatId: '6281234567890@s.whatsapp.net',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'pass' });
|
||||
});
|
||||
|
||||
test('fromMe echo of our own /send is dropped', () => {
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(['M-OWN-1']),
|
||||
allowlistMatches: makeAllowlist('*'),
|
||||
messageId: 'M-OWN-1',
|
||||
chatId: '6281234567890@s.whatsapp.net',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'drop_echo' });
|
||||
});
|
||||
|
||||
test('fromMe is dropped when forwarding is disabled', () => {
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: false,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: makeAllowlist('*'),
|
||||
messageId: 'M-OWN-2',
|
||||
chatId: '6281234567890@s.whatsapp.net',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'drop_disabled' });
|
||||
});
|
||||
|
||||
test('fromMe is dropped when chatId is not on the allowlist (regression)', () => {
|
||||
// This is the bug. Before the fix, an owner reply in a non-allowlisted
|
||||
// chat was still forwarded with fromOwner: true, which made the
|
||||
// gateway-policy owner-implicit branch create stray handover rows for
|
||||
// the non-allowlisted contact.
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: makeAllowlist(['6281234567890@s.whatsapp.net']),
|
||||
messageId: 'M-OWN-3',
|
||||
chatId: '111600547700784@lid',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'drop_allowlist' });
|
||||
});
|
||||
|
||||
test('fromMe is forwarded as owner when chatId is allowlisted', () => {
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: makeAllowlist(['6281234567890@s.whatsapp.net']),
|
||||
messageId: 'M-OWN-4',
|
||||
chatId: '6281234567890@s.whatsapp.net',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'forward_owner' });
|
||||
});
|
||||
|
||||
test('open-allowlist (matchesAllowedUser short-circuits true) forwards as owner', () => {
|
||||
// matchesAllowedUser returns true on empty allowlist or "*"; the gate
|
||||
// must respect that so deployments without an allowlist are unaffected
|
||||
// by the new check.
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: () => true,
|
||||
messageId: 'M-OWN-5',
|
||||
chatId: '111600547700784@lid',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'forward_owner' });
|
||||
});
|
||||
|
||||
test('echo check fires before allowlist check', () => {
|
||||
// A bot-API echo whose chatId happens to be off-allowlist should still
|
||||
// be dropped as drop_echo, not drop_allowlist, so logging stays
|
||||
// honest about the actual reason.
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: true,
|
||||
recentlySent: makeRecentlySent(['M-ECHO-1']),
|
||||
allowlistMatches: makeAllowlist([]),
|
||||
messageId: 'M-ECHO-1',
|
||||
chatId: '111600547700784@lid',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'drop_echo' });
|
||||
});
|
||||
|
||||
test('disabled flag fires before allowlist check', () => {
|
||||
// Pre-existing deployments with WHATSAPP_FORWARD_OWNER_MESSAGES unset
|
||||
// must see drop_disabled regardless of allowlist state, otherwise
|
||||
// every fromMe message would log a misleading allowlist_mismatch.
|
||||
const decision = classifyOwnerMessageGate({
|
||||
fromMe: true,
|
||||
fromOwnerEnabled: false,
|
||||
recentlySent: makeRecentlySent(),
|
||||
allowlistMatches: makeAllowlist([]),
|
||||
messageId: 'M-OWN-6',
|
||||
chatId: '111600547700784@lid',
|
||||
});
|
||||
assert.deepEqual(decision, { action: 'drop_disabled' });
|
||||
});
|
||||
+2177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "hermes-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for Hermes Agent using Baileys",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "7.0.0-rc13",
|
||||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "^7.5.5",
|
||||
"body-parser": "1.20.6"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user