feat(farcaster): 新增Farcaster去中心化社交协议通道插件

该提交完整实现了ForcePilot的Farcaster通道插件,包含:
1. 基础配置适配器与多账户支持
2. Neynar API客户端封装,带重试机制与签名验证
3. 消息去重处理模块
4. 网关服务与健康检查、轮询降级逻辑
5. Webhook回调端点与事件解析
6. 收发消息、 reactions、媒体发送等完整交互能力
7. 插件元数据与系统集成适配
This commit is contained in:
Kris 2026-05-21 10:46:17 +08:00
parent 59c6caaa64
commit 7c6186e9a4
12 changed files with 1815 additions and 0 deletions

View File

@ -0,0 +1,503 @@
import logging
import httpx
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.extensions.farcaster.client import NeynarClient, NeynarError
from yuxi.channel.extensions.farcaster.config import FarcasterConfigAdapter
from yuxi.channel.extensions.farcaster.defaults import DEDUPE_TTL_S, DEDUPE_MAX_TRACKED
from yuxi.channel.extensions.farcaster.dedupe import FarcasterDeduplicator
from yuxi.channel.extensions.farcaster.direct_cast_client import DirectCastClient, DirectCastError
from yuxi.channel.extensions.farcaster.gateway import FarcasterGateway, FarcasterGatewayError
from yuxi.channel.extensions.farcaster.outbound import FarcasterOutboundAdapter
from yuxi.channel.extensions.farcaster.webhook import (
register_gateway_state,
unregister_gateway_state,
_dispatch_webhook_event,
)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
from yuxi.channel.protocols import ClassifiedError, ErrorSeverity
logger = logging.getLogger(__name__)
class FarcasterPlugin(BaseChannelPlugin):
id = "farcaster"
name = "Farcaster"
order = 105
label = "Farcaster"
aliases = ["fc"]
resolve_reply_to_mode = "thread"
def __init__(self):
super().__init__()
self._config = FarcasterConfigAdapter()
self._gateway = FarcasterGateway()
self._outbound: FarcasterOutboundAdapter | None = None
self._dedupe = FarcasterDeduplicator()
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct"],
message_types=["text"],
interactions=["recast"],
reactions=True,
typing_indicator=False,
threads=True,
edit=False,
unsend=True,
reply=True,
media=True,
native_commands=False,
polls=False,
streaming=True,
streaming_mode="block",
block_streaming=True,
block_streaming_chunk_min_chars=240,
block_streaming_chunk_max_chars=300,
block_streaming_chunk_break_preference="paragraph",
)
# ── ConfigProtocol ──────────────────────────────────
def list_account_ids(self, config: dict) -> list[str]:
return self._config.list_account_ids(config)
async def resolve_account(self, account_id: str) -> dict:
return await self._config.resolve_account(account_id)
def is_configured(self, account: dict) -> bool:
return self._config.is_configured(account)
def is_enabled(self, account: dict) -> bool:
return self._config.is_enabled(account)
def disabled_reason(self, account: dict) -> str:
return self._config.disabled_reason(account)
def describe_account(self, account: dict) -> dict:
return self._config.describe_account(account)
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
account = await self._config.resolve_account(account_id)
return account.get("allow_from", [])
def config_schema(self) -> dict:
return self._config.config_schema()
# ── GatewayProtocol ─────────────────────────────────
async def start(self, ctx: ChannelContext) -> object:
account_id = getattr(ctx, "account_id", "default")
self._config.update_config(ctx.config)
account = await self._config.resolve_account(account_id)
if not account.get("configured"):
raise FarcasterGatewayError("Farcaster account not configured: missing api_key")
self._outbound = FarcasterOutboundAdapter(self._client_getter, account_id=account_id)
handle = await self._gateway.start(ctx)
register_gateway_state(account_id, ctx, self._gateway, self._dedupe)
logger.info(
"Farcaster plugin started: account=%s fid=%s fname=%s",
account_id,
handle.get("bot_fid"),
handle.get("bot_fname"),
)
return handle
async def stop(self, ctx: ChannelContext) -> None:
account_id = getattr(ctx, "account_id", "default")
unregister_gateway_state(account_id)
await self._gateway.stop(ctx)
self._outbound = None
# ── OutboundProtocol ────────────────────────────────
@property
def delivery_mode(self):
if self._outbound is None:
from yuxi.channel.protocols import OutboundDeliveryMode
return OutboundDeliveryMode.DIRECT
return self._outbound.delivery_mode
@property
def text_chunk_limit(self) -> int | None:
if self._outbound is None:
return 320
return self._outbound.text_chunk_limit
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if self._outbound is None:
logger.error("Farcaster send_text: outbound not initialized")
return
resolved = await self._config.resolve_account(account_id or "default")
channel_id = resolved.get("channel_id", "")
await self._outbound.send_text(
target_id,
content,
reply_to_id=reply_to_id,
thread_id=thread_id,
account_id=account_id,
channel_id=channel_id or None,
)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if self._outbound is None:
return
await self._outbound.send_media(
target_id,
media_url,
media_type,
reply_to_id=reply_to_id,
thread_id=thread_id,
account_id=account_id,
)
async def send_recast(
self,
target_hash: str,
*,
account_id: str | None = None,
) -> None:
if self._outbound is None:
logger.error("Farcaster send_recast: outbound not initialized")
return
await self._outbound.send_reaction(target_hash, reaction_type="recast")
async def delete_cast(
self,
cast_hash: str,
*,
account_id: str | None = None,
) -> None:
client = self._client_getter(account_id or "default")
if client is None:
logger.error("Farcaster delete_cast: no active client")
return
try:
await client.delete_cast(cast_hash)
logger.info("Farcaster cast deleted: hash=%s", cast_hash)
except NeynarError as e:
logger.error("Farcaster delete_cast failed: %s", e)
async def send_direct_cast(
self,
recipient_fid: int,
content: str,
*,
account_id: str | None = None,
) -> None:
account = await self._config.resolve_account(account_id or "default")
api_key = account.get("api_key", "")
signer_uuid = account.get("signer_uuid", "")
if not api_key or not signer_uuid:
logger.error("Farcaster send_direct_cast: missing api_key or signer_uuid")
return
dc_client = DirectCastClient(api_key=api_key, signer_uuid=signer_uuid)
try:
await dc_client.send_direct_cast(recipient_fid, content)
logger.info("Farcaster direct cast sent to fid=%s", recipient_fid)
except DirectCastError as e:
logger.error("Farcaster send_direct_cast failed: %s", e)
finally:
await dc_client.close()
async def follow_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.follow_user(target_fid)
async def unfollow_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.unfollow_user(target_fid)
async def search_users(
self,
query: str,
*,
limit: int = 10,
account_id: str | None = None,
) -> list[dict]:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.search_users(query, limit=limit)
async def block_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.block_user(target_fid)
async def unblock_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.unblock_user(target_fid)
async def mute_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.mute_user(target_fid)
async def unmute_user(
self,
target_fid: int,
*,
account_id: str | None = None,
) -> dict:
client = self._client_getter(account_id or "default")
if client is None:
raise NeynarError("No active Farcaster client")
return await client.unmute_user(target_fid)
# ── StatusProtocol ──────────────────────────────────
async def probe(self, account: dict | None = None) -> bool:
api_key = ""
if account:
api_key = account.get("api_key", "")
else:
resolved = await self._config.resolve_account("default")
api_key = resolved.get("api_key", "")
if not api_key:
return False
client = NeynarClient(api_key=api_key)
try:
await client.get_me()
return True
except NeynarError:
return False
finally:
await client.close()
def build_summary(self, snapshot: object) -> dict:
return {"channel": "farcaster", "accounts": self._gateway.account_count}
# ── SecurityProtocol ────────────────────────────────
async def check_allowlist(self, peer_id: str, channel_type: str, account_id: str = "default") -> bool:
account = await self._config.resolve_account(account_id)
allow_from = account.get("allow_from", [])
if not allow_from:
return False
fid = peer_id.replace("farcaster:", "").strip()
return fid in allow_from or str(fid) in allow_from
def resolve_dm_policy(self) -> dict:
return {"mode": "pairing", "allow_from": []}
# ── AgentPromptProtocol ─────────────────────────────
@property
def channel_format_instructions(self) -> str | None:
return (
"You are connected to Farcaster, a decentralized social protocol. "
"Messages are public Casts visible to everyone on the network. "
"Each Cast is limited to 320 UTF-8 bytes. Longer responses will be automatically "
"split into multiple Cast replies in the same thread. "
"No markdown formatting is supported — use plain text only. "
"URLs placed in Casts are automatically rendered as embeds (images, links) by clients. "
"Use @username to mention other users. "
"Farcaster community appreciates concise, Web3-native communication style. "
"Keep answers brief, direct, and informative."
)
# ── DedupeProtocol ──────────────────────────────────
def is_duplicate(self, key: str) -> bool:
return self._dedupe.is_duplicate(key)
def mark_seen(self, key: str) -> None:
self._dedupe.is_duplicate(key)
def reset(self) -> None:
self._dedupe.reset()
@property
def ttl_seconds(self) -> int:
return DEDUPE_TTL_S
@property
def max_entries(self) -> int:
return DEDUPE_MAX_TRACKED
# ── InboundHandlerProtocol ──────────────────────────
def resolve_event_type(self, raw_event: dict) -> str:
return raw_event.get("type", "unknown")
def parse_to_unified(
self,
raw_event: dict,
account_id: str,
) -> object | None:
from yuxi.channel.extensions.farcaster.monitor import FarcasterMonitor
event_type = raw_event.get("type", "")
event_data = raw_event.get("data", {})
handle = self._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)
if event_type == "cast.created":
return monitor.convert_cast_to_unified(event_data, account_id=account_id)
elif event_type == "reaction.created":
return monitor.convert_reaction_to_unified(event_data, account_id=account_id)
return None
async def handle_raw_event(
self,
event: dict,
account: dict,
) -> object | None:
return self.parse_to_unified(event, account.get("account_id", "default"))
# ── MentionsProtocol ────────────────────────────────
def extract_mentions(self, raw_message: dict) -> list[str]:
from yuxi.channel.extensions.farcaster.monitor import FarcasterMonitor
text = raw_message.get("text", "")
if not text:
return []
monitor = FarcasterMonitor()
return monitor.extract_mentions(text)
# ── FormatProtocol ──────────────────────────────────
def markdown_to_native(self, md_text: str) -> dict | str:
return _strip_markdown_formatting(md_text)
def native_to_markdown(self, native_content: dict | str) -> str:
if isinstance(native_content, dict):
return native_content.get("text", "")
return str(native_content)
# ── WebhookProtocol ─────────────────────────────────
def verify_signature(self, body: bytes, signature: str, secret: str) -> bool:
import hashlib
import hmac
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
async def handle_webhook(
self,
request: object,
account_id: str | None = None,
) -> object:
return await _dispatch_webhook_event(request, account_id or "default")
def resolve_webhook_auth_bypass_paths(self) -> list[str]:
return ["/farcaster/webhook"]
# ── ErrorHandlingProtocol ───────────────────────────
def classify_error(self, error: BaseException) -> ClassifiedError:
severity = self._classify_severity(error)
return ClassifiedError(severity=severity, original_error=error, error_message=str(error))
def is_retryable(self, error: BaseException) -> bool:
return self._classify_severity(error) == ErrorSeverity.RETRYABLE
def _classify_severity(self, error: BaseException) -> ErrorSeverity:
if isinstance(error, NeynarError):
return ErrorSeverity.RETRYABLE
if isinstance(error, FarcasterGatewayError):
return ErrorSeverity.FATAL
if isinstance(error, httpx.HTTPStatusError):
status = error.response.status_code
if status == 429:
return ErrorSeverity.RATE_LIMITED
if status in (401, 403):
return ErrorSeverity.FORBIDDEN
if status >= 500:
return ErrorSeverity.RETRYABLE
return ErrorSeverity.FATAL
if isinstance(error, (httpx.TimeoutException, httpx.ConnectError, httpx.RemoteProtocolError)):
return ErrorSeverity.NETWORK
classified = super().classify_error(error)
return classified.severity if hasattr(classified, "severity") else ErrorSeverity.FATAL
# ── Helpers ─────────────────────────────────────────
def _client_getter(self, account_id: str = "default"):
handle = self._gateway.get_handle(account_id)
return handle.get("client") if handle else None
def _strip_markdown_formatting(text: str) -> str:
import re
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"\*(.+?)\*", r"\1", text)
text = re.sub(r"~~(.+?)~~", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
text = re.sub(r"!?\[([^\]]*)\]\([^)]+\)", r"\1", text)
return text.strip()
farcaster_plugin = FarcasterPlugin()
ChannelPluginRegistry.register(farcaster_plugin)

View File

@ -0,0 +1,341 @@
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

View File

@ -0,0 +1,172 @@
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
ENV_NEYNAR_API_KEY = "NEYNAR_API_KEY"
ENV_NEYNAR_SIGNER_UUID = "NEYNAR_SIGNER_UUID"
class FarcasterConfigAdapter:
def __init__(self):
self._config: dict = {}
def update_config(self, config: dict) -> None:
self._config = config
def list_account_ids(self, config: dict) -> list[str]:
self._config = config
fc_cfg = self._resolve_fc_config(config)
accounts = fc_cfg.get("accounts", {}) if isinstance(fc_cfg, dict) else {}
if accounts:
return list(accounts.keys())
return ["default"]
async def resolve_account(self, account_id: str) -> dict:
fc_cfg = self._get_farcaster_config()
accounts = fc_cfg.get("accounts", {}) if isinstance(fc_cfg, dict) else {}
raw = accounts.get(account_id, {}) if isinstance(accounts, dict) else {}
base = {k: v for k, v in fc_cfg.items() if k != "accounts"}
api_key = (
raw.get("api_key")
or base.get("api_key", "")
or _read_file(raw.get("api_key_file", "") or base.get("api_key_file", ""))
or os.environ.get(ENV_NEYNAR_API_KEY, "")
)
signer_uuid = (
raw.get("signer_uuid") or base.get("signer_uuid", "") or os.environ.get(ENV_NEYNAR_SIGNER_UUID, "")
)
return {
"account_id": account_id,
"name": raw.get("name", account_id),
"enabled": raw.get("enabled", base.get("enabled", True)),
"configured": bool(api_key),
"api_key": api_key,
"signer_uuid": signer_uuid,
"fid": raw.get("fid", base.get("fid", 0)),
"fname": raw.get("fname", base.get("fname", "")),
"dm_policy": raw.get("dm_policy", base.get("dm_policy", "pairing")),
"allow_from": raw.get("allow_from", base.get("allow_from", [])),
"webhook_base_url": raw.get("webhook_base_url", base.get("webhook_base_url", "")),
"channel_id": raw.get("channel_id", base.get("channel_id", "")),
}
def is_configured(self, account: dict) -> bool:
return bool(account.get("api_key"))
def is_enabled(self, account: dict) -> bool:
return account.get("enabled", True)
def disabled_reason(self, account: dict) -> str:
if not account.get("api_key"):
return "Neynar API Key is required"
if not account.get("signer_uuid"):
return "Signer UUID is required for write operations"
return ""
def describe_account(self, account: dict) -> dict:
return {
"account_id": account.get("account_id", ""),
"name": account.get("name", ""),
"configured": account.get("configured", False),
"fname": account.get("fname", ""),
}
def config_schema(self) -> dict:
return {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True, "title": "启用"},
"api_key": {
"type": "string",
"title": "Neynar API Key",
"description": "从 dev.neynar.com 获取的 API Key",
"sensitive": True,
},
"signer_uuid": {
"type": "string",
"title": "Signer UUID",
"description": "Neynar 托管签名的 UUID写操作必需",
"sensitive": True,
},
"fid": {
"type": "integer",
"title": "Bot FID",
"description": "Bot 的 Farcaster FID",
},
"fname": {
"type": "string",
"title": "Bot Username",
"description": "Bot 的 Farcaster 用户名(用于 @提及匹配)",
},
"channel_id": {
"type": "string",
"title": "默认频道 ID",
"description": "Farcaster Channel ID所有公开 Cast 默认发布到此频道",
},
"dm_policy": {
"type": "string",
"enum": ["pairing", "allowlist", "open", "disabled"],
"default": "pairing",
"title": "DM 安全策略",
"description": "pairing=配对码模式, allowlist=白名单, open=开放, disabled=禁用",
},
"allow_from": {
"type": "array",
"items": {"type": "string"},
"title": "允许列表",
"description": 'FID 白名单(如 ["1234", "5678"])',
},
"webhook_base_url": {
"type": "string",
"title": "Webhook 回调地址",
"description": "Neynar Webhook 推送的公网地址(如 https://forcepilot.example.com",
},
"default_account": {"type": "string", "default": "default"},
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {"type": "string"},
"enabled": {"type": "boolean", "default": True},
"api_key": {"type": "string", "sensitive": True},
"signer_uuid": {"type": "string", "sensitive": True},
"fid": {"type": "integer"},
"fname": {"type": "string"},
"channel_id": {"type": "string"},
"dm_policy": {
"type": "string",
"enum": ["pairing", "allowlist", "open", "disabled"],
},
"allow_from": {"type": "array", "items": {"type": "string"}},
"webhook_base_url": {"type": "string"},
},
},
},
},
}
@staticmethod
def _resolve_fc_config(config: dict) -> dict:
channels = config.get("channels", {})
fc_cfg = channels.get("farcaster", {})
return fc_cfg if isinstance(fc_cfg, dict) else {}
def _get_farcaster_config(self) -> dict:
return self._resolve_fc_config(self._config)
def _read_file(filepath: str) -> str:
if not filepath:
return ""
try:
path = Path(filepath)
if path.exists():
return path.read_text(encoding="utf-8").strip()
except OSError:
pass
return ""

