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