OpenSpace/openspace/communication/adapters/whatsapp.py
2026-07-17 11:43:42 +08:00

462 lines
17 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
import os
import re
import secrets
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any, Optional
import aiohttp
from openspace.communication.adapters.base import BaseChannelAdapter
from openspace.communication.attachment_cache import AttachmentCache
from openspace.communication.config import WhatsAppConfig
from openspace.communication.types import (
AttachmentKind,
ChannelMessage,
ChannelPlatform,
ChannelSource,
SendResult,
)
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class WhatsAppAdapter(BaseChannelAdapter):
def __init__(
self,
config: WhatsAppConfig,
attachment_cache: AttachmentCache,
*,
runtime_dir: Optional[Path] = None,
poll_interval_seconds: float = 1.0,
):
super().__init__(ChannelPlatform.WHATSAPP)
self.config = config
self.attachment_cache = attachment_cache
self.runtime_dir = (
Path(runtime_dir).expanduser().resolve()
if runtime_dir is not None
else attachment_cache.base_dir.parent.resolve()
)
self._poll_interval_seconds = poll_interval_seconds
self._http_session: Optional[aiohttp.ClientSession] = None
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._receiver_task: Optional[asyncio.Task] = None
self._bridge_process: Optional[subprocess.Popen] = None
self._pending_requests: dict[str, asyncio.Future[dict[str, Any]]] = {}
self._auth_event = asyncio.Event()
self._status_event = asyncio.Event()
self._bridge_state = "disconnected"
def validate_configuration(self) -> None:
if self.config.bridge.enforce_loopback and self.config.bridge.host not in {"127.0.0.1", "localhost"}:
raise ValueError("WhatsApp bridge host must stay on loopback")
def get_lock_identity(self) -> Optional[tuple[str, str]]:
return ("whatsapp-session", str(self._session_dir().resolve()))
async def connect(self) -> bool:
self.validate_configuration()
if self._http_session is None:
self._http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=20),
)
for attempt in range(2):
try:
await self._open_control_socket()
except Exception as exc:
logger.info("WhatsApp bridge connection attempt %s failed: %s", attempt + 1, exc)
if attempt == 0:
await self._start_bridge_process()
await asyncio.sleep(1)
continue
return False
break
for _ in range(20):
if self._bridge_state == "connected":
self._connected = True
return True
await asyncio.sleep(1)
logger.error("WhatsApp bridge control channel opened but WhatsApp session did not connect")
return False
async def disconnect(self) -> None:
self._connected = False
self._bridge_state = "disconnected"
self._status_event.clear()
self._auth_event.clear()
if self._receiver_task is not None:
self._receiver_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._receiver_task
self._receiver_task = None
if self._ws is not None:
await self._ws.close()
self._ws = None
for future in self._pending_requests.values():
if not future.done():
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
self._pending_requests.clear()
if self._http_session is not None:
await self._http_session.close()
self._http_session = None
if self._bridge_process is not None and self._bridge_process.poll() is None:
self._bridge_process.terminate()
try:
self._bridge_process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._bridge_process.kill()
self._bridge_process = None
async def send_text(
self,
chat_id: str,
content: str,
*,
reply_to_message_id: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
) -> SendResult:
if self._ws is None:
return SendResult(success=False, error="WhatsApp bridge not initialized")
last_message_id: Optional[str] = None
for chunk in _split_text(content, 60000):
try:
payload = await self._send_command(
{
"type": "send",
"to": chat_id,
"text": chunk,
"replyToMessageId": reply_to_message_id,
}
)
except Exception as exc:
return SendResult(success=False, error=str(exc))
last_message_id = _optional_str(payload.get("messageId")) or last_message_id
return SendResult(success=True, message_id=last_message_id)
async def send_media(
self,
chat_id: str,
*,
file_path: str,
mimetype: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to_message_id: Optional[str] = None,
) -> SendResult:
if self._ws is None:
return SendResult(success=False, error="WhatsApp bridge not initialized")
try:
payload = await self._send_command(
{
"type": "send_media",
"to": chat_id,
"filePath": file_path,
"mimetype": mimetype,
"caption": caption,
"fileName": file_name,
"replyToMessageId": reply_to_message_id,
}
)
except Exception as exc:
return SendResult(success=False, error=str(exc))
return SendResult(success=True, message_id=_optional_str(payload.get("messageId")))
async def _open_control_socket(self) -> None:
if self._http_session is None:
raise RuntimeError("WhatsApp bridge HTTP session is not initialized")
if self._receiver_task is not None:
self._receiver_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._receiver_task
self._receiver_task = None
if self._ws is not None:
await self._ws.close()
self._ws = None
self._auth_event.clear()
self._status_event.clear()
ws = await self._http_session.ws_connect(
self.config.bridge.ws_url,
heartbeat=20,
autoping=True,
max_msg_size=4 * 1024 * 1024,
)
self._ws = ws
self._receiver_task = asyncio.create_task(self._receive_loop(ws))
await self._send_ws_json({"type": "auth", "token": self._effective_bridge_token()})
await asyncio.wait_for(self._auth_event.wait(), timeout=5)
async def _receive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
try:
async for msg in ws:
if msg.type != aiohttp.WSMsgType.TEXT:
if msg.type in {
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
}:
break
continue
try:
payload = json.loads(msg.data)
except json.JSONDecodeError:
logger.warning("Ignoring invalid WhatsApp bridge JSON: %r", msg.data[:200])
continue
await self._handle_ws_payload(payload)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("WhatsApp bridge receive loop stopped: %s", exc)
finally:
if self._ws is ws:
self._ws = None
self._connected = False
self._bridge_state = "disconnected"
self._status_event.clear()
for request_id, future in list(self._pending_requests.items()):
if not future.done():
future.set_exception(RuntimeError("WhatsApp bridge disconnected"))
self._pending_requests.pop(request_id, None)
async def _handle_ws_payload(self, payload: dict[str, Any]) -> None:
message_type = str(payload.get("type", "")).strip().lower()
if message_type == "auth_ok":
self._auth_event.set()
return
if message_type == "status":
self._bridge_state = str(payload.get("status", "")).strip().lower() or "disconnected"
self._connected = self._bridge_state == "connected"
self._status_event.set()
return
if message_type == "qr":
logger.info("WhatsApp bridge is waiting for QR scan")
return
if message_type == "ack":
request_id = _optional_str(payload.get("requestId"))
if request_id and request_id in self._pending_requests:
future = self._pending_requests.pop(request_id)
if not future.done():
future.set_result(payload)
return
if message_type == "error":
request_id = _optional_str(payload.get("requestId"))
error = _optional_str(payload.get("error")) or "Unknown bridge error"
if request_id and request_id in self._pending_requests:
future = self._pending_requests.pop(request_id)
if not future.done():
future.set_exception(RuntimeError(error))
else:
logger.warning("WhatsApp bridge error: %s", error)
return
if message_type == "message":
message = await self._normalize_event(payload)
if message is not None:
await self.dispatch_message(message)
async def _send_command(self, payload: dict[str, Any]) -> dict[str, Any]:
if self._ws is None:
raise RuntimeError("WhatsApp bridge control channel is not connected")
request_id = uuid.uuid4().hex[:12]
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
self._pending_requests[request_id] = future
try:
await self._send_ws_json({**payload, "requestId": request_id})
return await asyncio.wait_for(future, timeout=20)
finally:
self._pending_requests.pop(request_id, None)
async def _send_ws_json(self, payload: dict[str, Any]) -> None:
if self._ws is None:
raise RuntimeError("WhatsApp bridge control channel is not connected")
await self._ws.send_str(json.dumps(payload, ensure_ascii=False))
async def _normalize_event(self, event: dict[str, Any]) -> Optional[ChannelMessage]:
chat_id = str(event.get("chatId", "")).strip()
message_id = str(event.get("messageId", "")).strip()
sender_id = str(event.get("senderId", "")).strip()
if not chat_id or not message_id:
return None
normalized_sender_id = _normalize_whatsapp_identifier(sender_id)
source = ChannelSource(
platform=ChannelPlatform.WHATSAPP,
chat_id=chat_id,
chat_type="group" if event.get("isGroup") else "dm",
user_id=normalized_sender_id or sender_id or None,
user_name=_optional_str(event.get("senderName")),
chat_name=_optional_str(event.get("chatName")),
)
session_key = _build_session_key_hint(source)
attachments = []
media_type = str(event.get("mediaType", "")).strip().lower()
attachment_kind = AttachmentKind.IMAGE if media_type == "image" else AttachmentKind.DOCUMENT
for media_path in event.get("mediaUrls") or []:
attachment = self.attachment_cache.copy_local_file(
session_key=session_key,
source_path=str(media_path),
kind=attachment_kind,
)
if attachment is not None:
attachments.append(attachment)
body = str(event.get("body", "") or "").strip()
return ChannelMessage(
source=source,
text=body,
message_id=message_id,
attachments=attachments,
reply_to_message_id=_optional_str(event.get("replyToMessageId")),
mentions_bot=bool(event.get("mentionsBot")),
metadata={
"bridge_event": event,
"raw_user_id": sender_id or None,
"auth_candidates": [
candidate
for candidate in (
sender_id or None,
normalized_sender_id or None,
f"+{normalized_sender_id}" if normalized_sender_id else None,
)
if candidate
],
},
)
async def _start_bridge_process(self) -> None:
if self._bridge_process is not None and self._bridge_process.poll() is None:
return
bridge_script = self._resolve_bridge_script()
bridge_dir = bridge_script.parent
session_dir = self._session_dir()
session_dir.mkdir(parents=True, exist_ok=True)
self._outbound_media_root().mkdir(parents=True, exist_ok=True)
if self.config.bridge.auto_install_dependencies and not (bridge_dir / "node_modules").exists():
subprocess.run(
["npm", "install", "--silent"],
cwd=bridge_dir,
check=True,
)
env = os.environ.copy()
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["BRIDGE_MEDIA_ROOT"] = str(self._outbound_media_root())
if self.config.allowed_users:
env["WHATSAPP_ALLOWED_USERS"] = ",".join(self.config.allowed_users)
if self.config.reply_prefix is not None:
env["WHATSAPP_REPLY_PREFIX"] = self.config.reply_prefix
self._bridge_process = subprocess.Popen(
[
"node",
str(bridge_script),
"--host",
self.config.bridge.host,
"--port",
str(self.config.bridge.port),
"--session",
str(session_dir),
"--mode",
self.config.bridge.mode,
],
cwd=str(bridge_dir),
env=env,
)
def _resolve_bridge_script(self) -> Path:
if self.config.bridge.script_path:
custom_path = Path(self.config.bridge.script_path).expanduser().resolve()
return custom_path / "bridge.js" if custom_path.is_dir() else custom_path
source_dir = Path(__file__).resolve().parent.parent / "bridges" / "whatsapp"
target_dir = self.runtime_dir / "whatsapp-bridge"
target_dir.mkdir(parents=True, exist_ok=True)
for filename in ("bridge.js", "allowlist.js", "package.json"):
shutil.copy2(source_dir / filename, target_dir / filename)
return target_dir / "bridge.js"
def _effective_bridge_token(self) -> str:
configured = _optional_str(self.config.bridge.token)
if configured:
return configured
token_path = self.runtime_dir / "bridge_tokens" / "whatsapp.token"
if token_path.exists():
token = token_path.read_text(encoding="utf-8").strip()
if token:
return token
token_path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
token_path.write_text(token, encoding="utf-8")
try:
token_path.chmod(0o600)
except OSError:
pass
return token
def _session_dir(self) -> Path:
if self.config.bridge.session_dir:
return Path(self.config.bridge.session_dir).expanduser().resolve()
return (self.runtime_dir / "whatsapp" / "session").resolve()
def _outbound_media_root(self) -> Path:
return (self.runtime_dir / "outbound_media").resolve()
def _split_text(content: str, limit: int) -> list[str]:
text = content.strip()
if not text:
return [""]
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
chunk = remaining[:limit]
if len(remaining) > limit:
split_at = chunk.rfind("\n")
if split_at < limit // 3:
split_at = chunk.rfind(" ")
if split_at >= limit // 3:
chunk = chunk[:split_at]
chunks.append(chunk.strip())
remaining = remaining[len(chunk):].lstrip()
return [chunk for chunk in chunks if chunk]
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
value = str(value).strip()
return value or None
def _build_session_key_hint(source: ChannelSource) -> str:
parts = [source.platform.value, source.chat_id]
if source.thread_id:
parts.append(source.thread_id)
return "__".join(part.replace("/", "_") for part in parts if part)
def _normalize_whatsapp_identifier(value: Any) -> str:
normalized = re.sub(r":.*@", "@", str(value or "").strip())
normalized = re.sub(r"@.*", "", normalized)
return normalized.lstrip("+")