import asyncio import logging import time from yuxi.channel.extensions.farcaster.client import NeynarClient, NeynarError from yuxi.channel.extensions.farcaster.defaults import ( GATEWAY_FALLBACK_THRESHOLD_S, GATEWAY_HEALTH_CHECK_INTERVAL, ) logger = logging.getLogger(__name__) class FarcasterGatewayError(Exception): pass class FarcasterGateway: def __init__(self): self._handles: dict[str, dict] = {} async def start(self, ctx) -> dict: account_id = getattr(ctx, "account_id", "default") config = getattr(ctx, "config", {}) or {} channels = config.get("channels", {}) fc_cfg = channels.get("farcaster", {}) if isinstance(channels, dict) else {} api_key = fc_cfg.get("api_key", "") signer_uuid = fc_cfg.get("signer_uuid", "") webhook_base_url = fc_cfg.get("webhook_base_url", "") if not api_key: raise FarcasterGatewayError("api_key not configured") client = NeynarClient(api_key=api_key, signer_uuid=signer_uuid) try: me = await client.get_me() except NeynarError as e: await client.close() raise FarcasterGatewayError(f"Failed to verify API key: {e}") from e bot_fid = me.get("fid", 0) bot_fname = me.get("username", "") logger.info( "Farcaster bot verified: fid=%s fname=%s display_name=%s", bot_fid, bot_fname, me.get("display_name", ""), ) handle = { "client": client, "webhook_id": None, "bot_fid": bot_fid, "bot_fname": bot_fname, "fallback_task": None, "last_webhook_event_at": time.time(), "running": True, } if webhook_base_url and bot_fid: webhook_url = f"{webhook_base_url.rstrip('/')}/farcaster/webhook?account_id={account_id}" filters = { "cast.created": { "author_fids": [bot_fid], "mentioned_fids": [bot_fid], }, "reaction.created": { "author_fids": [bot_fid], }, } try: result = await client.register_webhook(webhook_url, filters) webhook_obj = result.get("webhook", {}) handle["webhook_id"] = webhook_obj.get("object") if isinstance(webhook_obj, dict) else None logger.info("Neynar webhook registered: id=%s", handle["webhook_id"]) except NeynarError as e: logger.warning("Webhook registration failed, will rely on polling fallback: %s", e) handle["fallback_task"] = asyncio.create_task( self._health_check_loop(account_id, handle, ctx), name=f"farcaster-health-{account_id}", ) self._handles[account_id] = handle return handle async def stop(self, ctx) -> None: account_id = getattr(ctx, "account_id", "default") handle = self._handles.pop(account_id, None) if handle is None: return handle["running"] = False fallback_task = handle.get("fallback_task") if fallback_task: fallback_task.cancel() try: await fallback_task except (asyncio.CancelledError, Exception): pass webhook_id = handle.get("webhook_id") client = handle.get("client") if webhook_id and client: try: await client.delete_webhook(webhook_id) logger.info("Neynar webhook deleted: id=%s", webhook_id) except NeynarError as e: logger.warning("Failed to delete webhook: %s", e) if client: await client.close() logger.info("Farcaster gateway stopped for account %s", account_id) def get_handle(self, account_id: str = "default") -> dict | None: return self._handles.get(account_id) @property def account_count(self) -> int: return len(self._handles) async def _health_check_loop(self, account_id: str, handle: dict, ctx) -> None: STORAGE_WARN_THRESHOLD = 0.9 while handle.get("running", False): await asyncio.sleep(GATEWAY_HEALTH_CHECK_INTERVAL) elapsed = time.time() - handle.get("last_webhook_event_at", 0) if elapsed > GATEWAY_FALLBACK_THRESHOLD_S: logger.warning( "Farcaster webhook unhealthy for %s (%ss since last event), falling back to polling", account_id, int(elapsed), ) await self._poll_notifications(handle, ctx) client = handle.get("client") bot_fid = handle.get("bot_fid") if client and bot_fid: try: storage = await client.get_storage_usage(bot_fid) used = storage.get("used", 0) limit = storage.get("limit", 0) if limit > 0 and used / limit > STORAGE_WARN_THRESHOLD: logger.warning( "Farcaster storage nearly exhausted: fid=%s used=%s limit=%s ratio=%.1f%%", bot_fid, used, limit, used / limit * 100, ) except NeynarError: pass async def _poll_notifications(self, handle: dict, ctx) -> None: client = handle.get("client") bot_fid = handle.get("bot_fid") if not client or not bot_fid: return try: result = await client.get_notifications(bot_fid, limit=25) notifications = result.get("notifications", []) for notif in notifications: event_type = notif.get("type", "") if event_type in ("cast-reply", "mentions"): cast = notif.get("cast", {}) if cast: queue = getattr(ctx, "queue", None) if queue is not None: await queue.put({"type": "cast.created", "data": cast}) except NeynarError as e: logger.error("Polling fallback failed: %s", e)