* fix: prevent infinite 400 failure loop on context overflow (#1630) When a gateway session exceeds the model's context window, Anthropic may return a generic 400 invalid_request_error with just 'Error' as the message. This bypassed the phrase-based context-length detection, causing the agent to treat it as a non-retryable client error. Worse, the failed user message was still persisted to the transcript, making the session even larger on each attempt — creating an infinite loop. Three-layer fix: 1. run_agent.py — Fallback heuristic: when a 400 error has a very short generic message AND the session is large (>40% of context or >80 messages), treat it as a probable context overflow and trigger compression instead of aborting. 2. run_agent.py + gateway/run.py — Don't persist failed messages: when the agent returns failed=True before generating any response, skip writing the user's message to the transcript/DB. This prevents the session from growing on each failure. 3. gateway/run.py — Smarter error messages: detect context-overflow failures and suggest /compact or /reset specifically, instead of a generic 'try again' that will fail identically. * fix(skills): detect prompt injection patterns and block cache file reads Adds two security layers to prevent prompt injection via skills hub cache files (#1558): 1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory (index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json was the original injection vector — untrusted skill descriptions in the catalog contained adversarial text that the model executed. 2. skill_view: warns when skills are loaded from outside the trusted ~/.hermes/skills/ directory, and detects common injection patterns in skill content ("ignore previous instructions", "<system>", etc.). Cherry-picked from PR #1562 by ygd58. * fix(tools): chunk long messages in send_message_tool before dispatch (#1552) Long messages sent via send_message tool or cron delivery silently failed when exceeding platform limits. Gateway adapters handle this via truncate_message(), but the standalone senders in send_message_tool bypassed that entirely. - Apply truncate_message() chunking in _send_to_platform() before dispatching to individual platform senders - Remove naive message[i:i+2000] character split in _send_discord() in favor of centralized smart splitting - Attach media files to last chunk only for Telegram - Add regression tests for chunking and media placement Cherry-picked from PR #1557 by llbn. * fix(approval): show full command in dangerous command approval (#1553) Previously the command was truncated to 80 chars in CLI (with a [v]iew full option), 500 chars in Discord embeds, and missing entirely in Telegram/Slack approval messages. Now the full command is always displayed everywhere: - CLI: removed 80-char truncation and [v]iew full menu option - Gateway (TG/Slack): approval_required message includes full command in a code block - Discord: embed shows full command up to 4096-char limit - Windows: skip SIGALRM-based test timeout (Unix-only) - Updated tests: replaced view-flow tests with direct approval tests Cherry-picked from PR #1566 by crazywriter1. * fix(cli): flush stdout during agent loop to prevent macOS display freeze (#1624) The interrupt polling loop in chat() waited on the queue without invalidating the prompt_toolkit renderer. On macOS, the StdoutProxy buffer only flushed on input events, causing the CLI to appear frozen during tool execution until the user typed a key. Fix: call _invalidate() on each queue timeout (every ~100ms, throttled to 150ms) to force the renderer to flush buffered agent output. * fix(claw): warn when API keys are skipped during OpenClaw migration (#1580) When --migrate-secrets is not passed (the default), API keys like OPENROUTER_API_KEY are silently skipped with no warning. Users don't realize their keys weren't migrated until the agent fails to connect. Add a post-migration warning with actionable instructions: either re-run with --migrate-secrets or add the key manually via hermes config set. Cherry-picked from PR #1593 by ygd58. * fix(security): block sandbox backend creds from subprocess env (#1264) Add Modal and Daytona sandbox credentials to the subprocess env blocklist so they're not leaked to agent terminal sessions via printenv/env. Cherry-picked from PR #1571 by ygd58. * fix(gateway): cap interrupt recursion depth to prevent resource exhaustion (#816) When a user sends multiple messages while the agent keeps failing, _run_agent() calls itself recursively with no depth limit. This can exhaust stack/memory if the agent is in a failure loop. Add _MAX_INTERRUPT_DEPTH = 3. When exceeded, the pending message is logged and the current result is returned instead of recursing deeper. The log handler duplication bug described in #816 was already fixed separately (AIAgent.__init__ deduplicates handlers). * fix(gateway): /model shows active fallback model instead of config default (#1615) When the agent falls back to a different model (e.g. due to rate limiting), /model still showed the config default. Now tracks the effective model/provider after each agent run and displays it. Cleared when the primary model succeeds again or the user explicitly switches via /model. Cherry-picked from PR #1616 by MaxKerkula. Added hasattr guard for test compatibility. * feat(gateway): inject reply-to message context for out-of-session replies (#1594) When a user replies to a Telegram message, check if the quoted text exists in the current session transcript. If missing (from cron jobs, background tasks, or old sessions), prepend [Replying to: "..."] to the message so the agent has context about what's being referenced. - Add reply_to_text field to MessageEvent (base.py) - Populate from Telegram's reply_to_message (text or caption) - Inject context in _handle_message when not found in history Based on PR #1596 by anpicasso (cherry-picked reply-to feature only, excluded unrelated /server command and background delegation changes). * fix: recognize Claude Code OAuth credentials in startup gate (#1455) The _has_any_provider_configured() startup check didn't look for Claude Code OAuth credentials (~/.claude/.credentials.json). Users with only Claude Code auth got the setup wizard instead of starting. Cherry-picked from PR #1455 by kshitijk4poor. * perf: use ripgrep for file search (200x faster than find) search_files(target='files') now uses rg --files -g instead of find. Ripgrep respects .gitignore, excludes hidden dirs by default, and has parallel directory traversal — ~200x faster on wide trees (0.14s vs 34s benchmarked on 164-repo tree). Falls back to find when rg is unavailable, preserving hidden-dir exclusion and BSD find compatibility. Salvaged from PR #1464 by @light-merlin-dark (Merlin) — adapted to preserve hidden-dir exclusion added since the original PR. * refactor(tts): replace NeuTTS optional skill with built-in provider + setup flow Remove the optional skill (redundant now that NeuTTS is a built-in TTS provider). Replace neutts_cli dependency with a standalone synthesis helper (tools/neutts_synth.py) that calls the neutts Python API directly in a subprocess. Add TTS provider selection to hermes setup: - 'hermes setup' now prompts for TTS provider after model selection - 'hermes setup tts' available as standalone section - Selecting NeuTTS checks for deps and offers to install: espeak-ng (system) + neutts[all] (pip) - ElevenLabs/OpenAI selections prompt for API keys - Tool status display shows NeuTTS install state Changes: - Remove optional-skills/mlops/models/neutts/ (skill + CLI scaffold) - Add tools/neutts_synth.py (standalone synthesis subprocess helper) - Move jo.wav/jo.txt to tools/neutts_samples/ (bundled default voice) - Refactor _generate_neutts() — uses neutts API via subprocess, no neutts_cli dependency, config-driven ref_audio/ref_text/model/device - Add TTS setup to hermes_cli/setup.py (SETUP_SECTIONS, tool status) - Update config.py defaults (ref_audio, ref_text, model, device) * fix(docker): add explicit env allowlist for container credentials (#1436) Docker terminal sessions are secret-dark by default. This adds terminal.docker_forward_env as an explicit allowlist for env vars that may be forwarded into Docker containers. Values resolve from the current shell first, then fall back to ~/.hermes/.env. Only variables the user explicitly lists are forwarded — nothing is auto-exposed. Cherry-picked from PR #1449 by @teknium1, conflict-resolved onto current main. Fixes #1436 Supersedes #1439 * fix: email send_typing metadata param + ☤ Hermes staff symbol - email.py: add missing metadata parameter to send_typing() to match BasePlatformAdapter signature (PR #1431 by @ItsChoudhry) - README.md: ⚕ → ☤ — the caduceus is Hermes's staff, not the medical Staff of Asclepius (PR #1420 by @rianczerwinski) * fix(whatsapp): support LID format in self-chat mode (#1556) WhatsApp now uses LID (Linked Identity Device) format alongside classic @s.whatsapp.net. Self-chat detection checked only the classic format, breaking self-chat mode for users on newer WhatsApp versions. - Check both sock.user.id and sock.user.lid for self-chat detection - Accept 'append' message type in addition to 'notify' (self-chat messages arrive as 'append') - Track sent message IDs to prevent echo-back loops with media - Add WHATSAPP_DEBUG env var for troubleshooting Based on PR #1556 by jcorrego (manually applied due to cherry-pick conflicts). --------- Co-authored-by: buray <ygd58@users.noreply.github.com> Co-authored-by: lbn <llbn@users.noreply.github.com> Co-authored-by: crazywriter1 <53251494+crazywriter1@users.noreply.github.com> Co-authored-by: Max K <MaxKerkula@users.noreply.github.com> Co-authored-by: Angello Picasso <angello.picasso@devsu.com> Co-authored-by: kshitij <kshitijk4poor@users.noreply.github.com> Co-authored-by: jcorrego <jcorrego@users.noreply.github.com>
444 lines
15 KiB
JavaScript
444 lines
15 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 /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 } from '@whiskeysockets/baileys';
|
|
import express from 'express';
|
|
import { Boom } from '@hapi/boom';
|
|
import pino from 'pino';
|
|
import path from 'path';
|
|
import { mkdirSync, readFileSync, existsSync } from 'fs';
|
|
import qrcode from 'qrcode-terminal';
|
|
|
|
// 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());
|
|
|
|
const PORT = parseInt(getArg('port', '3000'), 10);
|
|
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
|
|
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 = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
|
|
mkdirSync(SESSION_DIR, { recursive: true });
|
|
|
|
const logger = pino({ level: 'warn' });
|
|
|
|
// Message queue for polling
|
|
const messageQueue = [];
|
|
const MAX_QUEUE_SIZE = 100;
|
|
|
|
// Track recently sent message IDs to prevent echo-back loops with media
|
|
const recentlySentIds = new Set();
|
|
const MAX_RECENT_IDS = 50;
|
|
|
|
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,
|
|
});
|
|
|
|
sock.ev.on('creds.update', saveCreds);
|
|
|
|
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.upsert', ({ 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;
|
|
|
|
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
|
|
if (msg.key.fromMe) {
|
|
if (isGroup || chatId.includes('status')) continue;
|
|
|
|
if (WHATSAPP_MODE === 'bot') {
|
|
// Bot mode: separate number. ALL fromMe are echo-backs of our own replies — skip.
|
|
continue;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Check allowlist for messages from others
|
|
if (!msg.key.fromMe && ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(senderNumber)) {
|
|
continue;
|
|
}
|
|
|
|
// Extract message body
|
|
let body = '';
|
|
let hasMedia = false;
|
|
let mediaType = '';
|
|
const mediaUrls = [];
|
|
|
|
if (msg.message.conversation) {
|
|
body = msg.message.conversation;
|
|
} else if (msg.message.extendedTextMessage?.text) {
|
|
body = msg.message.extendedTextMessage.text;
|
|
} else if (msg.message.imageMessage) {
|
|
body = msg.message.imageMessage.caption || '';
|
|
hasMedia = true;
|
|
mediaType = 'image';
|
|
} else if (msg.message.videoMessage) {
|
|
body = msg.message.videoMessage.caption || '';
|
|
hasMedia = true;
|
|
mediaType = 'video';
|
|
} else if (msg.message.audioMessage || msg.message.pttMessage) {
|
|
hasMedia = true;
|
|
mediaType = msg.message.pttMessage ? 'ptt' : 'audio';
|
|
} else if (msg.message.documentMessage) {
|
|
body = msg.message.documentMessage.caption || msg.message.documentMessage.fileName || '';
|
|
hasMedia = true;
|
|
mediaType = 'document';
|
|
}
|
|
|
|
// Ignore Hermes' own reply messages in self-chat mode to avoid loops.
|
|
if (msg.key.fromMe && (body.startsWith('⚕ *Hermes Agent*') || 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 (!body && !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;
|
|
}
|
|
|
|
const event = {
|
|
messageId: msg.key.id,
|
|
chatId,
|
|
senderId,
|
|
senderName: msg.pushName || senderNumber,
|
|
chatName: isGroup ? (chatId.split('@')[0]) : (msg.pushName || senderNumber),
|
|
isGroup,
|
|
body,
|
|
hasMedia,
|
|
mediaType,
|
|
mediaUrls,
|
|
timestamp: msg.messageTimestamp,
|
|
};
|
|
|
|
messageQueue.push(event);
|
|
if (messageQueue.length > MAX_QUEUE_SIZE) {
|
|
messageQueue.shift();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// HTTP server
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// 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 {
|
|
// Prefix responses so the user can distinguish agent replies from their
|
|
// own messages (especially in self-chat / "Message Yourself").
|
|
const prefixed = `⚕ *Hermes Agent*\n────────────\n${message}`;
|
|
const sent = await sock.sendMessage(chatId, { text: prefixed });
|
|
|
|
// Track sent message ID to prevent echo-back loops
|
|
if (sent?.key?.id) {
|
|
recentlySentIds.add(sent.key.id);
|
|
if (recentlySentIds.size > MAX_RECENT_IDS) {
|
|
recentlySentIds.delete(recentlySentIds.values().next().value);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, messageId: sent?.key?.id });
|
|
} 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 prefixed = `⚕ *Hermes Agent*\n────────────\n${message}`;
|
|
const key = { id: messageId, fromMe: true, remoteJid: chatId };
|
|
await sock.sendMessage(chatId, { text: prefixed, edit: key });
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// 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') {
|
|
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':
|
|
msgPayload = { image: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'image/jpeg' };
|
|
break;
|
|
case 'video':
|
|
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
|
|
break;
|
|
case 'audio': {
|
|
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
|
|
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
|
|
break;
|
|
}
|
|
case 'document':
|
|
default:
|
|
msgPayload = {
|
|
document: buffer,
|
|
fileName: fileName || path.basename(filePath),
|
|
caption: caption || undefined,
|
|
mimetype: MIME_MAP[ext] || 'application/octet-stream',
|
|
};
|
|
break;
|
|
}
|
|
|
|
const sent = await sock.sendMessage(chatId, msgPayload);
|
|
|
|
// Track sent message ID to prevent echo-back loops
|
|
if (sent?.key?.id) {
|
|
recentlySentIds.add(sent.key.id);
|
|
if (recentlySentIds.size > MAX_RECENT_IDS) {
|
|
recentlySentIds.delete(recentlySentIds.values().next().value);
|
|
}
|
|
}
|
|
|
|
res.json({ success: true, messageId: sent?.key?.id });
|
|
} catch (err) {
|
|
res.status(500).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(),
|
|
});
|
|
});
|
|
|
|
// 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, () => {
|
|
console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`);
|
|
console.log(`📁 Session stored in: ${SESSION_DIR}`);
|
|
if (ALLOWED_USERS.length > 0) {
|
|
console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`);
|
|
} else {
|
|
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
|
|
}
|
|
console.log();
|
|
startSocket();
|
|
});
|
|
}
|