View File

@ -0,0 +1,30 @@
import time
from collections import OrderedDict
from yuxi.channel.extensions.farcaster.defaults import DEDUPE_MAX_TRACKED, DEDUPE_TTL_S
class FarcasterDeduplicator:
def __init__(self):
self._seen: OrderedDict[str, float] = OrderedDict()
def is_duplicate(self, msg_id: str) -> bool:
self._evict_expired()
if msg_id in self._seen:
return True
self._seen[msg_id] = time.time()
self._trim()
return False
def reset(self) -> None:
self._seen.clear()
def _evict_expired(self) -> None:
now = time.time()
expired = [k for k, v in self._seen.items() if now - v > DEDUPE_TTL_S]
for key in expired:
self._seen.pop(key, None)
def _trim(self) -> None:
while len(self._seen) > DEDUPE_MAX_TRACKED:
self._seen.popitem(last=False)

View File

@ -0,0 +1,18 @@
CAST_BYTE_LIMIT = 320
BLOCK_CHUNK_MIN = 240
BLOCK_CHUNK_MAX = 300
WEBHOOK_BODY_MAX_BYTES = 128 * 1024
WEBHOOK_BODY_TIMEOUT_S = 5.0
GATEWAY_HEALTH_CHECK_INTERVAL = 30
GATEWAY_FALLBACK_THRESHOLD_S = 120
DEDUPE_MAX_TRACKED = 10000
DEDUPE_TTL_S = 600
RETRY_MAX_ATTEMPTS = 3
RETRY_BASE_DELAY_S = 1.0
RETRY_MAX_DELAY_S = 10.0
CLIENT_TIMEOUT_S = 30.0

