新增 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 重连机制
149 lines
5.0 KiB
Python
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
|