from __future__ import annotations import asyncio import logging import httpx from yuxi.channel.extensions.workplace.dedupe import WorkplaceDeduplicator from yuxi.channel.extensions.workplace.errors import classify_error, ErrorCategory from yuxi.channel.extensions.workplace.monitor import WorkplaceMonitor logger = logging.getLogger(__name__) WORKPLACE_API_BASE = "https://graph.facebook.com" _target_queue: asyncio.Queue | None = None _active_gateway: WorkplaceGateway | None = None def _get_webhook_queue() -> asyncio.Queue | None: return _active_gateway._queue if _active_gateway else None def _get_active_gateway() -> WorkplaceGateway | None: return _active_gateway def set_target_queue(queue: asyncio.Queue) -> None: global _target_queue _target_queue = queue class WorkplaceGateway: def __init__(self, deduplicator: WorkplaceDeduplicator | None = None): self._running = False self._tasks: list[asyncio.Task] = [] self._account: dict = {} self._queue: asyncio.Queue | None = None self._deduplicator = deduplicator or WorkplaceDeduplicator(max_size=10000, ttl_seconds=300) self._monitor = WorkplaceMonitor() async def start(self, ctx) -> object: global _active_gateway account = await self._resolve_account(ctx) if not account.is_configured(): logger.warning("Workplace gateway: account not configured, id=%s", account.account_id) return {"running": False, "reason": "not-configured", "account_id": account.account_id} await self._probe_community(account) self._queue = asyncio.Queue(maxsize=2000) self._running = True _active_gateway = self cancel_event = getattr(ctx, "cancel_event", asyncio.Event()) task = asyncio.create_task( _consume_loop(self._queue, cancel_event, account, self._deduplicator, self._monitor), name=f"workplace-consume-{account.account_id}", ) self._tasks.append(task) logger.info( "Workplace gateway started: community_id=%s, account_id=%s", account.community_id, account.account_id, ) return {"running": True, "account_id": account.account_id, "queue": self._queue} async def stop(self, ctx) -> None: global _active_gateway self._running = False for task in self._tasks: task.cancel() try: await task except asyncio.CancelledError: pass self._tasks.clear() self._queue = None _active_gateway = None logger.info("Workplace gateway stopped") async def _resolve_account(self, ctx): from yuxi.channel.extensions.workplace.types import WorkplaceAccount account_id = getattr(ctx, "account_id", "default") config = getattr(ctx, "config", {}) from yuxi.channel.extensions.workplace.config import WorkplaceConfigAdapter adapter = WorkplaceConfigAdapter() raw = await adapter.resolve_account(account_id, config) return WorkplaceAccount( account_id=raw["account_id"], community_id=raw.get("community_id", ""), page_id=raw.get("page_id", ""), access_token=raw.get("access_token", ""), app_id=raw.get("app_id", ""), app_secret=raw.get("app_secret", ""), verify_token=raw.get("verify_token", ""), graph_api_version=raw.get("graph_api_version", "v24.0"), ) async def _probe_community(self, account) -> None: url = f"{WORKPLACE_API_BASE}/{account.graph_api_version}/community" async with httpx.AsyncClient(timeout=15.0) as client: try: resp = await client.get(url, params={"access_token": account.access_token}) if resp.status_code == 200: data = resp.json() account.community_id = data.get("id", account.community_id) logger.info("Workplace community probed: id=%s", account.community_id) else: error = classify_error(resp.status_code, resp.json() if resp.content else {}) if error.category == ErrorCategory.TOKEN: logger.error("Workplace token invalid: %s", error.message) else: logger.warning("Workplace probe returned %d: %s", resp.status_code, error.message) except httpx.RequestError as exc: logger.error("Workplace probe network error: %s", exc) async def _consume_loop( queue: asyncio.Queue, cancel_event: asyncio.Event, account, deduplicator: WorkplaceDeduplicator, monitor: WorkplaceMonitor, ) -> None: logger.info("Workplace consume loop started for account_id=%s", account.account_id) while not cancel_event.is_set(): try: event = await asyncio.wait_for(queue.get(), timeout=1.0) except TimeoutError: continue except asyncio.CancelledError: break try: if "field" in event and "value" in event: unified = monitor.parse_change_to_unified(event, account) else: msg_id = event.get("message", {}).get("mid", "") if msg_id and deduplicator.is_duplicate(msg_id): logger.debug("Duplicate message skipped: mid=%s", msg_id) continue unified = monitor.parse_to_unified(event, account) if unified is None: logger.debug("Event not convertible to UnifiedMessage: type=%s", event.get("event_type")) continue if _target_queue is not None: await _target_queue.put(unified) logger.debug("Workplace message dispatched: sender=%s", unified.sender.id) except Exception: logger.exception("Error processing Workplace event: %s", event) logger.info("Workplace consume loop stopped")