import asyncio import time import logging from yuxi.channel.extensions.whatsapp.monitor import parse_webhook_to_unified, parse_statuses from yuxi.channel.extensions.whatsapp.types import WhatsAppAccount logger = logging.getLogger(__name__) class WhatsAppGateway: def __init__(self, config_adapter, deduplicator): self._config_adapter = config_adapter self._deduplicator = deduplicator self._running = False self._last_message_at: float | None = None self._tasks: list[asyncio.Task] = [] self._queue: asyncio.Queue | None = None @property def last_message_at(self) -> float | None: return self._last_message_at def get_queue(self) -> asyncio.Queue | None: return self._queue async def start(self, ctx) -> object: account = self._resolve_account(ctx) if not account.is_configured(): logger.warning("whatsapp account %s not configured, skipping start", account.account_id) return {"running": False, "reason": "not-configured"} self._queue = asyncio.Queue(maxsize=1000) self._running = True task = asyncio.create_task(self._webhook_processing_loop(account)) self._tasks.append(task) logger.info("whatsapp gateway started for account %s", account.account_id) return {"running": True, "account_id": account.account_id} async def stop(self, ctx) -> None: self._running = False for task in self._tasks: task.cancel() self._tasks.clear() self._queue = None logger.info("whatsapp gateway stopped") async def enqueue_webhook(self, payload: dict) -> None: if self._queue is not None: await self._queue.put(payload) async def _webhook_processing_loop(self, account: WhatsAppAccount): while self._running and self._queue is not None: try: payload = await asyncio.wait_for(self._queue.get(), timeout=1.0) await self._process_payload(payload, account) except TimeoutError: continue except asyncio.CancelledError: break except Exception: logger.exception("Error processing webhook payload") async def _process_payload(self, payload: dict, account: WhatsAppAccount): messages = parse_webhook_to_unified(payload, account) status_events = parse_statuses(payload, account) for status in status_events: logger.info( "WhatsApp status: msg_id=%s status=%s recipient=%s", status["msg_id"], status["status"], status["recipient_id"], ) if status["errors"]: logger.warning("WhatsApp message error: %s", status["errors"]) entries = payload.get("entry", []) for entry in entries: for change in entry.get("changes", []): value = change.get("value", {}) for err in value.get("errors", []): logger.error( "WhatsApp webhook error: code=%s title=%s", err.get("code"), err.get("title"), ) for unified_msg in messages: if self._is_echo(unified_msg, account): continue if self._deduplicator.is_duplicate(unified_msg.get("msg_id", "")): logger.debug("Duplicate message ignored: %s", unified_msg.get("msg_id")) continue target_queue = self._resolve_target_queue(unified_msg) if target_queue: self._last_message_at = time.monotonic() await target_queue.put(unified_msg) logger.debug("WhatsApp message enqueued: sender=%s", unified_msg.get("sender", {}).get("id")) @staticmethod def _is_echo(msg: dict, account: WhatsAppAccount) -> bool: sender = msg.get("sender", {}) sender_id = sender.get("id", "") return sender_id == account.phone_number_id def _resolve_target_queue(self, _msg: dict) -> asyncio.Queue | None: return self._queue def _resolve_account(self, ctx) -> WhatsAppAccount: config = getattr(ctx, "config", {}) if ctx else {} accounts = config.get("accounts", {}) account_id = getattr(ctx, "account_id", "default") raw = accounts.get(account_id, {}) account_dict = self._config_adapter._build_account(account_id, raw) field_names = {f.name for f in WhatsAppAccount.__dataclass_fields__.values()} return WhatsAppAccount(**{k: v for k, v in account_dict.items() if k in field_names})