ForcePilot/backend/package/yuxi/channel/extensions/tlon/sse_client.py
Kris 0babb1ca8e feat(channel): 添加 Tlon 渠道扩展
新增 Tlon 渠道扩展,支持在 Yuxi 平台中集成 Tlon/Urbit 去中心化通讯平台。

包含以下功能模块:
- tlon_api: Tlon API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- sse_client: SSE 客户端
- outbound: 外发消息管理
- send: 消息发送
- security: 安全校验
- auth: 认证管理
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- approval: 审批流程
- channel_mgmt: 频道管理
- channel_ops: 频道操作
- contacts: 联系人管理
- discovery: 服务发现
- doctor: 健康诊断
- expose: 服务暴露
- gallery: 图库管理
- history: 历史记录
- hooks: 钩子管理
- media: 媒体资源处理
- notebook: 笔记本功能
- settings_store: 设置存储
- setup: 初始化设置
- story: 故事功能
- targets: 目标管理
- cite_parser: 引用解析
- utils: 工具函数
- types: 类型定义
2026-05-21 11:55:25 +08:00

267 lines
9.0 KiB
Python

import asyncio
import json
import logging
import time
import uuid
from collections.abc import Callable
import httpx
from yuxi.channel.extensions.tlon.errors import UrbitError, UrbitSSEError
from yuxi.channel.extensions.tlon.types import SSEConfig
logger = logging.getLogger(__name__)
class UrbitSSEClient:
def __init__(self, config: SSEConfig):
self._config = config
self._channel_id = f"{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
self._subscriptions: dict[int, dict] = {}
self._event_handlers: dict[int, callable] = {}
self._event_count = 0
self._state = "idle"
self._abort = asyncio.Event()
self._http: httpx.AsyncClient | None = None
@property
def channel_id(self) -> str:
return self._channel_id
@property
def state(self) -> str:
return self._state
async def connect(self) -> None:
self._http = self._create_http_client()
await self._create_channel()
await self._poke_helm_hi()
await self._subscribe_all()
self._state = "connected"
async def subscribe(self, app: str, path: str,
on_event: Callable,
on_error: Callable | None = None,
on_quit: Callable | None = None) -> int:
sub_id = len(self._subscriptions) + 1
self._subscriptions[sub_id] = {
"app": app,
"path": path,
"on_error": on_error,
"on_quit": on_quit,
}
self._event_handlers[sub_id] = on_event
if self._state == "connected":
await self._poke_subscribe(sub_id, app, path)
return sub_id
async def poke(self, app: str, mark: str, json_data: dict) -> int:
poke_id = int(time.time() * 1000)
payload = [{
"id": poke_id,
"action": "poke",
"ship": self._config.ship,
"app": app,
"mark": mark,
"json": json_data,
}]
await self._put_channel(payload)
return poke_id
async def scry(self, path: str) -> dict:
scry_url = f"{self._config.url}/~/scry{path}"
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.get(
scry_url,
headers={"Cookie": self._config.cookie},
)
response.raise_for_status()
return response.json()
async def listen(self) -> None:
if self._state != "connected":
raise UrbitSSEError("Client not connected")
async with self._http.stream(
"GET",
f"/~/channel/{self._channel_id}",
headers={
"Cookie": self._config.cookie,
"Accept": "text/event-stream",
},
timeout=self._config.connect_timeout,
) as response:
self._state = "streaming"
event_id = None
data_lines = []
async for line in response.aiter_lines():
if self._abort.is_set():
break
if line.startswith("id:"):
event_id = int(line[3:].strip())
elif line.startswith("data:"):
data_lines.append(line[5:])
elif line == "" and event_id is not None:
await self._handle_event(event_id, data_lines)
event_id = None
data_lines = []
elif line == "":
event_id = None
data_lines = []
async def close(self) -> None:
self._state = "closing"
try:
for sub_id in self._subscriptions:
await self._poke_unsubscribe(sub_id)
if self._http:
await self._http.delete(
f"/~/channel/{self._channel_id}",
headers={"Cookie": self._config.cookie},
)
finally:
self._abort.set()
if self._http:
await self._http.aclose()
self._state = "closed"
async def attempt_reconnect(self) -> None:
logger.info("[tlon] SSE reconnecting...")
for attempt in range(1, self._config.max_reconnect_attempts + 1):
if self._abort.is_set():
return
delay = min(
self._config.reconnect_delay_ms * (2 ** (attempt - 1)),
self._config.max_reconnect_delay_ms,
)
await asyncio.sleep(delay / 1000)
try:
self._config.cookie = await self._config.on_reconnect()
except Exception:
continue
self._channel_id = f"{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}"
try:
if self._http:
await self._http.aclose()
await self.connect()
return
except Exception:
continue
logger.warning("[tlon] Max reconnect attempts reached, retrying after delay")
await asyncio.sleep(10)
await self.attempt_reconnect()
async def abort(self) -> None:
self._abort.set()
async def _handle_event(self, event_id: int, data_lines: list[str]) -> None:
self._event_count += 1
raw_data = "".join(data_lines)
try:
data = json.loads(raw_data)
except json.JSONDecodeError:
return
if isinstance(data, dict) and data.get("response") == "quit":
for sub_id, info in self._subscriptions.items():
if info.get("on_quit"):
await info["on_quit"](data)
return
for sub_id, handler in self._event_handlers.items():
try:
await handler(data)
except Exception:
logger.exception("[tlon] Event handler error")
if self._event_count % self._config.ack_threshold == 0:
await self._send_ack(event_id)
async def _send_ack(self, event_id: int) -> None:
try:
await self._http.put(
f"/~/channel/{self._channel_id}",
json=[{"id": event_id, "action": "ack"}],
headers={"Cookie": self._config.cookie},
)
except Exception:
pass
def _create_http_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self._config.url,
timeout=30.0,
)
async def _create_channel(self) -> None:
response = await self._http.put(
f"/~/channel/{self._channel_id}",
json=[],
headers={"Cookie": self._config.cookie, "Content-Type": "application/json"},
)
if response.status_code not in (200, 204):
raise UrbitError(f"Channel PUT failed: HTTP {response.status_code}")
async def _poke_helm_hi(self) -> None:
await self._http.put(
f"/~/channel/{self._channel_id}",
json=[{
"id": 1,
"action": "poke",
"ship": self._config.ship,
"app": "hood",
"mark": "helm-hi",
"json": "opening airlock",
}],
headers={"Cookie": self._config.cookie, "Content-Type": "application/json"},
)
async def _poke_subscribe(self, sub_id: int, app: str, path: str) -> None:
await self._http.put(
f"/~/channel/{self._channel_id}",
json=[{
"id": sub_id,
"action": "subscribe",
"ship": self._config.ship,
"app": app,
"path": path,
}],
headers={"Cookie": self._config.cookie, "Content-Type": "application/json"},
)
async def _poke_unsubscribe(self, sub_id: int) -> None:
try:
sub = self._subscriptions.get(sub_id)
if sub:
await self._http.put(
f"/~/channel/{self._channel_id}",
json=[{
"id": sub_id,
"action": "unsubscribe",
"ship": self._config.ship,
"app": sub["app"],
"path": sub["path"],
}],
headers={"Cookie": self._config.cookie, "Content-Type": "application/json"},
)
except Exception:
pass
async def _subscribe_all(self) -> None:
for sub_id, sub in self._subscriptions.items():
await self._poke_subscribe(sub_id, sub["app"], sub["path"])
async def _put_channel(self, payload: list[dict]) -> None:
response = await self._http.put(
f"/~/channel/{self._channel_id}",
json=payload,
headers={"Cookie": self._config.cookie, "Content-Type": "application/json"},
)
if response.status_code not in (200, 204):
raise UrbitError(f"Channel PUT failed: HTTP {response.status_code}")
async def scry_static(self, path: str) -> dict:
return await self.scry(path)