add full support for whatsapp
This commit is contained in:
@@ -545,6 +545,7 @@ function Copy-ConfigTemplates {
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null
|
||||
|
||||
# Create .env
|
||||
$envPath = "$HermesHome\.env"
|
||||
@@ -626,7 +627,7 @@ function Install-NodeDeps {
|
||||
Push-Location $InstallDir
|
||||
|
||||
if (Test-Path "package.json") {
|
||||
Write-Info "Installing Node.js dependencies..."
|
||||
Write-Info "Installing Node.js dependencies (browser tools)..."
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
Write-Success "Node.js dependencies installed"
|
||||
@@ -635,6 +636,20 @@ function Install-NodeDeps {
|
||||
}
|
||||
}
|
||||
|
||||
# Install WhatsApp bridge dependencies
|
||||
$bridgeDir = "$InstallDir\scripts\whatsapp-bridge"
|
||||
if (Test-Path "$bridgeDir\package.json") {
|
||||
Write-Info "Installing WhatsApp bridge dependencies..."
|
||||
Push-Location $bridgeDir
|
||||
try {
|
||||
npm install --silent 2>&1 | Out-Null
|
||||
Write-Success "WhatsApp bridge dependencies installed"
|
||||
} catch {
|
||||
Write-Warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
|
||||
}
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
@@ -673,6 +688,29 @@ function Start-GatewayIfConfigured {
|
||||
|
||||
if (-not $hasMessaging) { return }
|
||||
|
||||
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
|
||||
if (-not (Test-Path $hermesCmd)) {
|
||||
$hermesCmd = "hermes"
|
||||
}
|
||||
|
||||
# If WhatsApp is enabled but not yet paired, run foreground for QR scan
|
||||
$whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
|
||||
$whatsappSession = "$HermesHome\whatsapp\session\creds.json"
|
||||
if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
|
||||
Write-Host ""
|
||||
Write-Info "WhatsApp is enabled but not yet paired."
|
||||
Write-Info "Running 'hermes whatsapp' to pair via QR code..."
|
||||
Write-Host ""
|
||||
$response = Read-Host "Pair WhatsApp now? [Y/n]"
|
||||
if ($response -eq "" -or $response -match "^[Yy]") {
|
||||
try {
|
||||
& $hermesCmd whatsapp
|
||||
} catch {
|
||||
# Expected after pairing completes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Info "Messaging platform token detected!"
|
||||
Write-Info "The gateway handles messaging platforms and cron job execution."
|
||||
@@ -680,11 +718,6 @@ function Start-GatewayIfConfigured {
|
||||
$response = Read-Host "Would you like to start the gateway now? [Y/n]"
|
||||
|
||||
if ($response -eq "" -or $response -match "^[Yy]") {
|
||||
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
|
||||
if (-not (Test-Path $hermesCmd)) {
|
||||
$hermesCmd = "hermes"
|
||||
}
|
||||
|
||||
Write-Info "Starting gateway in background..."
|
||||
try {
|
||||
$logFile = "$HermesHome\logs\gateway.log"
|
||||
|
||||
@@ -676,7 +676,7 @@ copy_config_templates() {
|
||||
log_info "Setting up configuration files..."
|
||||
|
||||
# Create ~/.hermes directory structure (config at top level, code in subdir)
|
||||
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills}
|
||||
mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills,whatsapp/session}
|
||||
|
||||
# Create .env at ~/.hermes/.env (top level, easy to find)
|
||||
if [ ! -f "$HERMES_HOME/.env" ]; then
|
||||
@@ -745,14 +745,23 @@ install_node_deps() {
|
||||
fi
|
||||
|
||||
if [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
log_info "Installing Node.js dependencies..."
|
||||
log_info "Installing Node.js dependencies (browser tools)..."
|
||||
cd "$INSTALL_DIR"
|
||||
npm install --silent 2>/dev/null || {
|
||||
log_warn "npm install failed (browser tools may not work)"
|
||||
return 0
|
||||
}
|
||||
log_success "Node.js dependencies installed"
|
||||
fi
|
||||
|
||||
# Install WhatsApp bridge dependencies
|
||||
if [ -f "$INSTALL_DIR/scripts/whatsapp-bridge/package.json" ]; then
|
||||
log_info "Installing WhatsApp bridge dependencies..."
|
||||
cd "$INSTALL_DIR/scripts/whatsapp-bridge"
|
||||
npm install --silent 2>/dev/null || {
|
||||
log_warn "WhatsApp bridge npm install failed (WhatsApp may not work)"
|
||||
}
|
||||
log_success "WhatsApp bridge dependencies installed"
|
||||
fi
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
@@ -798,6 +807,24 @@ maybe_start_gateway() {
|
||||
echo ""
|
||||
log_info "Messaging platform token detected!"
|
||||
log_info "The gateway needs to be running for Hermes to send/receive messages."
|
||||
|
||||
# If WhatsApp is enabled and no session exists yet, run foreground first for QR scan
|
||||
WHATSAPP_VAL=$(grep "^WHATSAPP_ENABLED=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
|
||||
WHATSAPP_SESSION="$HERMES_HOME/whatsapp/session/creds.json"
|
||||
if [ "$WHATSAPP_VAL" = "true" ] && [ ! -f "$WHATSAPP_SESSION" ]; then
|
||||
echo ""
|
||||
log_info "WhatsApp is enabled but not yet paired."
|
||||
log_info "Running 'hermes whatsapp' to pair via QR code..."
|
||||
echo ""
|
||||
read -p "Pair WhatsApp now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||
HERMES_CMD="$HOME/.local/bin/hermes"
|
||||
[ ! -x "$HERMES_CMD" ] && HERMES_CMD="hermes"
|
||||
$HERMES_CMD whatsapp || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r
|
||||
echo
|
||||
|
||||
278
scripts/whatsapp-bridge/bridge.js
Normal file
278
scripts/whatsapp-bridge/bridge.js
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/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 /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 } 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 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 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;
|
||||
|
||||
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 }) => {
|
||||
if (type !== 'notify') return;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.message) continue;
|
||||
|
||||
const chatId = msg.key.remoteJid;
|
||||
const senderId = msg.key.participant || chatId;
|
||||
const isGroup = chatId.endsWith('@g.us');
|
||||
const senderNumber = senderId.replace(/@.*/, '');
|
||||
|
||||
// Skip own messages UNLESS it's a self-chat ("Message Yourself")
|
||||
// Self-chat JID ends with the user's own number
|
||||
if (msg.key.fromMe && !chatId.includes('status') && isGroup) continue;
|
||||
// In non-group chats, fromMe means we sent it — skip unless allowed user sent to themselves
|
||||
if (msg.key.fromMe && !isGroup && ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(senderNumber)) 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';
|
||||
}
|
||||
|
||||
// Skip empty messages
|
||||
if (!body && !hasMedia) 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 });
|
||||
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}`);
|
||||
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();
|
||||
});
|
||||
}
|
||||
2156
scripts/whatsapp-bridge/package-lock.json
generated
Normal file
2156
scripts/whatsapp-bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
scripts/whatsapp-bridge/package.json
Normal file
16
scripts/whatsapp-bridge/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "hermes-whatsapp-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "WhatsApp bridge for Hermes Agent using Baileys",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node bridge.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"express": "^4.21.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"pino": "^9.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user