import asyncio import hashlib import hmac import secrets import uuid from functools import wraps import httpx from yuxi.channel.extensions.farcaster.defaults import ( CLIENT_TIMEOUT_S, RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY_S, RETRY_MAX_DELAY_S, ) NEYNAR_BASE_URL = "https://api.neynar.com/v2/farcaster" logger = __import__("logging").getLogger(__name__) class NeynarError(Exception): pass def _retry_on_network_error(max_attempts: int = RETRY_MAX_ATTEMPTS): def decorator(func): @wraps(func) async def wrapper(self, *args, **kwargs): last_exc = None for attempt in range(1, max_attempts + 1): try: return await func(self, *args, **kwargs) except httpx.HTTPStatusError as e: if e.response.status_code < 500: raise last_exc = e except (httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError) as e: last_exc = e if attempt < max_attempts: delay = min(RETRY_BASE_DELAY_S * (2 ** (attempt - 1)), RETRY_MAX_DELAY_S) logger.warning( "NeynarClient %s attempt %d/%d failed, retrying in %.1fs: %s", func.__name__, attempt, max_attempts, delay, last_exc, ) await asyncio.sleep(delay) raise last_exc or NeynarError("Retry exhausted") return wrapper return decorator class NeynarClient: def __init__( self, api_key: str, signer_uuid: str | None = None, webhook_secret: str | None = None, timeout: float = CLIENT_TIMEOUT_S, ): self._api_key = api_key self._signer_uuid = signer_uuid self._webhook_secret = webhook_secret or secrets.token_hex(32) self._client: httpx.AsyncClient | None = None self._timeout = timeout async def _ensure_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient( base_url=NEYNAR_BASE_URL, headers={ "accept": "application/json", "x-api-key": self._api_key, }, timeout=httpx.Timeout(self._timeout), ) return self._client async def get_me(self) -> dict: client = await self._ensure_client() resp = await client.get("/user/me") resp.raise_for_status() return resp.json()["result"]["user"] async def get_user_by_fid(self, fid: int) -> dict: client = await self._ensure_client() resp = await client.get("/user/bulk", params={"fids": str(fid)}) resp.raise_for_status() users = resp.json().get("users", []) if not users: raise NeynarError(f"User not found: fid={fid}") return users[0] async def get_user_by_fname(self, fname: str) -> dict: client = await self._ensure_client() resp = await client.get("/user/bulk", params={"fnames": fname}) resp.raise_for_status() users = resp.json().get("users", []) if not users: raise NeynarError(f"User not found: fname={fname}") return users[0] async def get_cast(self, cast_hash: str) -> dict: client = await self._ensure_client() resp = await client.get("/cast", params={"identifier": cast_hash, "type": "hash"}) resp.raise_for_status() return resp.json() async def get_notifications(self, fid: int, cursor: str | None = None, limit: int = 25) -> dict: client = await self._ensure_client() params = {"fid": fid, "limit": limit} if cursor: params["cursor"] = cursor resp = await client.get("/notifications/channel", params=params) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def post_cast( self, text: str, *, parent_hash: str | None = None, embeds: list[dict] | None = None, channel_id: str | None = None, ) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") body: dict = {"signer_uuid": self._signer_uuid, "text": text, "idem": str(uuid.uuid4())} if parent_hash: body["parent"] = parent_hash if embeds: body["embeds"] = embeds if channel_id: body["channel_id"] = channel_id client = await self._ensure_client() resp = await client.post("/cast", json=body) resp.raise_for_status() data = resp.json() if not data.get("success"): raise NeynarError(f"Cast publish failed: {data}") return data @_retry_on_network_error() async def delete_cast(self, cast_hash: str) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() resp = await client.delete( "/cast", json={"signer_uuid": self._signer_uuid, "target_hash": cast_hash}, ) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def post_reaction(self, target_hash: str, reaction_type: str = "like") -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") body = { "signer_uuid": self._signer_uuid, "type": reaction_type, "target": target_hash, "idem": str(uuid.uuid4()), } client = await self._ensure_client() resp = await client.post("/reaction", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def delete_reaction(self, target_hash: str, reaction_type: str = "like") -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") body = { "signer_uuid": self._signer_uuid, "type": reaction_type, "target": target_hash, } client = await self._ensure_client() resp = await client.delete("/reaction", json=body) resp.raise_for_status() return resp.json() async def register_webhook(self, url: str, filters: dict) -> dict: body = { "name": "ForcePilot Farcaster Bot", "url": url, "webhook_secret": self._webhook_secret, "subscription": filters, } client = await self._ensure_client() resp = await client.post("/webhook", json=body) resp.raise_for_status() return resp.json() async def get_webhooks(self) -> list[dict]: client = await self._ensure_client() resp = await client.get("/webhook") resp.raise_for_status() return resp.json().get("webhooks", []) async def delete_webhook(self, webhook_id: str) -> None: client = await self._ensure_client() resp = await client.delete("/webhook", params={"webhook_id": webhook_id}) resp.raise_for_status() async def validate_frame(self, message_bytes: str) -> dict: body = {"message_bytes_in_hex": message_bytes} client = await self._ensure_client() resp = await client.post("/frame/validate", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def follow_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() body = { "signer_uuid": self._signer_uuid, "target_fid": target_fid, "idem": str(uuid.uuid4()), } resp = await client.post("/user/follow", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def unfollow_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() resp = await client.delete( "/user/follow", json={ "signer_uuid": self._signer_uuid, "target_fid": target_fid, }, ) resp.raise_for_status() return resp.json() async def get_storage_usage(self, fid: int) -> dict: client = await self._ensure_client() resp = await client.get("/storage/usage", params={"fid": fid}) resp.raise_for_status() return resp.json() async def search_users(self, query: str, limit: int = 10) -> list[dict]: client = await self._ensure_client() params: dict = {"q": query} if limit: params["limit"] = limit resp = await client.get("/user/search", params=params) resp.raise_for_status() result = resp.json() return result.get("result", {}).get("users", []) @_retry_on_network_error() async def block_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() body = { "signer_uuid": self._signer_uuid, "blocked_fid": target_fid, "idem": str(uuid.uuid4()), } resp = await client.post("/block", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def unblock_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() body = { "signer_uuid": self._signer_uuid, "blocked_fid": target_fid, } resp = await client.delete("/block", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def mute_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() body = { "signer_uuid": self._signer_uuid, "muted_fid": target_fid, "idem": str(uuid.uuid4()), } resp = await client.post("/mute", json=body) resp.raise_for_status() return resp.json() @_retry_on_network_error() async def unmute_user(self, target_fid: int) -> dict: if not self._signer_uuid: raise NeynarError("signer_uuid required for write operations") client = await self._ensure_client() body = { "signer_uuid": self._signer_uuid, "muted_fid": target_fid, } resp = await client.delete("/mute", json=body) resp.raise_for_status() return resp.json() def verify_webhook_signature(self, body: bytes, signature: str) -> bool: expected = hmac.new( self._webhook_secret.encode(), body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature) @property def webhook_secret(self) -> str: return self._webhook_secret async def close(self) -> None: if self._client is not None: await self._client.aclose() self._client = None