feat(whatsapp): native Baileys polls, clarify-as-poll, locations, and rich inbound metadata

Salvaged from PR #58704 by @devatnull, scoped to the WhatsApp surface:
- bridge_helpers.js: pure, tested extraction of inbound Baileys message
  parsing (quoted text, MIME/filename, PTT vs audio, stickers, contacts,
  reactions, polls, locations, GIF playback metadata)
- native poll primitive: /send-poll endpoint, poll messageSecret caching,
  encrypted vote decryption + aggregation via Baileys
- send_clarify() renders multi-choice clarify prompts as native polls;
  votes flow back through the existing clarify text-intercept
- send_location() + /send-location for native WhatsApp location pins
- structured quoted-reply context (fixes duplicated '[Replying to: ...]'
  rendered both by the adapter and gateway/run.py)
- outbound formatting: markdown *italic* -> WhatsApp _italic_, invisible
  unicode sanitization; execSync -> execFileSync hardening; GIF -> mp4
  gifPlayback conversion with truthful image/gif fallback

Out of scope (deliberately not salvaged from #58704): cross-platform
ordered-delivery machinery in gateway/platforms/base.py, LOCATION: and
hermes:poll response-text directives (no prompt wiring exists yet), and
the unconditional WhatsApp reply-anchor suppression.
This commit is contained in:
devatnull 2026-07-05 06:02:10 -07:00 committed by Teknium
parent 0ca2a927cf
commit 11627fdcb9
6 changed files with 1521 additions and 170 deletions

View file

@ -10,6 +10,7 @@
* POST /send - Send a message { chatId, message, replyTo? }
* POST /edit - Edit a sent message { chatId, messageId, message }
* POST /send-media - Send media natively { chatId, filePath, mediaType?, caption?, fileName? }
* POST /send-location - Send location pin { chatId, latitude, longitude, name?, address? }
* POST /typing - Send typing indicator { chatId }
* GET /chat/:id - Get chat info
* GET /health - Health check
@ -18,20 +19,31 @@
* node bridge.js --port 3000 --session ~/.hermes/whatsapp/session
*/
import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } from '@whiskeysockets/baileys';
import { makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage, getAggregateVotesInPollMessage, decryptPollVote, getKeyAuthor, jidNormalizedUser } from '@whiskeysockets/baileys';
import express from 'express';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { mkdirSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
import { randomBytes, createHash } from 'crypto';
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import { tmpdir } from 'os';
import qrcode from 'qrcode-terminal';
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
import { createOutboundIdTracker } from './outbound_ids.js';
import { classifyOwnerMessageGate } from './owner_message_gate.js';
import {
buildPollPayload,
buildLocationPayload,
buildTextSendPayload,
createBoundedMessageStore,
extractBridgeEvent,
inferMediaType,
mediaPayloadForFile,
pollCreationMessageFromPayload,
pollUpdateForAggregation,
} from './bridge_helpers.js';
// Parse CLI args
const args = process.argv.slice(2);
@ -119,7 +131,7 @@ function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) {
function sendWithTimeout(chatId, payload, options = {}, timeoutMs = SEND_TIMEOUT_MS) {
let timer;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(
@ -128,7 +140,7 @@ function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) {
);
});
return enqueueSend(() =>
Promise.race([sock.sendMessage(chatId, payload), timeoutPromise])
Promise.race([sock.sendMessage(chatId, payload, options), timeoutPromise])
.finally(() => clearTimeout(timer))
);
}
@ -164,6 +176,18 @@ function splitLongMessage(message, maxLength = MAX_MESSAGE_LENGTH) {
return chunks;
}
function rememberSentMessage(sent, payload) {
if (!sent?.key?.id) return;
if (sent.message) {
messageStore.remember(sent);
return;
}
const syntheticMessage = pollCreationMessageFromPayload(payload);
if (syntheticMessage) {
messageStore.remember({ ...sent, message: syntheticMessage });
}
}
function trackSentMessageId(sent) {
rememberSentId(sent?.key?.id);
}
@ -227,6 +251,98 @@ const MAX_QUEUE_SIZE = 100;
// Capacity bounded (see outbound_ids.js) to keep memory flat under
// sustained sending.
const recentlySentIds = createOutboundIdTracker(512);
const recentlyProcessedPollUpdates = createOutboundIdTracker(512);
const messageStore = createBoundedMessageStore(512);
function normalizePollUpdateOptions(aggregation, pollUpdateMessage, meId) {
const selected = [];
for (const option of aggregation || []) {
if ((option.voters || []).length > 0 && option.name && option.name !== 'Unknown') {
selected.push(option.name);
}
}
if (selected.length > 0) return selected;
// Fallback for already-decrypted pollUpdateMessage payloads where Baileys did
// not have the creation message available. This may only yield hashes, but
// keeping them in metadata is still better than dropping the vote entirely.
const raw = pollUpdateMessage?.vote?.selectedOptions || [];
return raw.map(option => String(option)).filter(Boolean);
}
function pollAggregationSummary(aggregation) {
return (aggregation || []).map(option => ({
name: option?.name || '',
voterCount: (option?.voters || []).length,
}));
}
function logPollUpdateDiagnostic({ sourcePath, pollId, pollCreation, pollUpdates, selectedOptions, aggregation }) {
const firstUpdate = pollUpdates?.[0] || {};
try {
console.log(JSON.stringify({
event: 'poll_update_decode',
sourcePath,
pollId: pollId || '',
pollCreationFound: !!pollCreation,
updateKeys: Object.keys(firstUpdate),
hasVote: !!firstUpdate.vote,
selectedOptionsLength: selectedOptions?.length || 0,
aggregation: pollAggregationSummary(aggregation),
}));
} catch {}
}
function enqueuePollUpdateEvent({ key, update, selectedOptions, aggregation }) {
const chatId = normalizeWhatsAppId(key?.remoteJid || update?.pollUpdates?.[0]?.pollUpdateMessageKey?.remoteJid || '');
const senderId = normalizeWhatsAppId(
key?.participant
|| update?.pollUpdates?.[0]?.pollUpdateMessageKey?.participant
|| chatId
);
const pollId = key?.id
|| update?.pollUpdates?.[0]?.pollCreationMessageKey?.id
|| update?.pollUpdates?.[0]?.pollUpdateMessageKey?.id
|| '';
const chosenText = selectedOptions.length ? selectedOptions.join(', ') : `[Poll update${pollId ? `: ${pollId}` : ''}]`;
const dedupeId = `poll:${pollId}:${senderId}:${selectedOptions.join('|')}`;
if (recentlyProcessedPollUpdates.has(dedupeId)) return;
recentlyProcessedPollUpdates.remember(dedupeId);
const event = {
messageId: `${pollId || 'poll'}:update:${Date.now()}`,
chatId,
senderId,
senderName: senderId.replace(/@.*/, ''),
chatName: chatId.replace(/@.*/, ''),
isGroup: chatId.endsWith('@g.us'),
body: chosenText,
hasMedia: false,
mediaType: 'poll_update',
mime: '',
fileName: '',
nativeType: 'pollUpdateMessage',
nativeMetadata: {
pollUpdate: {
pollId,
selectedOptions,
aggregation,
},
},
mediaUrls: [],
mentionedIds: [],
quotedMessageId: pollId,
quotedParticipant: '',
quotedRemoteJid: chatId,
quotedText: '',
hasQuotedMessage: !!pollId,
botIds: [],
timestamp: Math.floor(Date.now() / 1000),
};
messageQueue.push(event);
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
}
}
function rememberSentId(id) {
recentlySentIds.remember(id);
@ -294,6 +410,57 @@ async function startSocket() {
}
});
sock.ev.on('messages.update', async (updates) => {
for (const { key, update } of updates || []) {
if (!update?.pollUpdates) continue;
const pollCreationId = key?.id || update.pollUpdates?.[0]?.pollCreationMessageKey?.id;
const pollCreation = messageStore.get(pollCreationId);
let aggregation = [];
let pollUpdates = update.pollUpdates;
try {
if (pollCreation) {
const meId = jidNormalizedUser(sock.user?.id || 'me');
pollUpdates = update.pollUpdates.map(pollUpdate => (
pollUpdateForAggregation({
pollUpdateMessage: pollUpdate,
pollUpdateMessageKey: pollUpdate.pollUpdateMessageKey,
pollCreation,
decryptPollVote,
getKeyAuthor,
meId,
pollCreatorJids: [
jidNormalizedUser(sock.user?.lid || ''),
jidNormalizedUser(sock.user?.id || ''),
getKeyAuthor(pollUpdate.pollCreationMessageKey || key, jidNormalizedUser(sock.user?.lid || '')),
getKeyAuthor(pollUpdate.pollCreationMessageKey || key, jidNormalizedUser(sock.user?.id || '')),
],
voterJids: [
normalizeWhatsAppId(pollUpdate.pollUpdateMessageKey?.participant || ''),
normalizeWhatsAppId(pollUpdate.pollUpdateMessageKey?.remoteJid || key?.remoteJid || ''),
],
}) || pollUpdate
));
aggregation = getAggregateVotesInPollMessage({
message: pollCreation.message,
pollUpdates,
});
}
} catch (err) {
console.warn('[bridge] failed to aggregate poll update:', err.message);
}
const selectedOptions = normalizePollUpdateOptions(aggregation, pollUpdates?.[0]);
logPollUpdateDiagnostic({
sourcePath: 'messages.update',
pollId: pollCreationId,
pollCreation,
pollUpdates,
selectedOptions,
aggregation,
});
enqueuePollUpdateEvent({ key, update: { ...update, pollUpdates }, selectedOptions, aggregation });
}
});
sock.ev.on('messages.upsert', async ({ messages, type }) => {
// In self-chat mode, your own messages commonly arrive as 'append' rather
// than 'notify'. Accept both and filter agent echo-backs below.
@ -405,93 +572,83 @@ async function startSocket() {
}
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;
// Extract message body
let body = '';
let hasMedia = false;
let mediaType = '';
const mediaUrls = [];
if (messageContent.conversation) {
body = messageContent.conversation;
} else if (messageContent.extendedTextMessage?.text) {
body = messageContent.extendedTextMessage.text;
} else if (messageContent.imageMessage) {
body = messageContent.imageMessage.caption || '';
hasMedia = true;
mediaType = 'image';
if (messageContent.pollUpdateMessage) {
const pollUpdateMessage = messageContent.pollUpdateMessage;
const pollKey = pollUpdateMessage.pollCreationMessageKey || {
id: pollUpdateMessage.key?.id || msg.key.id,
remoteJid: chatId,
participant: senderId,
};
const pollCreation = messageStore.get(pollKey.id);
let aggregation = [];
let pollUpdates = [pollUpdateMessage];
try {
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
const mime = messageContent.imageMessage.mimetype || 'image/jpeg';
const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' };
const ext = extMap[mime] || '.jpg';
mkdirSync(IMAGE_CACHE_DIR, { recursive: true });
const filePath = path.join(IMAGE_CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`);
writeFileSync(filePath, buf);
mediaUrls.push(filePath);
if (pollCreation) {
const meId = jidNormalizedUser(sock.user?.id || 'me');
const pollUpdate = pollUpdateForAggregation({
pollUpdateMessage,
pollUpdateMessageKey: msg.key,
pollCreation,
decryptPollVote,
getKeyAuthor,
meId,
pollCreatorJids: [
jidNormalizedUser(sock.user?.lid || ''),
jidNormalizedUser(sock.user?.id || ''),
getKeyAuthor(pollUpdateMessage.pollCreationMessageKey || pollKey, jidNormalizedUser(sock.user?.lid || '')),
getKeyAuthor(pollUpdateMessage.pollCreationMessageKey || pollKey, jidNormalizedUser(sock.user?.id || '')),
],
voterJids: [
normalizeWhatsAppId(msg.key?.participant || ''),
normalizeWhatsAppId(msg.key?.remoteJid || chatId || ''),
normalizeWhatsAppId(senderId || ''),
],
});
if (pollUpdate) pollUpdates = [pollUpdate];
aggregation = getAggregateVotesInPollMessage({
message: pollCreation.message,
pollUpdates,
});
}
} catch (err) {
console.error('[bridge] Failed to download image:', err.message);
}
} else if (messageContent.videoMessage) {
body = messageContent.videoMessage.caption || '';
hasMedia = true;
mediaType = 'video';
try {
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
const mime = messageContent.videoMessage.mimetype || 'video/mp4';
const ext = mime.includes('mp4') ? '.mp4' : '.mkv';
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
const filePath = path.join(DOCUMENT_CACHE_DIR, `vid_${randomBytes(6).toString('hex')}${ext}`);
writeFileSync(filePath, buf);
mediaUrls.push(filePath);
} catch (err) {
console.error('[bridge] Failed to download video:', err.message);
}
} else if (messageContent.audioMessage || messageContent.pttMessage) {
hasMedia = true;
mediaType = messageContent.pttMessage ? 'ptt' : 'audio';
try {
const audioMsg = messageContent.pttMessage || messageContent.audioMessage;
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
const mime = audioMsg.mimetype || 'audio/ogg';
const ext = mime.includes('ogg') ? '.ogg' : mime.includes('mp4') ? '.m4a' : '.ogg';
mkdirSync(AUDIO_CACHE_DIR, { recursive: true });
const filePath = path.join(AUDIO_CACHE_DIR, `aud_${randomBytes(6).toString('hex')}${ext}`);
writeFileSync(filePath, buf);
mediaUrls.push(filePath);
} catch (err) {
console.error('[bridge] Failed to download audio:', err.message);
}
} else if (messageContent.documentMessage) {
body = messageContent.documentMessage.caption || '';
hasMedia = true;
mediaType = 'document';
const fileName = messageContent.documentMessage.fileName || 'document';
try {
const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage });
mkdirSync(DOCUMENT_CACHE_DIR, { recursive: true });
const safeFileName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(DOCUMENT_CACHE_DIR, `doc_${randomBytes(6).toString('hex')}_${safeFileName}`);
writeFileSync(filePath, buf);
mediaUrls.push(filePath);
} catch (err) {
console.error('[bridge] Failed to download document:', err.message);
console.warn('[bridge] failed to aggregate poll upsert:', err.message);
}
const selectedOptions = normalizePollUpdateOptions(aggregation, pollUpdates[0]);
logPollUpdateDiagnostic({
sourcePath: 'messages.upsert',
pollId: pollKey.id,
pollCreation,
pollUpdates,
selectedOptions,
aggregation,
});
enqueuePollUpdateEvent({
key: { ...pollKey, remoteJid: pollKey.remoteJid || chatId, participant: pollKey.participant || senderId },
update: { pollUpdates },
selectedOptions,
aggregation,
});
continue;
}
// For media without caption, use a placeholder so the API message is never empty
if (hasMedia && !body) {
body = `[${mediaType} received]`;
}
const event = await extractBridgeEvent({
msg,
chatId,
senderId,
senderNumber,
botIds,
isGroup,
downloadMedia: async (mediaMsg) => downloadMediaMessage(mediaMsg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }),
cacheDirs: {
image: IMAGE_CACHE_DIR,
document: DOCUMENT_CACHE_DIR,
audio: AUDIO_CACHE_DIR,
},
});
event.fromOwner = fromOwner;
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
if (msg.key.fromMe && ((REPLY_PREFIX && body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) {
if (WHATSAPP_DEBUG) {
try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {}
}
@ -499,7 +656,7 @@ async function startSocket() {
}
// Skip empty messages
if (!body && !hasMedia) {
if (!event.body && !event.hasMedia) {
if (WHATSAPP_DEBUG) {
try {
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
@ -510,34 +667,7 @@ async function startSocket() {
continue;
}
// Resolve LID → phone for the senderId so the gateway can match
// against phone-based allowlists (WHATSAPP_ALLOWED_USERS).
const resolvedSenderId = lidToPhone[senderNumber]
? (lidToPhone[senderNumber] + '@s.whatsapp.net')
: senderId;
const resolvedSenderNumber = lidToPhone[senderNumber] || senderNumber;
const event = {
messageId: msg.key.id,
chatId,
senderId: resolvedSenderId,
senderName: msg.pushName || resolvedSenderNumber,
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || resolvedSenderNumber),
isGroup,
body,
hasMedia,
mediaType,
mediaUrls,
mentionedIds,
quotedMessageId,
quotedParticipant,
quotedRemoteJid,
hasQuotedMessage,
botIds,
timestamp: msg.messageTimestamp,
fromOwner,
};
messageStore.remember(msg);
messageQueue.push(event);
if (messageQueue.length > MAX_QUEUE_SIZE) {
messageQueue.shift();
@ -602,8 +732,14 @@ app.post('/send', async (req, res) => {
const chunks = splitLongMessage(formatOutgoingMessage(message));
const messageIds = [];
for (let i = 0; i < chunks.length; i += 1) {
const sent = await sendWithTimeout(chatId, { text: chunks[i] });
const { content: payload, options } = buildTextSendPayload(chunks[i], {
chatId,
replyTo: i === 0 ? replyTo : undefined,
messageStore,
});
const sent = await sendWithTimeout(chatId, payload, options);
trackSentMessageId(sent);
messageStore.remember(sent);
if (sent?.key?.id) messageIds.push(sent.key.id);
if (chunks.length > 1 && i < chunks.length - 1) {
await sleep(CHUNK_DELAY_MS);
@ -654,25 +790,6 @@ app.post('/edit', async (req, res) => {
}
});
// MIME type map and media type inference for /send-media
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',
};
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';
}
// Send media (image, video, document) natively
app.post('/send-media', async (req, res) => {
if (!sock || connectionState !== 'connected') {
@ -696,10 +813,37 @@ app.post('/send-media', async (req, res) => {
switch (type) {
case 'image':
msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
if (ext === 'gif') {
// WhatsApp's native animated-GIF UX is an MP4 video payload with
// gifPlayback=true. Convert when ffmpeg is available; otherwise fall
// back to a truthful image/gif send instead of mislabeling GIF bytes
// as video/mp4.
let tmpGifMp4 = null;
try {
tmpGifMp4 = path.join(tmpdir(), `hermes_gif_${randomBytes(6).toString('hex')}.mp4`);
execFileSync(
'ffmpeg',
['-y', '-i', filePath, '-movflags', 'faststart', '-pix_fmt', 'yuv420p', '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', tmpGifMp4],
{ timeout: 30000, stdio: 'pipe' }
);
msgPayload = {
video: readFileSync(tmpGifMp4),
caption: caption || undefined,
mimetype: 'video/mp4',
gifPlayback: true,
};
} catch (gifErr) {
console.warn('[bridge] gif conversion failed, sending as image/gif:', gifErr.message);
msgPayload = mediaPayloadForFile({ buffer, filePath, mediaType: type, caption, fileName });
} finally {
try { if (tmpGifMp4 && existsSync(tmpGifMp4)) unlinkSync(tmpGifMp4); } catch (_) {}
}
} else {
msgPayload = mediaPayloadForFile({ buffer, filePath, mediaType: type, caption, fileName });
}
break;
case 'video':
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
msgPayload = mediaPayloadForFile({ buffer, filePath, mediaType: type, caption, fileName });
break;
case 'audio': {
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
@ -712,8 +856,9 @@ app.post('/send-media', async (req, res) => {
if (needsConversion) {
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
try {
execSync(
`ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
execFileSync(
'ffmpeg',
['-y', '-i', filePath, '-ar', '48000', '-ac', '1', '-c:a', 'libopus', tmpPath],
{ timeout: 30000, stdio: 'pipe' }
);
audioBuffer = readFileSync(tmpPath);
@ -731,23 +876,65 @@ app.post('/send-media', async (req, res) => {
}
case 'document':
default:
msgPayload = {
document: buffer,
fileName: fileName || path.basename(filePath),
caption: caption || undefined,
mimetype: MIME_MAP[ext] || 'application/octet-stream',
};
msgPayload = mediaPayloadForFile({ buffer, filePath, mediaType: 'document', caption, fileName });
break;
}
const sent = await sendWithTimeout(chatId, msgPayload);
trackSentMessageId(sent);
messageStore.remember(sent);
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Send poll primitive. Approval UX is intentionally not wired here; gateway
// approvals need text fallback and explicit confirmation semantics above this
// low-level transport helper.
app.post('/send-poll', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, question, options, selectableCount } = req.body;
if (!chatId || !question || !Array.isArray(options)) {
return res.status(400).json({ error: 'chatId, question, and options are required' });
}
try {
const payload = buildPollPayload({ question, options, selectableCount });
const sent = await sendWithTimeout(chatId, payload);
trackSentMessageId(sent);
rememberSentMessage(sent, payload);
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Send native WhatsApp location pin
app.post('/send-location', async (req, res) => {
if (!sock || connectionState !== 'connected') {
return res.status(503).json({ error: 'Not connected to WhatsApp' });
}
const { chatId, latitude, longitude, name, address } = req.body;
if (!chatId || latitude === undefined || longitude === undefined) {
return res.status(400).json({ error: 'chatId, latitude, and longitude are required' });
}
try {
const payload = buildLocationPayload({ latitude, longitude, name, address });
const sent = await sendWithTimeout(chatId, payload);
trackSentMessageId(sent);
messageStore.remember(sent);
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Typing indicator
app.post('/typing', async (req, res) => {
if (!sock || connectionState !== 'connected') {

View file

@ -0,0 +1,327 @@
/**
* 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,
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');
}
console.log('\n✅ All WhatsApp native bridge helper tests passed.');

View file

@ -0,0 +1,525 @@
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}` : ''}]`;
}
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 saveMedia = async ({ mediaMessage, dir, prefix, fallbackExt, fileName: name }) => {
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);
};
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' });
} 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' });
} 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' });
} 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 });
} 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' });
} 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;
}
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,
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 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;
}