View File

@ -0,0 +1,57 @@
import logging
import uuid
import httpx
WARPCAST_API_BASE = "https://client.warpcast.com/v2"
logger = logging.getLogger(__name__)
class DirectCastError(Exception):
pass
class DirectCastClient:
def __init__(self, api_key: str, signer_uuid: str, timeout: float = 30.0):
self._api_key = api_key
self._signer_uuid = signer_uuid
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=WARPCAST_API_BASE,
headers={
"accept": "application/json",
"x-api-key": self._api_key,
},
timeout=httpx.Timeout(self._timeout),
)
return self._client
async def send_direct_cast(
self,
recipient_fid: int,
text: str,
*,
embeds: list[dict] | None = None,
) -> dict:
client = await self._ensure_client()
body: dict = {
"recipient_fid": recipient_fid,
"text": text,
"signer_uuid": self._signer_uuid,
"idem": str(uuid.uuid4()),
}
if embeds:
body["embeds"] = embeds
resp = await client.post("/ext-send-direct-cast", json=body)
resp.raise_for_status()
return resp.json()
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None

View File

@ -0,0 +1,175 @@
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)

View File

@ -0,0 +1,132 @@
import logging
import re
from datetime import datetime
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class FarcasterMonitor:
def __init__(self, bot_fid: int = 0, bot_fname: str = ""):
self._bot_fid = bot_fid
self._bot_fname = bot_fname
def convert_cast_to_unified(
self,
cast: dict,
*,
account_id: str = "default",
) -> UnifiedMessage | None:
text = cast.get("text", "")
author = cast.get("author", {})
author_fid = author.get("fid")
if author_fid is None:
logger.warning("Farcaster cast missing author.fid, skipping")
return None
if author_fid == self._bot_fid:
return None
clean_text = _strip_bot_mentions(text, self._bot_fname, self._bot_fid) if self._bot_fname else text
display_name = author.get("display_name") or author.get("username") or f"fid:{author_fid}"
timestamp = None
ts_str = cast.get("timestamp", "")
if ts_str:
try:
timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except (ValueError, TypeError):
pass
embeds = cast.get("embeds", [])
media_urls = []
for embed in embeds:
if isinstance(embed, dict) and "url" in embed:
media_urls.append(embed["url"])
return UnifiedMessage(
msg_id=_make_msg_id("cast", cast.get("hash", "")),
channel_type="farcaster",
account_id=account_id,
content=clean_text or text,
sender=PeerInfo(
kind=PeerKind.DIRECT,
id=f"farcaster:{author_fid}",
display_name=display_name,
),
message_type=MessageType.TEXT,
media_urls=media_urls if media_urls else None,
timestamp=timestamp,
reply_to_id=cast.get("parent_hash"),
raw_payload=cast,
metadata={
"cast_hash": cast.get("hash", ""),
"thread_hash": cast.get("thread_hash"),
"author_fid": author_fid,
"author_fname": author.get("username", ""),
"pfp_url": author.get("pfp_url"),
"channel": cast.get("channel"),
},
)
def convert_reaction_to_unified(
self,
reaction: dict,
*,
account_id: str = "default",
) -> UnifiedMessage | None:
reaction_type = reaction.get("reaction_type", "")
user = reaction.get("user", {})
cast = reaction.get("cast", {})
user_fid = user.get("fid")
if user_fid is None:
return None
if user_fid == self._bot_fid:
return None
display_name = user.get("display_name") or user.get("username") or f"fid:{user_fid}"
cast_hash = cast.get("hash", "")
return UnifiedMessage(
msg_id=_make_msg_id("reaction", f"{cast_hash}:{user_fid}:{reaction_type}"),
channel_type="farcaster",
account_id=account_id,
content=f"[{reaction_type}] cast/{cast_hash}",
sender=PeerInfo(
kind=PeerKind.DIRECT,
id=f"farcaster:{user_fid}",
display_name=display_name,
),
message_type=MessageType.EVENT,
raw_payload=reaction,
timestamp=datetime.now(datetime.UTC),
metadata={
"reaction_type": reaction_type,
"cast_hash": cast_hash,
"cast_text": cast.get("text", ""),
"author_fid": user_fid,
"author_fname": user.get("username", ""),
},
)
def extract_mentions(self, text: str) -> list[str]:
return re.findall(r"@(\w+)", text)
def strip_mentions(self, text: str) -> str:
return re.sub(r"@\w+", "", text).strip()
def _strip_bot_mentions(text: str, bot_fname: str, bot_fid: int) -> str:
text = re.sub(rf"@?{re.escape(bot_fname)}\b", "", text)
text = re.sub(rf"@fid:{bot_fid}", "", text)
return text.strip()
def _make_msg_id(prefix: str, hash_val: str) -> str:
return f"{prefix}:{hash_val}"

