import asyncio import logging import re import signal as unix_signal from dataclasses import dataclass logger = logging.getLogger(__name__) ERROR_PATTERN = re.compile(r"ERROR|WARN|WARNING|FAILED|SEVERE|EXCEPTION", re.IGNORECASE) @dataclass class SignalDaemonConfig: cli_path: str = "signal-cli" account: str = "" http_host: str = "127.0.0.1" http_port: int = 8080 receive_mode: str | None = None ignore_attachments: bool = False ignore_stories: bool = False send_read_receipts: bool = False config_dir: str | None = None verbose: bool = False log_file: str | None = None socket_path: str | None = None class SignalDaemonHandle: def __init__(self, process: asyncio.subprocess.Process): self.process = process self._exited_event = asyncio.Event() self._exit_code: int | None = None asyncio.create_task(self._monitor_exit()) @property def pid(self) -> int | None: return self.process.pid async def _monitor_exit(self) -> None: self._exit_code = await self.process.wait() self._exited_event.set() async def stop(self) -> None: if self.process.returncode is not None: return try: self.process.send_signal(unix_signal.SIGTERM) await asyncio.wait_for(self._exited_event.wait(), timeout=10) except asyncio.TimeoutError: self.process.kill() await self._exited_event.wait() async def wait_exit(self) -> int | None: await self._exited_event.wait() return self._exit_code async def spawn_signal_daemon(config: SignalDaemonConfig) -> SignalDaemonHandle: args = [ config.cli_path, "-a", config.account, "daemon", "--http", f"{config.http_host}:{config.http_port}", "--no-receive-stdout", ] if config.receive_mode: args.extend(["--receive-mode", config.receive_mode]) if config.ignore_attachments: args.append("--ignore-attachments") if config.ignore_stories: args.append("--ignore-stories") if config.send_read_receipts: args.append("--send-read-receipts") if config.config_dir: args.extend(["--config", config.config_dir]) if config.verbose: args.append("--verbose") if config.log_file: args.extend(["--log-file", config.log_file]) if config.socket_path: args.extend(["--socket", config.socket_path]) process = await asyncio.create_subprocess_exec( *args, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) handle = SignalDaemonHandle(process) asyncio.create_task(_log_daemon_output(process)) return handle async def _log_daemon_output(process: asyncio.subprocess.Process) -> None: async def _read_stream(stream, is_stderr: bool): while True: line = await stream.readline() if not line: break text = line.decode("utf-8", errors="replace").rstrip() if ERROR_PATTERN.search(text) or is_stderr: logger.error("signal-cli: %s", text) else: logger.info("signal-cli: %s", text) await asyncio.gather( _read_stream(process.stdout, False), _read_stream(process.stderr, True), ) async def wait_for_daemon_ready( base_url: str, startup_timeout_ms: int = 30000, poll_interval_ms: int = 150, log_after_ms: int = 10000, ) -> None: from yuxi.channel.extensions.signal.client import SignalRpcClient timeout = min(120_000, max(1_000, startup_timeout_ms)) elapsed = 0 last_log_at = -log_after_ms client = SignalRpcClient(base_url, timeout=1.0) while elapsed < timeout: try: ok = await client.check(timeout_ms=1000) if ok: logger.info("Signal daemon ready after %dms", elapsed) return except Exception: pass if elapsed >= log_after_ms and elapsed - last_log_at >= log_after_ms: logger.info("Waiting for Signal daemon... (%d/%d ms)", elapsed, timeout) last_log_at = elapsed await asyncio.sleep(poll_interval_ms / 1000) elapsed += poll_interval_ms raise TimeoutError(f"Signal daemon not ready within {timeout}ms")