mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-09-09 22:31:03 +00:00
462 lines
17 KiB
Python
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("+")
|