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

133 lines
4.3 KiB
Python

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}"