View File

@ -0,0 +1,105 @@
import logging
from yuxi.channel.extensions.farcaster.client import NeynarClient, NeynarError
from yuxi.channel.extensions.farcaster.defaults import CAST_BYTE_LIMIT
from yuxi.channel.protocols import OutboundDeliveryMode
logger = logging.getLogger(__name__)
class FarcasterOutboundAdapter:
delivery_mode = OutboundDeliveryMode.DIRECT
text_chunk_limit = CAST_BYTE_LIMIT
def __init__(self, client_getter, account_id: str = "default"):
self._client_getter = client_getter
self._account_id = account_id
def _get_client(self) -> NeynarClient:
client = self._client_getter(self._account_id)
if client is None:
raise NeynarError("No active Farcaster client")
return client
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
channel_id: str | None = None,
) -> None:
text = content
if len(text.encode("utf-8")) > CAST_BYTE_LIMIT:
text = _trim_to_byte_limit(text, CAST_BYTE_LIMIT)
parent_hash = reply_to_id or thread_id
client = self._get_client()
try:
result = await client.post_cast(
text=text,
parent_hash=parent_hash,
channel_id=channel_id,
)
cast_hash = result.get("cast", {}).get("hash", "")
logger.info(
"Farcaster cast sent: hash=%s to=%s parent=%s channel=%s",
cast_hash,
target_id,
parent_hash,
channel_id,
)
except NeynarError as e:
logger.error("Farcaster send_text failed: %s", e)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
channel_id: str | None = None,
) -> None:
captions = {"image": "🖼️ Image", "video": "🎬 Video", "audio": "🎵 Audio", "file": "📎 File"}
prefix = captions.get(media_type, media_url)
text = prefix
if len(text.encode("utf-8")) > CAST_BYTE_LIMIT:
text = _trim_to_byte_limit(text, CAST_BYTE_LIMIT)
parent_hash = reply_to_id or thread_id
client = self._get_client()
try:
await client.post_cast(
text=text,
parent_hash=parent_hash,
embeds=[{"url": media_url}],
channel_id=channel_id,
)
except NeynarError as e:
logger.error("Farcaster send_media failed: type=%s url=%s error=%s", media_type, media_url, e)
async def send_reaction(
self,
target_hash: str,
reaction_type: str = "like",
) -> None:
client = self._get_client()
try:
await client.post_reaction(target_hash, reaction_type)
except NeynarError as e:
logger.error("Farcaster send_reaction failed: %s", e)
def _trim_to_byte_limit(text: str, limit: int) -> str:
encoded = text.encode("utf-8")
if len(encoded) <= limit:
return text
trimmed = encoded[: limit - 3]
return trimmed.decode("utf-8", errors="ignore") + "..."

