新增 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 重连机制
146 lines
4.3 KiB
Python
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")
|