import asyncio import json import logging import time from urllib.parse import parse_qs from fastapi import APIRouter, Header, Request from fastapi.responses import PlainTextResponse from yuxi.channel.extensions.farcaster.defaults import ( WEBHOOK_BODY_MAX_BYTES, WEBHOOK_BODY_TIMEOUT_S, ) from yuxi.channel.extensions.farcaster.monitor import FarcasterMonitor router = APIRouter(prefix="/farcaster/webhook", tags=["farcaster"]) logger = logging.getLogger(__name__) _GATEWAY_STATE: dict = {} def register_gateway_state(account_id: str, ctx, gateway, dedupe) -> None: handle = gateway.get_handle(account_id) bot_fid = handle.get("bot_fid", 0) if handle else 0 bot_fname = handle.get("bot_fname", "") if handle else "" monitor = FarcasterMonitor(bot_fid=bot_fid, bot_fname=bot_fname) _GATEWAY_STATE[account_id] = { "ctx": ctx, "gateway": gateway, "monitor": monitor, "dedupe": dedupe, } def unregister_gateway_state(account_id: str) -> None: _GATEWAY_STATE.pop(account_id, None) async def _dispatch_webhook_event(request: Request, account_id: str = "default") -> PlainTextResponse: body = await _read_body(request) if body is None: return PlainTextResponse("", status_code=413) x_neynar_signature = request.headers.get("X-Neynar-Signature", "") state = _GATEWAY_STATE.get(account_id) if state is None: logger.warning("Farcaster webhook: no active gateway state for account=%s", account_id) return PlainTextResponse("", status_code=503) gateway = state["gateway"] handle = gateway.get_handle(account_id) if handle is None or not handle.get("running", False): return PlainTextResponse("", status_code=503) client = handle.get("client") if x_neynar_signature and client: if not client.verify_webhook_signature(body, x_neynar_signature): logger.warning("Farcaster webhook: invalid signature") return PlainTextResponse("", status_code=401) handle["last_webhook_event_at"] = time.time() try: payload = json.loads(body) except json.JSONDecodeError: logger.warning("Farcaster webhook: invalid JSON body") return PlainTextResponse("", status_code=400) event_type = payload.get("type", "") event_data = payload.get("data", {}) ctx = state["ctx"] monitor = state["monitor"] dedupe = state["dedupe"] if event_type == "cast.created": await _handle_cast_event(ctx, event_data, handle, monitor, dedupe, account_id) elif event_type == "reaction.created": unified = monitor.convert_reaction_to_unified(event_data, account_id=account_id) if unified is not None and not dedupe.is_duplicate(unified.msg_id): queue = getattr(ctx, "queue", None) if queue is not None: try: queue.put_nowait(unified) except asyncio.QueueFull: logger.warning("Farcaster queue full, dropping reaction: %s", unified.msg_id) else: logger.debug("Farcaster unhandled event type: %s", event_type) return PlainTextResponse("ok") @router.post("") async def farcaster_webhook( request: Request, x_neynar_signature: str = Header("", alias="X-Neynar-Signature"), ): account_id = "default" parsed = parse_qs(str(request.url.query)) if "account_id" in parsed: account_id = parsed["account_id"][0] return await _dispatch_webhook_event(request, account_id) async def _handle_cast_event( ctx, cast_data: dict, handle: dict, monitor: FarcasterMonitor, dedupe, account_id: str = "default" ) -> None: text = cast_data.get("text", "") author = cast_data.get("author", {}) bot_fname = handle.get("bot_fname", "") bot_fid = handle.get("bot_fid", 0) if not _check_mention(text, bot_fname, bot_fid): logger.debug( "Farcaster cast: not mentioned, fid=%s text=%.100s", author.get("fid", "?"), text, ) return unified = monitor.convert_cast_to_unified(cast_data, account_id=account_id) if unified is None: return if dedupe.is_duplicate(unified.msg_id): return queue = getattr(ctx, "queue", None) if queue is not None: try: queue.put_nowait(unified) except asyncio.QueueFull: logger.warning("Farcaster queue full, dropping message: %s", unified.msg_id) def _check_mention(text: str, bot_fname: str, bot_fid: int) -> bool: if not bot_fname: return False return f"@{bot_fname}" in text or f"@fid:{bot_fid}" in text async def _read_body(request: Request) -> bytes | None: try: body = await asyncio.wait_for(request.body(), timeout=WEBHOOK_BODY_TIMEOUT_S) except TimeoutError: logger.warning("Farcaster webhook: body read timeout") return None if len(body) > WEBHOOK_BODY_MAX_BYTES: logger.warning("Farcaster webhook: body too large (%d bytes)", len(body)) return None return body