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

215 lines
7.7 KiB
Python

from __future__ import annotations
import asyncio
import logging
import shutil
from collections.abc import Callable, Awaitable
from yuxi.channels.models import HealthStatus
from yuxi.channels.adapters.signal.client import RpcClient
logger = logging.getLogger(__name__)
DAEMON_STARTUP_RETRIES = 30
DAEMON_STARTUP_RETRY_DELAY = 1.0
DAEMON_STOP_TIMEOUT = 10.0
class SignalDaemonManager:
def __init__(
self,
cli_path: str,
account: str,
http_listen: str = "127.0.0.1:8080",
home_dir: str | None = None,
java_opts: str | None = None,
receive_mode: str | None = None,
send_read_receipts: bool | None = None,
daemon_startup_retries: int = DAEMON_STARTUP_RETRIES,
auto_start: bool = True,
poll_interval_ms: int = 150,
log_after_ms: int = 10000,
log_interval_ms: int = 10000,
):
self._cli_path = cli_path
self._account = account
self._http_listen = http_listen
self._home_dir = home_dir
self._java_opts = java_opts
self._receive_mode = receive_mode
self._send_read_receipts = send_read_receipts
self._daemon_startup_retries = daemon_startup_retries
self._auto_start = auto_start
self._poll_interval_ms = poll_interval_ms
self._log_after_ms = log_after_ms
self._log_interval_ms = log_interval_ms
self._process: asyncio.subprocess.Process | None = None
self._monitor_task: asyncio.Task | None = None
self._on_crash: asyncio.Event | None = None
self._crash_handler: Callable[[int, str], Awaitable[None]] | None = None
@property
def listen_addr(self) -> str:
return self._http_listen
@property
def is_running(self) -> bool:
return self._process is not None and self._process.returncode is None
def on_crash(self, handler: Callable[[int, str], Awaitable[None]]) -> None:
self._crash_handler = handler
async def start(self) -> None:
if not self._auto_start:
logger.info("signal-cli daemon auto_start is disabled, skipping")
return
if not shutil.which(self._cli_path) and self._cli_path != "signal-cli":
raise FileNotFoundError(f"signal-cli not found at: {self._cli_path}")
cmd = [self._cli_path, "-a", self._account, "daemon", "--http-listen", self._http_listen]
if self._receive_mode:
cmd.extend(["--receive-mode", self._receive_mode])
if self._send_read_receipts is not None:
if self._send_read_receipts:
cmd.append("--send-read-receipts")
else:
cmd.append("--no-send-read-receipts")
if self._home_dir:
cmd.extend(["--config", self._home_dir])
env = None
if self._java_opts:
env = {**dict(__import__("os").environ), "JAVA_OPTS": self._java_opts}
logger.info(f"Starting signal-cli daemon: {' '.join(cmd)}")
self._process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
self._on_crash = asyncio.Event()
self._monitor_task = asyncio.create_task(self._monitor_process())
rpc_client = RpcClient(f"http://{self._http_listen}")
await rpc_client.connect()
try:
poll_ms = max(100, self._poll_interval_ms)
total_elapsed_ms = 0
timeout_ms = self._daemon_startup_retries * 1000
logged = False
while total_elapsed_ms < timeout_ms:
try:
await rpc_client.call("version")
logger.info("signal-cli daemon is ready")
return
except Exception:
await asyncio.sleep(poll_ms / 1000.0)
total_elapsed_ms += poll_ms
if not logged and total_elapsed_ms >= self._log_after_ms:
logged = True
logger.info(
f"signal-cli daemon not ready after {total_elapsed_ms}ms, "
f"will log every {self._log_interval_ms}ms"
)
elif logged and total_elapsed_ms % self._log_interval_ms < poll_ms:
logger.info(f"signal-cli daemon still not ready (elapsed={total_elapsed_ms}ms)")
raise TimeoutError(
f"signal-cli daemon did not become ready within {timeout_ms}ms (poll_interval={poll_ms}ms)"
)
finally:
await rpc_client.disconnect()
async def stop(self) -> None:
if self._monitor_task:
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
self._monitor_task = None
if self._process:
logger.info("Stopping signal-cli daemon")
self._process.terminate()
try:
await asyncio.wait_for(self._process.wait(), timeout=DAEMON_STOP_TIMEOUT)
except TimeoutError:
logger.warning("signal-cli daemon did not stop gracefully, sending SIGKILL")
self._process.kill()
await self._process.wait()
self._process = None
if self._on_crash:
self._on_crash.clear()
async def restart(self) -> None:
logger.info("Restarting signal-cli daemon")
await self.stop()
await self.start()
async def health_check(self) -> HealthStatus:
if not self.is_running:
return HealthStatus(
status="unhealthy",
last_error="signal-cli daemon process is not running",
)
try:
rpc_client = RpcClient(f"http://{self._http_listen}")
await rpc_client.connect()
try:
await rpc_client.call("version")
return HealthStatus(status="healthy")
finally:
await rpc_client.disconnect()
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def _monitor_process(self) -> None:
while self._process and self._process.returncode is None:
try:
await asyncio.wait_for(self._process.wait(), timeout=5.0)
break
except TimeoutError:
continue
if self._process:
exit_code = self._process.returncode
stderr_data = await self._process.stderr.read() if self._process.stderr else b""
msg = stderr_data.decode("utf-8", errors="replace")[:500]
self._classify_and_log_stderr(msg)
logger.error(f"signal-cli daemon exited with code {exit_code}: {msg}")
if self._on_crash:
self._on_crash.set()
if self._crash_handler:
try:
await self._crash_handler(exit_code, msg)
except Exception:
logger.exception("Crash handler failed")
@staticmethod
def _classify_and_log_stderr(stderr_text: str) -> None:
if not stderr_text:
return
lower = stderr_text.lower()
if any(kw in lower for kw in ("error", "exception", "fatal", "failed")):
logger.error(f"[signal-cli stderr] {stderr_text[:500]}")
elif any(kw in lower for kw in ("warn", "warning")):
logger.warning(f"[signal-cli stderr] {stderr_text[:500]}")
elif any(kw in lower for kw in ("debug", "trace")):
logger.debug(f"[signal-cli stderr] {stderr_text[:500]}")
else:
logger.info(f"[signal-cli stderr] {stderr_text[:500]}")