ForcePilot/backend/package/yuxi/channels/adapters/signal/probe.py
Kris 8dc86766f1 feat(channels/signal): 新增Signal渠道适配器完整实现
新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
2026-05-12 00:48:25 +08:00

138 lines
4.2 KiB
Python

from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
import aiohttp
from yuxi.channels.models import HealthStatus
from yuxi.channels.adapters.signal.client import RpcClient, RpcError
logger = logging.getLogger(__name__)
PROBE_TIMEOUT_MS = 7500
class ProbeError(Exception):
def __init__(self, message: str, error_type: str = "UNKNOWN"):
super().__init__(message)
self.error_type = error_type
PROBE_ERROR_UNAUTHORIZED = "UNAUTHORIZED"
PROBE_ERROR_NOT_FOUND = "NOT_FOUND"
PROBE_ERROR_DAEMON_UNREACHABLE = "SIGNAL_DAEMON_UNREACHABLE"
PROBE_ERROR_TIMEOUT = "TIMEOUT"
PROBE_ERROR_UNKNOWN = "UNKNOWN"
@dataclass
class SignalProbeResult:
status: str = "unknown"
version: str | None = None
error_type: str | None = None
latency_ms: float = 0.0
metadata: dict = field(default_factory=dict)
@property
def success(self) -> bool:
return self.status == "healthy" and self.version is not None
async def probe_signal_daemon(rpc_client: RpcClient) -> SignalProbeResult:
import time as _time
start = _time.monotonic()
base_url = rpc_client.base_url
try:
about_result = await _probe_about_endpoint(base_url)
if about_result:
elapsed = (_time.monotonic() - start) * 1000
return SignalProbeResult(
status="healthy",
version=about_result.get("version", "unknown"),
latency_ms=elapsed,
metadata={"arm": "about", "about": about_result},
)
except Exception:
logger.debug("About endpoint probe failed, falling back to version RPC")
try:
version_result = await asyncio.wait_for(
rpc_client.call("version"),
timeout=PROBE_TIMEOUT_MS / 1000.0,
)
elapsed = (_time.monotonic() - start) * 1000
return SignalProbeResult(
status="healthy",
version=version_result.get("version", "unknown"),
latency_ms=elapsed,
metadata={"arm": "rpc_version"},
)
except TimeoutError:
return SignalProbeResult(
status="unhealthy",
error_type=PROBE_ERROR_TIMEOUT,
latency_ms=PROBE_TIMEOUT_MS,
)
except RpcError as e:
return SignalProbeResult(
status="unhealthy",
error_type=_classify_rpc_error(e),
metadata={"error": str(e)},
)
except Exception as e:
return SignalProbeResult(
status="unhealthy",
error_type=PROBE_ERROR_DAEMON_UNREACHABLE,
metadata={"error": str(e)},
)
async def _probe_about_endpoint(base_url: str) -> dict | None:
url = f"{base_url}/api/v1/about"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
return await resp.json()
except Exception:
pass
return None
async def health_check_signal(rpc_client: RpcClient) -> HealthStatus:
try:
probe_result = await probe_signal_daemon(rpc_client)
if not probe_result.success:
return HealthStatus(
status="unhealthy",
last_error=probe_result.error_type or "unknown error",
metadata=probe_result.metadata,
)
return HealthStatus(
status="healthy",
metadata={
"version": probe_result.version or "unknown",
"latency_ms": probe_result.latency_ms,
**probe_result.metadata,
},
)
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
def _classify_rpc_error(error: RpcError) -> str:
msg = str(error).lower()
if error.code == -32602:
return PROBE_ERROR_NOT_FOUND
if "unauthorized" in msg or "authorization" in msg or "forbidden" in msg:
return PROBE_ERROR_UNAUTHORIZED
if "not found" in msg or "missing" in msg:
return PROBE_ERROR_NOT_FOUND
if "refused" in msg or "unreachable" in msg or "connect" in msg:
return PROBE_ERROR_DAEMON_UNREACHABLE
return PROBE_ERROR_UNKNOWN