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