ForcePilot/backend/package/yuxi/channel/extensions/signal/daemon.py
Kris 6713dc2d0d feat(channel): 添加 Signal 渠道扩展
新增 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 重连机制
2026-05-21 11:41:55 +08:00

146 lines
4.3 KiB
Python

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")