add full support for whatsapp

This commit is contained in:
teknium1 2026-02-25 21:04:36 -08:00
parent f1311ad3de
commit 9fc0ca0a72
13 changed files with 2788 additions and 61 deletions

View file

@ -164,6 +164,10 @@ VOICE_TOOLS_OPENAI_KEY=
# Slack allowed users (comma-separated Slack user IDs) # Slack allowed users (comma-separated Slack user IDs)
# SLACK_ALLOWED_USERS= # SLACK_ALLOWED_USERS=
# WhatsApp (built-in Baileys bridge — run `hermes whatsapp` to pair)
# WHATSAPP_ENABLED=false
# WHATSAPP_ALLOWED_USERS=15551234567
# Gateway-wide: allow ALL users without an allowlist (default: false = deny) # Gateway-wide: allow ALL users without an allowlist (default: false = deny)
# Only set to true if you intentionally want open access. # Only set to true if you intentionally want open access.
# GATEWAY_ALLOW_ALL_USERS=false # GATEWAY_ALLOW_ALL_USERS=false

View file

@ -235,23 +235,31 @@ SLACK_ALLOWED_USERS=U01234ABCDE # Comma-separated Slack user IDs
### WhatsApp Setup ### WhatsApp Setup
WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes supports two approaches: WhatsApp doesn't have a simple bot API like Telegram or Discord. Hermes includes a built-in bridge using [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
**Option A — WhatsApp Business API** (requires [Meta Business verification](https://business.facebook.com/)): 1. **Run the setup command:**
- Production-grade, but requires a verified business account
- Set `WHATSAPP_ENABLED=true` in `~/.hermes/.env` and configure the Business API credentials
**Option B — whatsapp-web.js bridge** (personal accounts):
1. Install Node.js if not already present
2. Set up the bridge:
```bash ```bash
# Add to ~/.hermes/.env: hermes whatsapp
WHATSAPP_ENABLED=true
WHATSAPP_ALLOWED_USERS=YOUR_PHONE_NUMBER # e.g. 15551234567
``` ```
3. On first launch, the gateway will display a QR code — scan it with WhatsApp on your phone to link the session This will:
- Enable WhatsApp in your config
- Ask for your phone number (for the allowlist)
- Install bridge dependencies (Node.js required)
- Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
- Exit automatically once paired
2. **Start the gateway:**
```bash
hermes gateway # Foreground
hermes gateway install # Or install as a system service (Linux)
```
The gateway starts the WhatsApp bridge automatically using the saved session.
> **Note:** WhatsApp Web sessions can disconnect if WhatsApp updates their protocol. The gateway reconnects automatically. If you see persistent failures, re-pair with `hermes whatsapp`. Agent responses are prefixed with "⚕ Hermes Agent" so you can distinguish them from your own messages in self-chat.
See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration. See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration.

View file

@ -170,8 +170,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1") load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1")
model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6")
api_key = os.getenv("OPENROUTER_API_KEY", "") # Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
try: try:
import yaml import yaml

View file

@ -6,10 +6,13 @@ and implement the required methods.
""" """
import asyncio import asyncio
import logging
import os import os
import re import re
import uuid import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -517,6 +520,8 @@ class BasePlatformAdapter(ABC):
response = await self._message_handler(event) response = await self._message_handler(event)
# Send response if any # Send response if any
if not response:
logger.warning("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
if response: if response:
# Extract MEDIA:<path> tags (from TTS tool) before other processing # Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response) media_files, response = self.extract_media(response)
@ -526,6 +531,7 @@ class BasePlatformAdapter(ABC):
# Send the text portion first (if any remains after extractions) # Send the text portion first (if any remains after extractions)
if text_content: if text_content:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
result = await self.send( result = await self.send(
chat_id=event.source.chat_id, chat_id=event.source.chat_id,
content=text_content, content=text_content,

View file

@ -18,6 +18,7 @@ with different backends via a bridge pattern.
import asyncio import asyncio
import json import json
import logging import logging
import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
@ -80,11 +81,17 @@ class WhatsAppAdapter(BasePlatformAdapter):
# WhatsApp message limits # WhatsApp message limits
MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages
# Default bridge location relative to the hermes-agent install
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
def __init__(self, config: PlatformConfig): def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WHATSAPP) super().__init__(config, Platform.WHATSAPP)
self._bridge_process: Optional[subprocess.Popen] = None self._bridge_process: Optional[subprocess.Popen] = None
self._bridge_port: int = config.extra.get("bridge_port", 3000) self._bridge_port: int = config.extra.get("bridge_port", 3000)
self._bridge_script: Optional[str] = config.extra.get("bridge_script") self._bridge_script: Optional[str] = config.extra.get(
"bridge_script",
str(self._DEFAULT_BRIDGE_DIR / "bridge.js"),
)
self._session_path: Path = Path(config.extra.get( self._session_path: Path = Path(config.extra.get(
"session_path", "session_path",
Path.home() / ".hermes" / "whatsapp" / "session" Path.home() / ".hermes" / "whatsapp" / "session"
@ -98,25 +105,58 @@ class WhatsAppAdapter(BasePlatformAdapter):
This launches the Node.js bridge process and waits for it to be ready. This launches the Node.js bridge process and waits for it to be ready.
""" """
if not check_whatsapp_requirements(): if not check_whatsapp_requirements():
print(f"[{self.name}] Node.js not found. WhatsApp requires Node.js.") logger.warning("[%s] Node.js not found. WhatsApp requires Node.js.", self.name)
return False
if not self._bridge_script:
print(f"[{self.name}] No bridge script configured.")
print(f"[{self.name}] Set 'bridge_script' in whatsapp.extra config.")
print(f"[{self.name}] See docs/messaging.md for WhatsApp setup instructions.")
return False return False
bridge_path = Path(self._bridge_script) bridge_path = Path(self._bridge_script)
if not bridge_path.exists(): if not bridge_path.exists():
print(f"[{self.name}] Bridge script not found: {bridge_path}") logger.warning("[%s] Bridge script not found: %s", self.name, bridge_path)
return False return False
logger.info("[%s] Bridge found at %s", self.name, bridge_path)
# Auto-install npm dependencies if node_modules doesn't exist
bridge_dir = bridge_path.parent
if not (bridge_dir / "node_modules").exists():
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
try:
install_result = subprocess.run(
["npm", "install", "--silent"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=60,
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
return False
print(f"[{self.name}] Dependencies installed")
except Exception as e:
print(f"[{self.name}] Failed to install dependencies: {e}")
return False
try: try:
# Ensure session directory exists # Ensure session directory exists
self._session_path.mkdir(parents=True, exist_ok=True) self._session_path.mkdir(parents=True, exist_ok=True)
# Start the bridge process # Kill any orphaned bridge from a previous gateway run
try:
result = subprocess.run(
["fuser", f"{self._bridge_port}/tcp"],
capture_output=True, timeout=5,
)
if result.returncode == 0:
# Port is in use — kill the process
subprocess.run(
["fuser", "-k", f"{self._bridge_port}/tcp"],
capture_output=True, timeout=5,
)
import time
time.sleep(2)
except Exception:
pass
# Start the bridge process in its own process group
self._bridge_process = subprocess.Popen( self._bridge_process = subprocess.Popen(
[ [
"node", "node",
@ -124,19 +164,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
"--port", str(self._bridge_port), "--port", str(self._bridge_port),
"--session", str(self._session_path), "--session", str(self._session_path),
], ],
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, preexec_fn=os.setsid,
) )
# Wait for bridge to be ready (look for ready signal) # Wait for bridge to be ready via HTTP health check
# This is a simplified version - real implementation would import aiohttp
# wait for an HTTP health check or specific stdout message for attempt in range(15):
await asyncio.sleep(5) await asyncio.sleep(1)
if self._bridge_process.poll() is not None:
if self._bridge_process.poll() is not None: print(f"[{self.name}] Bridge process died (exit code {self._bridge_process.returncode})")
stderr = self._bridge_process.stderr.read() if self._bridge_process.stderr else "" return False
print(f"[{self.name}] Bridge process died: {stderr}") try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"http://localhost:{self._bridge_port}/health",
timeout=aiohttp.ClientTimeout(total=2)
) as resp:
if resp.status == 200:
data = await resp.json()
print(f"[{self.name}] Bridge ready (status: {data.get('status', '?')})")
break
except Exception:
continue
else:
print(f"[{self.name}] Bridge did not become ready in 15s")
return False return False
# Start message polling task # Start message polling task
@ -148,20 +201,37 @@ class WhatsAppAdapter(BasePlatformAdapter):
return True return True
except Exception as e: except Exception as e:
print(f"[{self.name}] Failed to start bridge: {e}") logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
return False return False
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Stop the WhatsApp bridge.""" """Stop the WhatsApp bridge and clean up any orphaned processes."""
if self._bridge_process: if self._bridge_process:
try: try:
self._bridge_process.terminate() # Kill the entire process group so child node processes die too
import signal
try:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
self._bridge_process.terminate()
await asyncio.sleep(1) await asyncio.sleep(1)
if self._bridge_process.poll() is None: if self._bridge_process.poll() is None:
self._bridge_process.kill() try:
os.killpg(os.getpgid(self._bridge_process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
self._bridge_process.kill()
except Exception as e: except Exception as e:
print(f"[{self.name}] Error stopping bridge: {e}") print(f"[{self.name}] Error stopping bridge: {e}")
# Also kill any orphaned bridge processes on our port
try:
subprocess.run(
["fuser", "-k", f"{self._bridge_port}/tcp"],
capture_output=True, timeout=5,
)
except Exception:
pass
self._running = False self._running = False
self._bridge_process = None self._bridge_process = None
print(f"[{self.name}] Disconnected") print(f"[{self.name}] Disconnected")
@ -355,9 +425,3 @@ class WhatsAppAdapter(BasePlatformAdapter):
print(f"[{self.name}] Error building event: {e}") print(f"[{self.name}] Error building event: {e}")
return None return None
# Note: A reference Node.js bridge script would be provided in scripts/whatsapp-bridge/
# It would use whatsapp-web.js or Baileys to:
# 1. Handle WhatsApp Web authentication (QR code)
# 2. Listen for incoming messages
# 3. Expose HTTP endpoints for send/receive/status

View file

@ -428,7 +428,11 @@ class GatewayRunner:
if global_allowlist: if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
return user_id in allowed_ids # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
return bool(check_ids & allowed_ids)
async def _handle_message(self, event: MessageEvent) -> Optional[str]: async def _handle_message(self, event: MessageEvent) -> Optional[str]:
""" """
@ -1388,8 +1392,9 @@ class GatewayRunner:
except Exception: except Exception:
pass pass
api_key = os.getenv("OPENROUTER_API_KEY", "") # Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6")
try: try:

View file

@ -133,6 +133,116 @@ def cmd_gateway(args):
gateway_command(args) gateway_command(args)
def cmd_whatsapp(args):
"""Set up WhatsApp: enable, configure allowed users, install bridge, pair via QR."""
import os
import subprocess
from pathlib import Path
from hermes_cli.config import get_env_value, save_env_value
print()
print("⚕ WhatsApp Setup")
print("=" * 50)
print()
print("This will link your WhatsApp account to Hermes Agent.")
print("The agent will respond to messages sent to your WhatsApp number.")
print()
# Step 1: Enable WhatsApp
current = get_env_value("WHATSAPP_ENABLED")
if current and current.lower() == "true":
print("✓ WhatsApp is already enabled")
else:
save_env_value("WHATSAPP_ENABLED", "true")
print("✓ WhatsApp enabled")
# Step 2: Allowed users
current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or ""
if current_users:
print(f"✓ Allowed users: {current_users}")
response = input("\n Update allowed users? [y/N] ").strip()
if response.lower() in ("y", "yes"):
phone = input(" Phone number(s) (e.g. 15551234567, comma-separated): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Updated to: {phone}")
else:
print()
phone = input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
print(f" ✓ Allowed users set: {phone}")
else:
print(" ⚠ No allowlist — the agent will respond to ALL incoming messages")
# Step 3: Install bridge deps
project_root = Path(__file__).resolve().parents[1]
bridge_dir = project_root / "scripts" / "whatsapp-bridge"
bridge_script = bridge_dir / "bridge.js"
if not bridge_script.exists():
print(f"\n✗ Bridge script not found at {bridge_script}")
return
if not (bridge_dir / "node_modules").exists():
print("\n→ Installing WhatsApp bridge dependencies...")
result = subprocess.run(
["npm", "install"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
print(f" ✗ npm install failed: {result.stderr}")
return
print(" ✓ Dependencies installed")
else:
print("✓ Bridge dependencies already installed")
# Step 4: Check for existing session
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
session_dir.mkdir(parents=True, exist_ok=True)
if (session_dir / "creds.json").exists():
print("✓ Existing WhatsApp session found")
response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
if response.lower() in ("y", "yes"):
import shutil
shutil.rmtree(session_dir, ignore_errors=True)
session_dir.mkdir(parents=True, exist_ok=True)
print(" ✓ Session cleared")
else:
print("\n✓ WhatsApp is configured and paired!")
print(" Start the gateway with: hermes gateway")
return
# Step 5: Run bridge in pair-only mode (no HTTP server, exits after QR scan)
print()
print("" * 50)
print("📱 Scan the QR code with your phone:")
print(" WhatsApp → Settings → Linked Devices → Link a Device")
print("" * 50)
print()
try:
subprocess.run(
["node", str(bridge_script), "--pair-only", "--session", str(session_dir)],
cwd=str(bridge_dir),
)
except KeyboardInterrupt:
pass
print()
if (session_dir / "creds.json").exists():
print("✓ WhatsApp paired successfully!")
print()
print("Start the gateway with: hermes gateway")
print("Or install as a service: hermes gateway install")
else:
print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.")
def cmd_setup(args): def cmd_setup(args):
"""Interactive setup wizard.""" """Interactive setup wizard."""
from hermes_cli.setup import run_setup_wizard from hermes_cli.setup import run_setup_wizard
@ -755,6 +865,16 @@ For more help on a command:
) )
setup_parser.set_defaults(func=cmd_setup) setup_parser.set_defaults(func=cmd_setup)
# =========================================================================
# whatsapp command
# =========================================================================
whatsapp_parser = subparsers.add_parser(
"whatsapp",
help="Set up WhatsApp integration",
description="Configure WhatsApp and pair via QR code"
)
whatsapp_parser.set_defaults(func=cmd_whatsapp)
# ========================================================================= # =========================================================================
# login command # login command
# ========================================================================= # =========================================================================

View file

@ -1227,13 +1227,22 @@ def run_setup_wizard(args):
# WhatsApp # WhatsApp
existing_whatsapp = get_env_value('WHATSAPP_ENABLED') existing_whatsapp = get_env_value('WHATSAPP_ENABLED')
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
print_info("WhatsApp uses a bridge service for connectivity.") print_info("WhatsApp connects via a built-in bridge (Baileys).")
print_info("See docs/messaging.md for detailed WhatsApp setup instructions.") print_info("Requires Node.js (already installed if you have browser tools).")
print_info("On first gateway start, you'll scan a QR code with your phone.")
print() print()
if prompt_yes_no("Enable WhatsApp bridge?", True): if prompt_yes_no("Enable WhatsApp?", True):
save_env_value("WHATSAPP_ENABLED", "true") save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled") print_success("WhatsApp enabled")
print_info("Run 'hermes gateway' to complete WhatsApp pairing via QR code")
allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)")
if allowed_users:
save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("WhatsApp allowlist configured")
else:
print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!")
print_info("Start the gateway with 'hermes gateway' and scan the QR code.")
# Gateway reminder # Gateway reminder
any_messaging = ( any_messaging = (

View file

@ -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\audio_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | 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\skills" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\whatsapp\session" | Out-Null
# Create .env # Create .env
$envPath = "$HermesHome\.env" $envPath = "$HermesHome\.env"
@ -626,7 +627,7 @@ function Install-NodeDeps {
Push-Location $InstallDir Push-Location $InstallDir
if (Test-Path "package.json") { if (Test-Path "package.json") {
Write-Info "Installing Node.js dependencies..." Write-Info "Installing Node.js dependencies (browser tools)..."
try { try {
npm install --silent 2>&1 | Out-Null npm install --silent 2>&1 | Out-Null
Write-Success "Node.js dependencies installed" 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 Pop-Location
} }
@ -673,6 +688,29 @@ function Start-GatewayIfConfigured {
if (-not $hasMessaging) { return } 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-Host ""
Write-Info "Messaging platform token detected!" Write-Info "Messaging platform token detected!"
Write-Info "The gateway handles messaging platforms and cron job execution." 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]" $response = Read-Host "Would you like to start the gateway now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") { 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..." Write-Info "Starting gateway in background..."
try { try {
$logFile = "$HermesHome\logs\gateway.log" $logFile = "$HermesHome\logs\gateway.log"

View file

@ -676,7 +676,7 @@ copy_config_templates() {
log_info "Setting up configuration files..." log_info "Setting up configuration files..."
# Create ~/.hermes directory structure (config at top level, code in subdir) # 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) # Create .env at ~/.hermes/.env (top level, easy to find)
if [ ! -f "$HERMES_HOME/.env" ]; then if [ ! -f "$HERMES_HOME/.env" ]; then
@ -745,14 +745,23 @@ install_node_deps() {
fi fi
if [ -f "$INSTALL_DIR/package.json" ]; then if [ -f "$INSTALL_DIR/package.json" ]; then
log_info "Installing Node.js dependencies..." log_info "Installing Node.js dependencies (browser tools)..."
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
npm install --silent 2>/dev/null || { npm install --silent 2>/dev/null || {
log_warn "npm install failed (browser tools may not work)" log_warn "npm install failed (browser tools may not work)"
return 0
} }
log_success "Node.js dependencies installed" log_success "Node.js dependencies installed"
fi 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() { run_setup_wizard() {
@ -798,6 +807,24 @@ maybe_start_gateway() {
echo "" echo ""
log_info "Messaging platform token detected!" log_info "Messaging platform token detected!"
log_info "The gateway needs to be running for Hermes to send/receive messages." 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 "" echo ""
read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r read -p "Would you like to install the gateway as a background service? [Y/n] " -n 1 -r
echo echo

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}