Files
ragflow/api/channels/whatsapp/channel.py

384 lines
14 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Optional
import aiohttp
from ..core.base import Channel, IncomingMessage, OutgoingMessage
from ..core.registry import register_channel
LOGGER = logging.getLogger(__name__)
WHATSAPP_DEFAULT_TIMEOUT_SECS = 30
WHATSAPP_DEFAULT_SESSION_KEY = "default"
WHATSAPP_MESSAGE_TTL_SECS = 3600
WHATSAPP_GATEWAY_START_RETRY_SECS = 30
WHATSAPP_GATEWAY_RECONNECT_SECS = 3
@dataclass
class WhatsAppAccount:
account_id: str
gateway_base_url: str
gateway_token: str = ""
session_key: str = ""
timeout_secs: int = WHATSAPP_DEFAULT_TIMEOUT_SECS
_live_channels: dict[str, "WhatsAppChannel"] = {}
def _default_gateway_base_url() -> str:
return "http://127.0.0.1:3005"
def get_runtime_snapshot(account_id: str) -> dict[str, Any] | None:
channel = _live_channels.get(account_id)
if channel is None:
return None
return channel.get_status_snapshot()
class WhatsAppChannel(Channel):
channel_id = "whatsapp"
def __init__(self, account: WhatsAppAccount) -> None:
super().__init__()
self.account = account
self.account_id = account.account_id
self._task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
self._lifecycle_lock = asyncio.Lock()
self._http_session: Optional[aiohttp.ClientSession] = None
self._status: str = "stopped"
self._last_error: str = ""
self._qr_data_url: str = ""
self._qr_updated_at: float = 0.0
self._connected_at: float = 0.0
self._last_snapshot_at: float = 0.0
self._session_id: str = ""
self._event_cursor: int = 0
self._seen_message_ids: dict[str, float] = {}
def _session_key(self) -> str:
value = str(self.account.session_key or "").strip()
return value or WHATSAPP_DEFAULT_SESSION_KEY
def _gateway_base_url(self) -> str:
return str(self.account.gateway_base_url or "").strip().rstrip("/")
def _gateway_url(self, path: str) -> str:
base_url = self._gateway_base_url()
if not base_url:
raise ValueError("WhatsApp gateway_base_url is required")
return f"{base_url}/{path.lstrip('/')}"
def _events_ws_url(self) -> str:
base_url = self._gateway_base_url()
if not base_url:
raise ValueError("WhatsApp gateway_base_url is required")
if base_url.startswith("http://"):
ws_base = f"ws://{base_url.removeprefix('http://')}"
elif base_url.startswith("https://"):
ws_base = f"wss://{base_url.removeprefix('https://')}"
else:
ws_base = base_url
return f"{ws_base}/whatsapp/{self._session_key()}/events/ws?after={self._event_cursor}"
def _gateway_headers(self) -> dict[str, str]:
token = str(self.account.gateway_token or "").strip()
if not token:
return {}
if token.lower().startswith("bearer "):
return {"Authorization": token}
return {"Authorization": f"Bearer {token}"}
async def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is not None and not self._http_session.closed:
return self._http_session
timeout = aiohttp.ClientTimeout(total=max(int(self.account.timeout_secs), 1))
self._http_session = aiohttp.ClientSession(timeout=timeout)
return self._http_session
async def _request_json(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
session = await self._ensure_http_session()
url = self._gateway_url(path)
async with session.request(
method,
url,
json=payload,
headers=self._gateway_headers(),
) as resp:
text = await resp.text()
if resp.status >= 400:
raise RuntimeError(f"status: {resp.status}, response: {text}")
if not text.strip():
return {}
content_type = resp.headers.get("content-type", "")
if "application/json" not in content_type.lower():
raise RuntimeError(f"unexpected response content-type: {content_type or 'unknown'}, response: {text[:200]}")
try:
return await resp.json()
except Exception as ex:
raise RuntimeError(f"invalid json response: {text[:200]}") from ex
async def _request_json_with_retry(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
deadline = time.time() + WHATSAPP_GATEWAY_START_RETRY_SECS
last_error: Exception | None = None
while time.time() < deadline and not self._stop_event.is_set():
try:
return await self._request_json(method, path, payload)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as ex:
last_error = ex
await asyncio.sleep(0.5)
if last_error is not None:
raise last_error
return await self._request_json(method, path, payload)
async def start(self) -> None:
async with self._lifecycle_lock:
if self._task and not self._task.done():
return
self._stop_event.clear()
_live_channels[self.account_id] = self
self._task = asyncio.create_task(self._run(), name=f"whatsapp-{self.account_id}")
LOGGER.info("[whatsapp:%s] starting gateway client", self.account_id)
async def stop(self) -> None:
async with self._lifecycle_lock:
self._stop_event.set()
task = self._task
if task and not task.done():
task.cancel()
try:
await task
except (asyncio.CancelledError, Exception):
pass
try:
await self._request_json(
"POST",
f"whatsapp/{self._session_key()}/stop",
{"account_id": self.account_id, "session_key": self._session_key()},
)
except Exception:
LOGGER.debug("[whatsapp:%s] gateway stop failed", self.account_id, exc_info=True)
try:
if self._http_session is not None and not self._http_session.closed:
await self._http_session.close()
finally:
self._http_session = None
_live_channels.pop(self.account_id, None)
self._task = None
self._status = "stopped"
self._last_error = ""
self._qr_data_url = ""
self._qr_updated_at = 0.0
self._connected_at = 0.0
self._last_snapshot_at = 0.0
self._session_id = ""
self._event_cursor = 0
self._seen_message_ids.clear()
async def send(self, message: OutgoingMessage) -> None:
if not message.chat_id:
LOGGER.error("[whatsapp:%s] missing chat_id; cannot send", self.account_id)
return
try:
LOGGER.info(
"[whatsapp:%s] sending reply chat_id=%s reply_to=%s text_preview=%r",
self.account_id,
message.chat_id,
message.reply_to_message_id,
message.text[:120],
)
await self._request_json(
"POST",
f"whatsapp/{self._session_key()}/send",
{
"account_id": self.account_id,
"session_key": self._session_key(),
"chat_id": message.chat_id,
"text": message.text,
"reply_to_message_id": message.reply_to_message_id,
},
)
LOGGER.info(
"[whatsapp:%s] message sent chat_id=%s reply_to=%s",
self.account_id,
message.chat_id,
message.reply_to_message_id,
)
except Exception:
LOGGER.error("[whatsapp:%s] send failed", self.account_id, exc_info=True)
async def _run(self) -> None:
self._status = "connecting"
self._last_error = ""
try:
await self._request_json_with_retry(
"POST",
f"whatsapp/{self._session_key()}/start",
{
"account_id": self.account_id,
"session_key": self._session_key(),
"gateway_base_url": self._gateway_base_url(),
},
)
while not self._stop_event.is_set():
try:
await self._run_events_ws()
except asyncio.CancelledError:
raise
except Exception as ex:
self._status = "error"
self._last_error = str(ex)
LOGGER.error("[whatsapp:%s] gateway event loop error", self.account_id, exc_info=True)
if not self._stop_event.is_set():
await asyncio.sleep(WHATSAPP_GATEWAY_RECONNECT_SECS)
except asyncio.CancelledError:
pass
except Exception as ex:
self._status = "error"
self._last_error = str(ex)
LOGGER.error("[whatsapp:%s] gateway runtime error", self.account_id, exc_info=True)
finally:
if not self._stop_event.is_set() and self._status != "error":
self._status = "disconnected"
if not self._last_error:
self._last_error = "WhatsApp gateway stopped unexpectedly."
self._last_snapshot_at = time.time()
def _apply_snapshot(self, snapshot: dict[str, Any]) -> None:
self._last_snapshot_at = time.time()
self._status = str(snapshot.get("status") or "connecting")
self._last_error = str(snapshot.get("last_error") or "")
self._qr_data_url = str(snapshot.get("qr_data_url") or "")
self._qr_updated_at = float(snapshot.get("qr_updated_at") or 0.0)
self._connected_at = float(snapshot.get("connected_at") or 0.0)
self._session_id = str(snapshot.get("session_id") or self._session_id or "")
self._event_cursor = max(self._event_cursor, int(snapshot.get("event_cursor") or 0))
if self._status == "connected":
self._last_error = ""
async def _run_events_ws(self) -> None:
session = await self._ensure_http_session()
url = self._events_ws_url()
async with session.ws_connect(url, headers=self._gateway_headers(), heartbeat=None) as ws:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await self._handle_ws_payload(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
raise RuntimeError(f"whatsapp events websocket error: {ws.exception()}")
LOGGER.warning("[whatsapp:%s] events websocket closed", self.account_id)
async def _handle_ws_payload(self, payload: str) -> None:
try:
obj = json.loads(payload)
except json.JSONDecodeError:
LOGGER.warning(
"[whatsapp:%s] ignored invalid websocket payload: %r",
self.account_id,
payload[:200],
)
return
kind = str(obj.get("type") or "").strip()
data = obj.get("data")
if kind == "snapshot" and isinstance(data, dict):
self._apply_snapshot(data)
return
if kind == "event" and isinstance(data, dict):
await self._handle_event_item(data)
return
async def _handle_event_item(self, item: dict[str, Any]) -> None:
seq = int(item.get("seq") or 0)
if seq > self._event_cursor:
self._event_cursor = seq
if item.get("kind") != "message":
return
message_id = str(item.get("message_id") or "").strip()
if not message_id:
return
self._prune_seen_message_ids()
if self._is_seen_message(message_id):
return
self._seen_message_ids[message_id] = time.time()
incoming = IncomingMessage(
channel=self.channel_id,
account_id=self.account_id,
chat_id=str(item.get("chat_id") or ""),
chat_type=str(item.get("chat_type") or "p2p"),
message_id=message_id,
sender_id=str(item.get("sender_id") or ""),
text=str(item.get("text") or ""),
raw=item.get("raw"),
)
LOGGER.info(
"[whatsapp:%s] inbound message_id=%s chat_id=%s",
self.account_id,
message_id,
incoming.chat_id,
)
await self._dispatch(incoming)
def _is_seen_message(self, message_id: str) -> bool:
return message_id in self._seen_message_ids
def _prune_seen_message_ids(self) -> None:
cutoff = time.time() - WHATSAPP_MESSAGE_TTL_SECS
stale = [key for key, ts in self._seen_message_ids.items() if ts < cutoff]
for key in stale:
self._seen_message_ids.pop(key, None)
def get_status_snapshot(self) -> dict[str, Any]:
return {
"account_id": self.account_id,
"session_key": self._session_key(),
"status": self._status,
"connected_at": self._connected_at or None,
"qr_updated_at": self._qr_updated_at or None,
"qr_data_url": self._qr_data_url or None,
"last_error": self._last_error or None,
"session_id": self._session_id or None,
"last_snapshot_at": self._last_snapshot_at or None,
"gateway_base_url": self._gateway_base_url() or None,
"event_cursor": self._event_cursor,
}
def _build(account_id: str, cfg: dict) -> Channel:
gateway_base_url = str(cfg.get("gateway_base_url") or cfg.get("gateway_url") or cfg.get("control_url") or _default_gateway_base_url())
gateway_token = str(cfg.get("gateway_token") or cfg.get("token") or "")
session_key = str(cfg.get("session_key") or cfg.get("session_id") or account_id)
timeout_secs = int(cfg.get("timeout_secs") or WHATSAPP_DEFAULT_TIMEOUT_SECS)
return WhatsAppChannel(
WhatsAppAccount(
account_id=account_id,
gateway_base_url=gateway_base_url,
gateway_token=gateway_token,
session_key=session_key,
timeout_secs=timeout_secs,
)
)
register_channel("whatsapp", _build)