Files
nanobot/nanobot/channels/whatsapp.py
T

189 lines
6.6 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
from collections import OrderedDict
from typing import Any
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)
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()
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
# Send auth token if configured
if self.config.bridge_token:
await ws.send(json.dumps({"type": "auth", "token": self.config.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
2026-02-01 07:36:42 +00:00
try:
payload = {
"type": "send",
"to": msg.chat_id,
"text": msg.content
}
await self._ws.send(json.dumps(payload, ensure_ascii=False))
2026-02-01 07:36:42 +00:00
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
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
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
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'))