mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
- bridge: only enqueue poll_update events for polls Hermes itself created (tracked via recentlySentIds when /send-poll returns) so arbitrary human polls in group chats don't inject agent-visible messages on every vote - update test_already_whatsapp_italic for the new markdown-italic mapping - AUTHOR_MAP entry for @devatnull (PR #58704 salvage)
1025 lines
38 KiB
JavaScript
1025 lines
38 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Hermes Agent WhatsApp Bridge
|
|
*
|
|
* Standalone Node.js process that connects to WhatsApp via Baileys
|
|
* and exposes HTTP endpoints for the Python gateway adapter.
|
|
*
|
|
* Endpoints (matches gateway/platforms/whatsapp.py expectations):
|
|
* GET /messages - Long-poll for new incoming messages
|
|
* 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
|
|
*
|
|
* Usage:
|
|
* node bridge.js --port 3000 --session ~/.hermes/whatsapp/session
|
|
*/
|
|
|
|
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, existsSync, readdirSync, unlinkSync } from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { randomBytes, createHash } from 'crypto';
|
|
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);
|
|
function getArg(name, defaultVal) {
|
|
const idx = args.indexOf(`--${name}`);
|
|
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultVal;
|
|
}
|
|
|
|
const WHATSAPP_DEBUG =
|
|
typeof process !== 'undefined' &&
|
|
process.env &&
|
|
typeof process.env.WHATSAPP_DEBUG === 'string' &&
|
|
['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_DEBUG.toLowerCase());
|
|
|
|
// Opt-in: when true (and WHATSAPP_MODE === 'bot'), fromMe inbound messages
|
|
// that are NOT echoes of our own /send or /send-media calls are forwarded
|
|
// to the Python adapter with `fromOwner: true`. This lets plugins detect
|
|
// "owner just typed in this customer chat" — needed for handover / sliding
|
|
// TTL flows. Default OFF: existing deployments see no behavior change.
|
|
//
|
|
// Heuristic limitation: we distinguish bot-API-sent from owner-typed by
|
|
// looking up `key.id` in `recentlySentIds` (populated when /send returns).
|
|
// On bridge restart that set is empty, so a few in-flight bot replies may
|
|
// briefly look like owner-typed until they age out. Acceptable; we don't
|
|
// persist the set.
|
|
const FORWARD_OWNER_MESSAGES =
|
|
typeof process !== 'undefined' &&
|
|
process.env &&
|
|
typeof process.env.WHATSAPP_FORWARD_OWNER_MESSAGES === 'string' &&
|
|
['1', 'true', 'yes', 'on'].includes(process.env.WHATSAPP_FORWARD_OWNER_MESSAGES.toLowerCase());
|
|
|
|
const PORT = parseInt(getArg('port', '3000'), 10);
|
|
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
|
|
// Cache directories: the Python gateway passes the profile-aware paths via
|
|
// env (HERMES_HOME-aware, new cache/ layout). Fall back to the legacy
|
|
// hardcoded locations for bridges launched outside the gateway.
|
|
const IMAGE_CACHE_DIR = process.env.HERMES_IMAGE_CACHE_DIR
|
|
|| path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
|
const DOCUMENT_CACHE_DIR = process.env.HERMES_DOCUMENT_CACHE_DIR
|
|
|| path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
|
const AUDIO_CACHE_DIR = process.env.HERMES_AUDIO_CACHE_DIR
|
|
|| path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
|
|
|
// Self-hash of this script file. Reported in /health so the Python gateway
|
|
// can detect a running bridge that predates the current bridge.js and
|
|
// restart it instead of silently reusing stale code (stale-bridge trap:
|
|
// `hermes update` updates bridge.js on disk but a long-lived bridge process
|
|
// keeps serving the old behavior forever).
|
|
let SCRIPT_HASH = '';
|
|
try {
|
|
SCRIPT_HASH = createHash('sha256')
|
|
.update(readFileSync(fileURLToPath(import.meta.url)))
|
|
.digest('hex')
|
|
.slice(0, 16);
|
|
} catch {}
|
|
const PAIR_ONLY = args.includes('--pair-only');
|
|
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
|
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
|
const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n';
|
|
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
|
|
? DEFAULT_REPLY_PREFIX
|
|
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
|
|
const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10);
|
|
const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10);
|
|
// Per-call timeout for sock.sendMessage(). Baileys occasionally hangs forever
|
|
// when uploading media to WhatsApp servers (and, less often, on text sends),
|
|
// which pins the bridge's HTTP handler until the upstream aiohttp timeout
|
|
// fires. Fail fast instead so the gateway can surface a real error and retry.
|
|
const SEND_TIMEOUT_MS = parseInt(process.env.WHATSAPP_SEND_TIMEOUT_MS || '60000', 10);
|
|
|
|
// --- Send queue: serialise all sock.sendMessage() calls across concurrent
|
|
// HTTP handlers so a single Baileys socket never has overlapping sends.
|
|
// Overlapping sends are the root cause of cross-chat contamination
|
|
// (#33360) — the WhatsApp protocol-level routing can misdeliver when
|
|
// two sendMessage() Promises race on the same socket. ---
|
|
let _sendQueue = Promise.resolve();
|
|
|
|
function enqueueSend(fn) {
|
|
const task = _sendQueue.then(() => fn(), () => fn());
|
|
_sendQueue = task.catch(() => {});
|
|
return task;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function sendWithTimeout(chatId, payload, options = {}, timeoutMs = SEND_TIMEOUT_MS) {
|
|
let timer;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(new Error(`sendMessage timed out after ${timeoutMs / 1000}s`)),
|
|
timeoutMs,
|
|
);
|
|
});
|
|
return enqueueSend(() =>
|
|
Promise.race([sock.sendMessage(chatId, payload, options), timeoutPromise])
|
|
.finally(() => clearTimeout(timer))
|
|
);
|
|
}
|
|
|
|
function formatOutgoingMessage(message) {
|
|
// In bot mode, messages come from a different number so the prefix is
|
|
// redundant — the sender identity is already clear. Only prepend in
|
|
// self-chat mode where bot and user share the same number.
|
|
if (WHATSAPP_MODE !== 'self-chat') return message;
|
|
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
|
|
}
|
|
|
|
function splitLongMessage(message, maxLength = MAX_MESSAGE_LENGTH) {
|
|
const text = String(message || '');
|
|
if (!text) return [];
|
|
if (!Number.isFinite(maxLength) || maxLength < 1 || text.length <= maxLength) {
|
|
return [text];
|
|
}
|
|
|
|
const chunks = [];
|
|
let remaining = text;
|
|
while (remaining.length > maxLength) {
|
|
let splitAt = remaining.lastIndexOf('\n', maxLength);
|
|
if (splitAt < Math.floor(maxLength / 2)) {
|
|
splitAt = remaining.lastIndexOf(' ', maxLength);
|
|
}
|
|
if (splitAt < 1) splitAt = maxLength;
|
|
|
|
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
remaining = remaining.slice(splitAt).trimStart();
|
|
}
|
|
if (remaining) chunks.push(remaining);
|
|
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);
|
|
}
|
|
|
|
function normalizeWhatsAppId(value) {
|
|
if (!value) return '';
|
|
return String(value).replace(':', '@');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 {};
|
|
}
|
|
|
|
mkdirSync(SESSION_DIR, { recursive: true });
|
|
|
|
// Build LID → phone reverse map from session files (lid-mapping-{phone}.json)
|
|
function buildLidMap() {
|
|
const map = {};
|
|
try {
|
|
for (const f of readdirSync(SESSION_DIR)) {
|
|
const m = f.match(/^lid-mapping-(\d+)\.json$/);
|
|
if (!m) continue;
|
|
const phone = m[1];
|
|
const lid = JSON.parse(readFileSync(path.join(SESSION_DIR, f), 'utf8'));
|
|
if (lid) map[String(lid)] = phone;
|
|
}
|
|
} catch {}
|
|
return map;
|
|
}
|
|
let lidToPhone = buildLidMap();
|
|
|
|
const logger = pino({ level: 'warn' });
|
|
|
|
// Message queue for polling
|
|
const messageQueue = [];
|
|
const MAX_QUEUE_SIZE = 100;
|
|
|
|
// Track recently sent message IDs. Two purposes:
|
|
// 1. Prevent echo-back loops with media in self-chat mode.
|
|
// 2. (When WHATSAPP_FORWARD_OWNER_MESSAGES=true) distinguish our own
|
|
// bot-API outbound messages from owner-typed messages on the linked
|
|
// device so we can forward only the latter.
|
|
// 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
|
|
|| '';
|
|
// Only surface votes on polls Hermes itself created (tracked when
|
|
// /send-poll returns). Arbitrary human polls in a group chat must not
|
|
// inject agent-visible messages on every vote.
|
|
if (!pollId || !recentlySentIds.has(pollId)) {
|
|
if (WHATSAPP_DEBUG) {
|
|
try { console.log(JSON.stringify({ event: 'ignored', reason: 'foreign_poll_update', pollId })); } catch {}
|
|
}
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
|
|
let sock = null;
|
|
let connectionState = 'disconnected';
|
|
|
|
async function startSocket() {
|
|
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
|
|
const { version } = await fetchLatestBaileysVersion();
|
|
|
|
sock = makeWASocket({
|
|
version,
|
|
auth: state,
|
|
logger,
|
|
printQRInTerminal: false,
|
|
browser: ['Hermes Agent', 'Chrome', '120.0'],
|
|
syncFullHistory: false,
|
|
markOnlineOnConnect: false,
|
|
// Required for Baileys 7.x: without this, incoming messages that need
|
|
// E2EE session re-establishment are silently dropped (msg.message === null)
|
|
getMessage: async (key) => {
|
|
// We don't maintain a message store, so return a placeholder.
|
|
// This is enough for Baileys to complete the retry handshake.
|
|
return { conversation: '' };
|
|
},
|
|
});
|
|
|
|
sock.ev.on('creds.update', () => { saveCreds(); lidToPhone = buildLidMap(); });
|
|
|
|
sock.ev.on('connection.update', (update) => {
|
|
const { connection, lastDisconnect, qr } = update;
|
|
|
|
if (qr) {
|
|
console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n');
|
|
qrcode.generate(qr, { small: true });
|
|
console.log('\nWaiting for scan...\n');
|
|
}
|
|
|
|
if (connection === 'close') {
|
|
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
|
|
connectionState = 'disconnected';
|
|
|
|
if (reason === DisconnectReason.loggedOut) {
|
|
console.log('❌ Logged out. Delete session and restart to re-authenticate.');
|
|
process.exit(1);
|
|
} else {
|
|
// 515 = restart requested (common after pairing). Always reconnect.
|
|
if (reason === 515) {
|
|
console.log('↻ WhatsApp requested restart (code 515). Reconnecting...');
|
|
} else {
|
|
console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`);
|
|
}
|
|
setTimeout(startSocket, reason === 515 ? 1000 : 3000);
|
|
}
|
|
} else if (connection === 'open') {
|
|
connectionState = 'connected';
|
|
console.log('✅ WhatsApp connected!');
|
|
if (PAIR_ONLY) {
|
|
console.log('✅ Pairing complete. Credentials saved.');
|
|
// Give Baileys a moment to flush creds, then exit cleanly
|
|
setTimeout(() => process.exit(0), 2000);
|
|
}
|
|
}
|
|
});
|
|
|
|
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.
|
|
if (type !== 'notify' && type !== 'append') return;
|
|
|
|
const botIds = Array.from(new Set([
|
|
normalizeWhatsAppId(sock.user?.id),
|
|
normalizeWhatsAppId(sock.user?.lid),
|
|
].filter(Boolean)));
|
|
|
|
for (const msg of messages) {
|
|
if (!msg.message) continue;
|
|
|
|
const chatId = msg.key.remoteJid;
|
|
if (WHATSAPP_DEBUG) {
|
|
try {
|
|
console.log(JSON.stringify({
|
|
event: 'upsert', type,
|
|
fromMe: !!msg.key.fromMe, chatId,
|
|
senderId: msg.key.participant || chatId,
|
|
messageKeys: Object.keys(msg.message || {}),
|
|
}));
|
|
} catch {}
|
|
}
|
|
const senderId = msg.key.participant || chatId;
|
|
const isGroup = chatId.endsWith('@g.us');
|
|
const senderNumber = senderId.replace(/@.*/, '');
|
|
|
|
// Handle fromMe messages based on mode
|
|
let fromOwner = false;
|
|
if (msg.key.fromMe) {
|
|
if (isGroup || chatId.includes('status')) continue;
|
|
|
|
if (WHATSAPP_MODE === 'bot') {
|
|
// Bot mode: separate bot number. fromMe inbound is either
|
|
// (a) an echo of our own /send (recentlySentIds will catch it), or
|
|
// (b) a message the owner typed from their own phone using the
|
|
// linked-device session.
|
|
//
|
|
// We always drop (a). We drop (b) too unless the operator opts in
|
|
// via WHATSAPP_FORWARD_OWNER_MESSAGES so existing deployments see
|
|
// no behavior change. When opted in, we still gate on the
|
|
// customer chatId allowlist — without that gate, any contact
|
|
// the owner replied to would leak into Hermes and trigger
|
|
// implicit handover. See `owner_message_gate.js`.
|
|
const decision = classifyOwnerMessageGate({
|
|
fromMe: true,
|
|
fromOwnerEnabled: FORWARD_OWNER_MESSAGES,
|
|
recentlySent: recentlySentIds,
|
|
allowlistMatches: (id) => matchesAllowedUser(id, ALLOWED_USERS, SESSION_DIR),
|
|
messageId: msg.key.id,
|
|
chatId,
|
|
});
|
|
if (decision.action === 'drop_echo') continue;
|
|
if (decision.action === 'drop_disabled') continue;
|
|
if (decision.action === 'drop_allowlist') {
|
|
try {
|
|
console.log(JSON.stringify({
|
|
event: 'ignored',
|
|
reason: 'allowlist_mismatch_owner_chat',
|
|
chatId,
|
|
senderId,
|
|
}));
|
|
} catch {}
|
|
continue;
|
|
}
|
|
fromOwner = true;
|
|
} else {
|
|
// Self-chat mode: only allow messages in the user's own self-chat.
|
|
// WhatsApp now uses LID (Linked Identity Device) format: 67427329167522@lid
|
|
// AND classic format: 34652029134@s.whatsapp.net
|
|
// sock.user has both: { id: "number:10@s.whatsapp.net", lid: "lid_number:10@lid" }
|
|
const myNumber = (sock.user?.id || '').replace(/:.*@/, '@').replace(/@.*/, '');
|
|
const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, '');
|
|
const chatNumber = chatId.replace(/@.*/, '');
|
|
const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
|
|
if (!isSelfChat) continue;
|
|
}
|
|
}
|
|
|
|
// Handle !fromMe messages (from other people) based on mode.
|
|
// Self-chat mode only responds to the user's own messages to
|
|
// themselves — stranger DMs / group pings must never reach the
|
|
// Python gateway, otherwise a pairing-code reply fires in response
|
|
// to arbitrary incoming messages (#8389).
|
|
if (!msg.key.fromMe) {
|
|
if (WHATSAPP_MODE === 'self-chat') {
|
|
try {
|
|
console.log(JSON.stringify({
|
|
event: 'ignored',
|
|
reason: 'self_chat_mode_rejects_non_self',
|
|
chatId,
|
|
senderId,
|
|
}));
|
|
} catch {}
|
|
continue;
|
|
}
|
|
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
|
|
try {
|
|
console.log(JSON.stringify({
|
|
event: 'ignored',
|
|
reason: 'allowlist_mismatch',
|
|
chatId,
|
|
senderId,
|
|
}));
|
|
} catch {}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const messageContent = getMessageContent(msg);
|
|
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 {
|
|
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.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;
|
|
}
|
|
|
|
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 && 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 {}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip empty messages
|
|
if (!event.body && !event.hasMedia) {
|
|
if (WHATSAPP_DEBUG) {
|
|
try {
|
|
console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) }));
|
|
} catch (err) {
|
|
console.error('Failed to log empty message event:', err);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
messageStore.remember(msg);
|
|
messageQueue.push(event);
|
|
if (messageQueue.length > MAX_QUEUE_SIZE) {
|
|
messageQueue.shift();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// HTTP server
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Host-header validation — defends against DNS rebinding.
|
|
// The bridge binds loopback-only (127.0.0.1) but a victim browser on
|
|
// the same machine could be tricked into fetching from an attacker
|
|
// hostname that TTL-flips to 127.0.0.1. Reject any request whose Host
|
|
// header doesn't resolve to a loopback alias.
|
|
// See GHSA-ppp5-vxwm-4cf7.
|
|
const _ACCEPTED_HOST_VALUES = new Set([
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'[::1]',
|
|
'::1',
|
|
]);
|
|
|
|
app.use((req, res, next) => {
|
|
const raw = (req.headers.host || '').trim();
|
|
if (!raw) {
|
|
return res.status(400).json({ error: 'Missing Host header' });
|
|
}
|
|
// Strip port suffix: "localhost:3000" → "localhost"
|
|
const hostOnly = (raw.includes(':')
|
|
? raw.substring(0, raw.lastIndexOf(':'))
|
|
: raw
|
|
).replace(/^\[|\]$/g, '').toLowerCase();
|
|
if (!_ACCEPTED_HOST_VALUES.has(hostOnly)) {
|
|
return res.status(400).json({
|
|
error: 'Invalid Host header. Bridge accepts loopback hosts only.',
|
|
});
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Poll for new messages (long-poll style)
|
|
app.get('/messages', (req, res) => {
|
|
const msgs = messageQueue.splice(0, messageQueue.length);
|
|
res.json(msgs);
|
|
});
|
|
|
|
// Send a message
|
|
app.post('/send', async (req, res) => {
|
|
if (!sock || connectionState !== 'connected') {
|
|
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
|
}
|
|
|
|
const { chatId, message, replyTo } = req.body;
|
|
if (!chatId || !message) {
|
|
return res.status(400).json({ error: 'chatId and message are required' });
|
|
}
|
|
|
|
try {
|
|
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
|
const messageIds = [];
|
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
messageId: messageIds[messageIds.length - 1],
|
|
messageIds,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Edit a previously sent message
|
|
app.post('/edit', async (req, res) => {
|
|
if (!sock || connectionState !== 'connected') {
|
|
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
|
}
|
|
|
|
const { chatId, messageId, message } = req.body;
|
|
if (!chatId || !messageId || !message) {
|
|
return res.status(400).json({ error: 'chatId, messageId, and message are required' });
|
|
}
|
|
|
|
try {
|
|
const key = { id: messageId, fromMe: true, remoteJid: chatId };
|
|
const chunks = splitLongMessage(formatOutgoingMessage(message));
|
|
const messageIds = [];
|
|
|
|
await sendWithTimeout(chatId, { text: chunks[0], edit: key });
|
|
if (chunks.length > 1) {
|
|
for (let i = 1; i < chunks.length; i += 1) {
|
|
const sent = await sendWithTimeout(chatId, { text: chunks[i] });
|
|
trackSentMessageId(sent);
|
|
if (sent?.key?.id) messageIds.push(sent.key.id);
|
|
if (i < chunks.length - 1) {
|
|
await sleep(CHUNK_DELAY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, messageIds });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Send media (image, video, document) natively
|
|
app.post('/send-media', async (req, res) => {
|
|
if (!sock || connectionState !== 'connected') {
|
|
return res.status(503).json({ error: 'Not connected to WhatsApp' });
|
|
}
|
|
|
|
const { chatId, filePath, mediaType, caption, fileName } = req.body;
|
|
if (!chatId || !filePath) {
|
|
return res.status(400).json({ error: 'chatId and filePath are required' });
|
|
}
|
|
|
|
try {
|
|
if (!existsSync(filePath)) {
|
|
return res.status(404).json({ error: `File not found: ${filePath}` });
|
|
}
|
|
|
|
const buffer = readFileSync(filePath);
|
|
const ext = filePath.toLowerCase().split('.').pop();
|
|
const type = mediaType || inferMediaType(ext);
|
|
let msgPayload;
|
|
|
|
switch (type) {
|
|
case 'image':
|
|
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 = mediaPayloadForFile({ buffer, filePath, mediaType: type, caption, fileName });
|
|
break;
|
|
case 'audio': {
|
|
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
|
|
// If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
|
|
// silently convert to ogg/opus via ffmpeg so ptt is always honoured.
|
|
let audioBuffer = buffer;
|
|
let audioExt = ext;
|
|
const needsConversion = !['ogg', 'opus'].includes(ext);
|
|
let tmpPath = null;
|
|
if (needsConversion) {
|
|
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
|
|
try {
|
|
execFileSync(
|
|
'ffmpeg',
|
|
['-y', '-i', filePath, '-ar', '48000', '-ac', '1', '-c:a', 'libopus', tmpPath],
|
|
{ timeout: 30000, stdio: 'pipe' }
|
|
);
|
|
audioBuffer = readFileSync(tmpPath);
|
|
audioExt = 'ogg';
|
|
} catch (convErr) {
|
|
// ffmpeg not available or conversion failed — fall back to original format
|
|
console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
|
|
} finally {
|
|
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
|
|
}
|
|
}
|
|
const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
|
msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
|
|
break;
|
|
}
|
|
case 'document':
|
|
default:
|
|
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') {
|
|
return res.status(503).json({ error: 'Not connected' });
|
|
}
|
|
|
|
const { chatId } = req.body;
|
|
if (!chatId) return res.status(400).json({ error: 'chatId required' });
|
|
|
|
try {
|
|
await sock.sendPresenceUpdate('composing', chatId);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.json({ success: false });
|
|
}
|
|
});
|
|
|
|
// Chat info
|
|
app.get('/chat/:id', async (req, res) => {
|
|
const chatId = req.params.id;
|
|
const isGroup = chatId.endsWith('@g.us');
|
|
|
|
if (isGroup && sock) {
|
|
try {
|
|
const metadata = await sock.groupMetadata(chatId);
|
|
return res.json({
|
|
name: metadata.subject,
|
|
isGroup: true,
|
|
participants: metadata.participants.map(p => p.id),
|
|
});
|
|
} catch {
|
|
// Fall through to default
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
name: chatId.replace(/@.*/, ''),
|
|
isGroup,
|
|
participants: [],
|
|
});
|
|
});
|
|
|
|
// Health check
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: connectionState,
|
|
queueLength: messageQueue.length,
|
|
uptime: process.uptime(),
|
|
scriptHash: SCRIPT_HASH,
|
|
});
|
|
});
|
|
|
|
// Start
|
|
if (PAIR_ONLY) {
|
|
// Pair-only mode: just connect, show QR, save creds, exit. No HTTP server.
|
|
console.log('📱 WhatsApp pairing mode');
|
|
console.log(`📁 Session: ${SESSION_DIR}`);
|
|
console.log();
|
|
startSocket();
|
|
} else {
|
|
app.listen(PORT, '127.0.0.1', () => {
|
|
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
|
console.log(`📁 Session stored in: ${SESSION_DIR}`);
|
|
if (ALLOWED_USERS.size > 0) {
|
|
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
|
|
} else if (WHATSAPP_MODE === 'self-chat') {
|
|
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
|
|
} else {
|
|
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
|
|
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
|
|
console.log(` or WHATSAPP_ALLOWED_USERS=* for an explicit open bot.`);
|
|
}
|
|
if (WHATSAPP_MODE === 'bot' && FORWARD_OWNER_MESSAGES) {
|
|
console.log(`👤 WHATSAPP_FORWARD_OWNER_MESSAGES=true — owner-typed messages will be forwarded with fromOwner:true`);
|
|
}
|
|
console.log();
|
|
startSocket();
|
|
});
|
|
}
|