新增了完整的Signal渠道适配器实现,包含RPC客户端、守护进程管理、安全策略、消息处理、安装配置工具等全套功能,支持通过signal-cli与Signal网络进行通信,包含账户管理、消息收发、反应处理、媒体分析、健康检查等能力。
215 lines
7.7 KiB
Python
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]}")
|