stage-13: WhatsApp bridge with Baileys + Python adapter

Node.js bridge (bridge/whatsapp/): Baileys client with Express HTTP API
on localhost:8098 — QR code linking, message queue, reconnection logic.

Python adapter (src/adapters/whatsapp.py): polls bridge every 2s, routes
through router.py, separate whatsapp.owner/admins auth, security logging.

Integrated in main.py alongside Discord + Telegram via asyncio.gather.
CLI: echo whatsapp status/qr. 442 tests pass (32 new, zero failures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 21:41:16 +00:00
parent 2d8e56d44c
commit 80502b7931
12 changed files with 3636 additions and 2 deletions

242
src/adapters/whatsapp.py Normal file
View File

@@ -0,0 +1,242 @@
"""WhatsApp adapter for Echo Core — connects to Node.js bridge."""
import asyncio
import logging
import httpx
from src.config import Config
from src.router import route_message
from src.claude_session import clear_session, get_active_session
log = logging.getLogger("echo-core.whatsapp")
_security_log = logging.getLogger("echo-core.security")
# Module-level config reference, set by run_whatsapp()
_config: Config | None = None
_bridge_url: str = "http://127.0.0.1:8098"
_running: bool = False
VALID_MODELS = {"opus", "sonnet", "haiku"}
def _get_config() -> Config:
"""Return the module-level config, raising if not initialized."""
if _config is None:
raise RuntimeError("WhatsApp adapter not initialized — call run_whatsapp() first")
return _config
# --- Authorization helpers ---
def is_owner(phone: str) -> bool:
"""Check if phone number matches config whatsapp.owner."""
owner = _get_config().get("whatsapp.owner")
return phone == str(owner) if owner else False
def is_admin(phone: str) -> bool:
"""Check if phone number is owner or in whatsapp admins list."""
if is_owner(phone):
return True
admins = _get_config().get("whatsapp.admins", [])
return phone in admins
def is_registered_chat(chat_id: str) -> bool:
"""Check if a WhatsApp chat is in any registered channel entry."""
channels = _get_config().get("whatsapp_channels", {})
return any(ch.get("id") == chat_id for ch in channels.values())
# --- Message splitting helper ---
def split_message(text: str, limit: int = 4096) -> list[str]:
"""Split text into chunks that fit WhatsApp's message limit."""
if len(text) <= limit:
return [text]
chunks = []
while text:
if len(text) <= limit:
chunks.append(text)
break
split_at = text.rfind("\n", 0, limit)
if split_at == -1:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
# --- Bridge communication ---
async def poll_messages(client: httpx.AsyncClient) -> list[dict]:
"""Poll bridge for new messages."""
try:
resp = await client.get(f"{_bridge_url}/messages", timeout=10)
if resp.status_code == 200:
data = resp.json()
return data.get("messages", [])
except Exception as e:
log.debug("Bridge poll error: %s", e)
return []
async def send_whatsapp(client: httpx.AsyncClient, to: str, text: str) -> bool:
"""Send a message via the bridge."""
try:
for chunk in split_message(text):
resp = await client.post(
f"{_bridge_url}/send",
json={"to": to, "text": chunk},
timeout=30,
)
if resp.status_code != 200 or not resp.json().get("ok"):
log.error("Failed to send to %s: %s", to, resp.text)
return False
return True
except Exception as e:
log.error("Send error: %s", e)
return False
async def get_bridge_status(client: httpx.AsyncClient) -> dict | None:
"""Get bridge connection status."""
try:
resp = await client.get(f"{_bridge_url}/status", timeout=5)
if resp.status_code == 200:
return resp.json()
except Exception:
pass
return None
# --- Message handler ---
async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
"""Process a single incoming WhatsApp message."""
sender = msg.get("from", "")
text = msg.get("text", "").strip()
push_name = msg.get("pushName", "unknown")
is_group = msg.get("isGroup", False)
if not text:
return
# Group chat: only registered chats
if is_group:
chat_id = sender # group JID
if not is_registered_chat(chat_id):
return
# Group messages — skip for now (can be enhanced later)
return
# Private chat: check admin
phone = sender.split("@")[0]
if not is_admin(phone):
_security_log.warning(
"Unauthorized WhatsApp DM from %s (%s): %.100s",
phone, push_name, text,
)
return
# Use phone number as channel ID
channel_id = f"wa-{phone}"
# Handle slash commands locally for immediate response
if text.startswith("/"):
cmd = text.split()[0].lower()
if cmd == "/clear":
cleared = clear_session(channel_id)
reply = "Session cleared." if cleared else "No active session."
await send_whatsapp(client, sender, reply)
return
if cmd == "/status":
session = get_active_session(channel_id)
if session:
model = session.get("model", "?")
sid = session.get("session_id", "?")[:8]
count = session.get("message_count", 0)
in_tok = session.get("total_input_tokens", 0)
out_tok = session.get("total_output_tokens", 0)
reply = (
f"Model: {model}\n"
f"Session: {sid}\n"
f"Messages: {count}\n"
f"Tokens: {in_tok} in / {out_tok} out"
)
else:
reply = "No active session."
await send_whatsapp(client, sender, reply)
return
# Route to Claude via router (handles /model and regular messages)
log.info("Message from %s (%s): %.50s", phone, push_name, text)
try:
response, _is_cmd = await asyncio.to_thread(
route_message, channel_id, phone, text
)
await send_whatsapp(client, sender, response)
except Exception as e:
log.error("Error handling message from %s: %s", phone, e)
await send_whatsapp(client, sender, "Sorry, an error occurred.")
# --- Main loop ---
async def run_whatsapp(config: Config, bridge_url: str = "http://127.0.0.1:8098"):
"""Main WhatsApp polling loop."""
global _config, _bridge_url, _running
_config = config
_bridge_url = bridge_url
_running = True
log.info("WhatsApp adapter starting (bridge: %s)", bridge_url)
async with httpx.AsyncClient() as client:
# Wait for bridge to be ready
retries = 0
while _running and retries < 30:
status = await get_bridge_status(client)
if status:
if status.get("connected"):
log.info("WhatsApp bridge connected (phone: %s)", status.get("phone"))
break
else:
qr = "QR available" if status.get("qr") else "waiting"
log.info("WhatsApp bridge not connected yet (%s)", qr)
else:
log.info("WhatsApp bridge not reachable, retrying...")
retries += 1
await asyncio.sleep(5)
if not _running:
return
log.info("WhatsApp adapter polling started")
# Polling loop
while _running:
try:
messages = await poll_messages(client)
for msg in messages:
await handle_incoming(msg, client)
except asyncio.CancelledError:
break
except Exception as e:
log.error("Polling error: %s", e)
await asyncio.sleep(2)
log.info("WhatsApp adapter stopped")
def stop_whatsapp():
"""Signal the polling loop to stop."""
global _running
_running = False

View File

@@ -118,6 +118,14 @@ def main():
else:
logger.info("No telegram_token — Telegram bot disabled")
# WhatsApp adapter (optional — only if whatsapp is enabled in config)
whatsapp_enabled = config.get("whatsapp", {}).get("enabled", False)
whatsapp_bridge_url = config.get("whatsapp", {}).get("bridge_url", "http://127.0.0.1:8098")
if whatsapp_enabled:
logger.info("WhatsApp adapter configured (bridge: %s)", whatsapp_bridge_url)
else:
logger.info("WhatsApp adapter disabled")
# PID file
PID_FILE.write_text(str(os.getpid()))
@@ -133,7 +141,7 @@ def main():
signal.signal(signal.SIGINT, handle_signal)
async def _run_all():
"""Run Discord + Telegram bots concurrently."""
"""Run Discord + Telegram + WhatsApp bots concurrently."""
tasks = [asyncio.create_task(client.start(token))]
if telegram_app:
async def _run_telegram():
@@ -149,6 +157,14 @@ def main():
await telegram_app.stop()
await telegram_app.shutdown()
tasks.append(asyncio.create_task(_run_telegram()))
if whatsapp_enabled:
from src.adapters.whatsapp import run_whatsapp, stop_whatsapp
async def _run_whatsapp():
try:
await run_whatsapp(config, whatsapp_bridge_url)
except asyncio.CancelledError:
stop_whatsapp()
tasks.append(asyncio.create_task(_run_whatsapp()))
await asyncio.gather(*tasks)
try: