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

106 lines
3.3 KiB
Python

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") + "..."