ForcePilot/backend/package/yuxi/channel/extensions/farcaster/client.py
Kris 7c6186e9a4 feat(farcaster): 新增Farcaster去中心化社交协议通道插件
该提交完整实现了ForcePilot的Farcaster通道插件,包含:
1. 基础配置适配器与多账户支持
2. Neynar API客户端封装,带重试机制与签名验证
3. 消息去重处理模块
4. 网关服务与健康检查、轮询降级逻辑
5. Webhook回调端点与事件解析
6. 收发消息、 reactions、媒体发送等完整交互能力
7. 插件元数据与系统集成适配
2026-05-21 10:46:17 +08:00

342 lines
12 KiB
Python

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