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