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

156 lines
5.0 KiB
Python

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