Files
nanobot/nanobot/channels/whatsapp.py
T

333 lines
11 KiB
Python
Raw Normal View History

2026-02-01 07:36:42 +00:00
"""WhatsApp channel implementation using Node.js bridge."""
import asyncio
import json
import mimetypes
import os
import secrets
import shutil
import subprocess
from collections import OrderedDict
from pathlib import Path
from typing import Any, Literal
2026-02-01 07:36:42 +00:00
from loguru import logger
from pydantic import Field
2026-02-01 07:36:42 +00:00
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Base
class WhatsAppConfig(Base):
"""WhatsApp channel configuration."""
enabled: bool = False
bridge_url: str = "ws://localhost:3001"
bridge_token: str = ""
allow_from: list[str] = Field(default_factory=list)
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned
2026-02-01 07:36:42 +00:00
def _bridge_token_path() -> Path:
from nanobot.config.paths import get_runtime_subdir
return get_runtime_subdir("whatsapp-auth") / "bridge-token"
def _load_or_create_bridge_token(path: Path) -> str:
"""Load a persisted bridge token or create one on first use."""
if path.exists():
token = path.read_text(encoding="utf-8").strip()
if token:
return token
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
path.write_text(token, encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return token
2026-02-01 07:36:42 +00:00
class WhatsAppChannel(BaseChannel):
"""
WhatsApp channel that connects to a Node.js bridge.
2026-02-01 07:36:42 +00:00
The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol.
Communication between Python and Node.js is via WebSocket.
"""
2026-02-01 07:36:42 +00:00
name = "whatsapp"
display_name = "WhatsApp"
@classmethod
def default_config(cls) -> dict[str, Any]:
return WhatsAppConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WhatsAppConfig.model_validate(config)
2026-02-01 07:36:42 +00:00
super().__init__(config, bus)
self._ws = None
self._connected = False
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._bridge_token: str | None = None
def _effective_bridge_token(self) -> str:
"""Resolve the bridge token, generating a local secret when needed."""
if self._bridge_token is not None:
return self._bridge_token
configured = self.config.bridge_token.strip()
if configured:
self._bridge_token = configured
else:
self._bridge_token = _load_or_create_bridge_token(_bridge_token_path())
return self._bridge_token
async def login(self, force: bool = False) -> bool:
"""
Set up and run the WhatsApp bridge for QR code login.
This spawns the Node.js bridge process which handles the WhatsApp
authentication flow. The process blocks until the user scans the QR code
or interrupts with Ctrl+C.
"""
try:
bridge_dir = _ensure_bridge_setup()
except RuntimeError as e:
logger.error("{}", e)
return False
env = {**os.environ}
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent)
logger.info("Starting WhatsApp bridge for QR login...")
try:
subprocess.run(
[shutil.which("npm"), "start"], cwd=bridge_dir, check=True, env=env
)
except subprocess.CalledProcessError:
return False
return True
2026-02-01 07:36:42 +00:00
async def start(self) -> None:
"""Start the WhatsApp channel by connecting to the bridge."""
import websockets
2026-02-01 07:36:42 +00:00
bridge_url = self.config.bridge_url
logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
2026-02-01 07:36:42 +00:00
self._running = True
2026-02-01 07:36:42 +00:00
while self._running:
try:
async with websockets.connect(bridge_url) as ws:
self._ws = ws
await ws.send(
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
)
2026-02-01 07:36:42 +00:00
self._connected = True
logger.info("Connected to WhatsApp bridge")
2026-02-01 07:36:42 +00:00
# Listen for messages
async for message in ws:
try:
await self._handle_bridge_message(message)
except Exception as e:
logger.error("Error handling bridge message: {}", e)
2026-02-01 07:36:42 +00:00
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._ws = None
logger.warning("WhatsApp bridge connection error: {}", e)
2026-02-01 07:36:42 +00:00
if self._running:
logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
2026-02-01 07:36:42 +00:00
async def stop(self) -> None:
"""Stop the WhatsApp channel."""
self._running = False
self._connected = False
2026-02-01 07:36:42 +00:00
if self._ws:
await self._ws.close()
self._ws = None
2026-02-01 07:36:42 +00:00
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through WhatsApp."""
if not self._ws or not self._connected:
logger.warning("WhatsApp bridge not connected")
return
chat_id = msg.chat_id
if msg.content:
try:
payload = {"type": "send", "to": chat_id, "text": msg.content}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
raise
for media_path in msg.media or []:
try:
mime, _ = mimetypes.guess_type(media_path)
payload = {
"type": "send_media",
"to": chat_id,
"filePath": media_path,
"mimetype": mime or "application/octet-stream",
"fileName": media_path.rsplit("/", 1)[-1],
}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp media {}: {}", media_path, e)
raise
2026-02-01 07:36:42 +00:00
async def _handle_bridge_message(self, raw: str) -> None:
"""Handle a message from the bridge."""
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Invalid JSON from bridge: {}", raw[:100])
2026-02-01 07:36:42 +00:00
return
2026-02-01 07:36:42 +00:00
msg_type = data.get("type")
2026-02-01 07:36:42 +00:00
if msg_type == "message":
# Incoming message from WhatsApp
# Deprecated by whatsapp: old phone number style typically: <phone>@s.whatspp.net
pn = data.get("pn", "")
# New LID sytle typically:
2026-02-01 07:36:42 +00:00
sender = data.get("sender", "")
content = data.get("content", "")
message_id = data.get("id", "")
if message_id:
if message_id in self._processed_message_ids:
return
self._processed_message_ids[message_id] = None
while len(self._processed_message_ids) > 1000:
self._processed_message_ids.popitem(last=False)
# Extract just the phone number or lid as chat_id
is_group = data.get("isGroup", False)
was_mentioned = data.get("wasMentioned", False)
if is_group and getattr(self.config, "group_policy", "open") == "mention":
if not was_mentioned:
return
user_id = pn if pn else sender
sender_id = user_id.split("@")[0] if "@" in user_id else user_id
logger.info("Sender {}", sender)
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
logger.info(
"Voice message received from {}, but direct download from bridge is not yet supported.",
sender_id,
)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
# Extract media paths (images/documents/videos downloaded by the bridge)
2026-03-06 23:36:54 +00:00
media_paths = data.get("media") or []
# Build content tags matching Telegram's pattern: [image: /path] or [file: /path]
if media_paths:
for p in media_paths:
mime, _ = mimetypes.guess_type(p)
media_type = "image" if mime and mime.startswith("image/") else "file"
media_tag = f"[{media_type}: {p}]"
content = f"{content}\n{media_tag}" if content else media_tag
2026-03-06 23:36:54 +00:00
2026-02-01 07:36:42 +00:00
await self._handle_message(
sender_id=sender_id,
chat_id=sender, # Use full LID for replies
2026-02-01 07:36:42 +00:00
content=content,
2026-03-06 23:36:54 +00:00
media=media_paths,
2026-02-01 07:36:42 +00:00
metadata={
"message_id": message_id,
2026-02-01 07:36:42 +00:00
"timestamp": data.get("timestamp"),
"is_group": data.get("isGroup", False),
},
2026-02-01 07:36:42 +00:00
)
2026-02-01 07:36:42 +00:00
elif msg_type == "status":
# Connection status update
status = data.get("status")
logger.info("WhatsApp status: {}", status)
2026-02-01 07:36:42 +00:00
if status == "connected":
self._connected = True
elif status == "disconnected":
self._connected = False
2026-02-01 07:36:42 +00:00
elif msg_type == "qr":
# QR code for authentication
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
2026-02-01 07:36:42 +00:00
elif msg_type == "error":
logger.error("WhatsApp bridge error: {}", data.get("error"))
def _ensure_bridge_setup() -> Path:
"""
Ensure the WhatsApp bridge is set up and built.
Returns the bridge directory. Raises RuntimeError if npm is not found
or bridge cannot be built.
"""
from nanobot.config.paths import get_bridge_install_dir
user_bridge = get_bridge_install_dir()
if (user_bridge / "dist" / "index.js").exists():
return user_bridge
npm_path = shutil.which("npm")
if not npm_path:
raise RuntimeError("npm not found. Please install Node.js >= 18.")
# Find source bridge
current_file = Path(__file__)
pkg_bridge = current_file.parent.parent / "bridge"
src_bridge = current_file.parent.parent.parent / "bridge"
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
raise RuntimeError(
"WhatsApp bridge source not found. "
"Try reinstalling: pip install --force-reinstall nanobot"
)
logger.info("Setting up WhatsApp bridge...")
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
logger.info(" Installing dependencies...")
subprocess.run([npm_path, "install"], cwd=user_bridge, check=True, capture_output=True)
logger.info(" Building...")
subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True)
logger.info("Bridge ready")
return user_bridge