ForcePilot/backend/package/yuxi/channel/extensions/farcaster/gateway.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

176 lines
6.2 KiB
Python

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)