该提交完整实现了ForcePilot的Farcaster通道插件,包含: 1. 基础配置适配器与多账户支持 2. Neynar API客户端封装,带重试机制与签名验证 3. 消息去重处理模块 4. 网关服务与健康检查、轮询降级逻辑 5. Webhook回调端点与事件解析 6. 收发消息、 reactions、媒体发送等完整交互能力 7. 插件元数据与系统集成适配
176 lines
6.2 KiB
Python
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)
|