View File

@ -0,0 +1,28 @@
{
"id": "farcaster",
"name": "Farcaster",
"version": "1.0.0",
"description": "Farcaster decentralized social protocol channel plugin for ForcePilot",
"author": "ForcePilot",
"order": 105,
"enabled": true,
"capabilities": {
"chat_types": ["direct"],
"message_types": ["text"],
"interactions": ["recast"],
"reactions": true,
"typing_indicator": false,
"threads": true,
"edit": false,
"unsend": true,
"reply": true,
"media": true,
"native_commands": false,
"polls": false,
"streaming": true,
"streaming_mode": "block",
"block_streaming": true,
"block_streaming_chunk_min_chars": 240,
"block_streaming_chunk_max_chars": 300
}
}

View File

@ -0,0 +1,99 @@
from pydantic import BaseModel, Field
class WebhookEventType:
CAST_CREATED = "cast.created"
REACTION_CREATED = "reaction.created"
class CastAuthor(BaseModel):
fid: int
username: str | None = None
display_name: str | None = None
pfp_url: str | None = None
follower_count: int = 0
following_count: int = 0
class CastEmbed(BaseModel):
url: str | None = None
class CastFrame(BaseModel):
url: str | None = None
class CastReactionSummary(BaseModel):
likes_count: int = 0
recasts_count: int = 0
class CastData(BaseModel):
hash: str
thread_hash: str | None = None
parent_hash: str | None = None
parent_url: str | None = None
author: CastAuthor
text: str = ""
timestamp: str | None = None
embeds: list[CastEmbed] = Field(default_factory=list)
reactions: CastReactionSummary | None = None
replies: dict | None = None
mentioned_profiles: list[dict] = Field(default_factory=list)
channel: dict | None = None
class CastCreatedData(BaseModel):
object: str = "cast"
hash: str
thread_hash: str | None = None
parent_hash: str | None = None
author: CastAuthor
text: str = ""
timestamp: str | None = None
embeds: list[CastEmbed] = Field(default_factory=list)
reactions: CastReactionSummary | None = None
replies: dict | None = None
mentioned_profiles: list[dict] = Field(default_factory=list)
channel: dict | None = None
class ReactionUser(BaseModel):
fid: int
username: str | None = None
display_name: str | None = None
pfp_url: str | None = None
class ReactionCast(BaseModel):
hash: str
author: CastAuthor | None = None
text: str | None = None
class ReactionCreatedData(BaseModel):
object: str = "reaction"
reaction_type: str = "like"
cast: ReactionCast
user: ReactionUser
class WebhookEvent(BaseModel):
created_at: int = 0
type: str
data: dict
model_config = {"extra": "allow"}
class ResolvedFarcasterAccount(BaseModel):
account_id: str
name: str = ""
enabled: bool = True
configured: bool = False
api_key: str = ""
signer_uuid: str = ""
fid: int = 0
fname: str = ""
dm_policy: str = "pairing"
allow_from: list[str] = Field(default_factory=list)
webhook_base_url: str = ""

View File

@ -0,0 +1,155 @@
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