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