ForcePilot/backend/package/yuxi/channel/extensions/signal/client.py
Kris 6713dc2d0d feat(channel): 添加 Signal 渠道扩展
新增 Signal 渠道扩展,支持在 Yuxi 平台中集成 Signal 加密即时通讯渠道。

包含以下功能模块:
- client: Signal 客户端封装
- daemon: signald 守护进程管理
- config_schema: 配置模式
- send: 消息发送
- accounts: 账户管理
- account_management: 账户综合管理
- access_policy: 访问策略
- identity: 身份管理
- profiles: 用户资料
- groups: 群组管理
- format: 消息格式转换
- normalize: 消息规范化
- dedupe: 消息去重
- monitor: 渠道状态监控
- probe: 健康探测
- sse_reconnect: SSE 重连机制
2026-05-21 11:41:55 +08:00

149 lines
5.0 KiB
Python

import asyncio
import base64
import json
import uuid
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any
import aiohttp
MAX_SINGLE_RESPONSE_BYTES = 1_048_576
MAX_SSE_BUFFER_BYTES = 1_048_576
MAX_SSE_EVENT_DATA_BYTES = 1_048_576
class SignalRpcError(Exception):
pass
@dataclass
class SignalSseEvent:
event: str
data: str
class SignalRpcClient:
def __init__(self, base_url: str, timeout: float = 10.0):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
async def call(self, method: str, params: dict | None = None, account: str | None = None) -> Any:
body = {
"jsonrpc": "2.0",
"method": method,
"params": params or {},
"id": str(uuid.uuid4()),
}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
async with session.post(
f"{self.base_url}/api/v1/rpc",
json=body,
headers={"Content-Type": "application/json"},
) as resp:
if resp.status == 201:
return None
raw = await resp.content.read()
if not raw:
raise SignalRpcError("Signal RPC empty response")
try:
data = json.loads(raw)
except Exception:
raise SignalRpcError("Signal RPC returned malformed JSON")
if "error" in data and data["error"]:
err = data["error"]
raise SignalRpcError(f"Signal RPC {err.get('code', -1)}: {err.get('message', 'unknown')}")
if "result" not in data:
raise SignalRpcError("Signal RPC returned invalid response envelope")
return data["result"]
async def check(self, timeout_ms: int = 1000) -> bool:
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout_ms / 1000)) as session:
async with session.get(f"{self.base_url}/api/v1/check") as resp:
return resp.status == 200
except Exception:
return False
async def version(self) -> str:
result = await self.call("version")
if isinstance(result, dict):
return str(result.get("version", "unknown"))
return str(result)
async def get_attachment(
self,
attachment_id: str,
*,
account: str | None = None,
recipient: str | None = None,
group_id: str | None = None,
) -> bytes:
params: dict = {"id": attachment_id}
if account:
params["account"] = account
if recipient:
params["recipient"] = recipient
if group_id:
params["groupId"] = group_id
result = await self.call("getAttachment", params, account=account)
if isinstance(result, str):
return base64.b64decode(result)
if isinstance(result, dict) and "data" in result:
return base64.b64decode(result["data"])
raise SignalRpcError("getAttachment returned unexpected format")
class SignalSseClient:
def __init__(self, base_url: str, account: str | None = None):
self.base_url = base_url.rstrip("/")
self.account = account
async def stream_events(self, cancel: asyncio.Event | None = None) -> AsyncGenerator[SignalSseEvent, None]:
url = f"{self.base_url}/api/v1/events"
params = {}
if self.account:
params["account"] = self.account
async with aiohttp.ClientSession() as session:
async with session.get(url, params=params) as resp:
event_type = None
data_lines: list[str] = []
total_bytes = 0
async for line in resp.content:
if cancel and cancel.is_set():
break
decoded = line.decode("utf-8").rstrip("\r\n")
if decoded == "":
if data_lines:
yield SignalSseEvent(
event=event_type or "message",
data="\n".join(data_lines),
)
event_type = None
data_lines.clear()
total_bytes = 0
elif decoded.startswith(":"):
continue
elif decoded.startswith("event:"):
event_type = decoded[6:].strip()
elif decoded.startswith("data:"):
data_str = decoded[5:].strip()
data_lines.append(data_str)
total_bytes += len(data_str)
if total_bytes > MAX_SSE_BUFFER_BYTES:
raise RuntimeError("SSE buffer size exceeded")
elif decoded.startswith("id:"):
pass