ForcePilot/backend/package/yuxi/channels/adapters/signal/setup.py
Kris b47c6126e6 refactor(signal-adapter): 整理代码结构与导入顺序,新增批量消息操作支持
1.  调整多个文件的导入顺序与格式,统一代码风格
2.  在security模块新增允许列表持久化存储逻辑
3.  新增send_sticker/send_voice/send_silent/pin/unpin等消息操作
4.  新增群组创建/删除/成员管理方法
5.  重构流式消息处理逻辑,提取为独立工具类
6.  修复配置校验与安全检查的逻辑顺序问题
7.  优化初始化流程,新增配置校验步骤
2026-05-13 16:14:06 +08:00

182 lines
6.1 KiB
Python

from __future__ import annotations
import re
from enum import StrEnum
from typing import Any
E164_MIN_LENGTH = 5
E164_MAX_LENGTH = 15
E164_PATTERN = re.compile(r"^\+\d{%d,%d}$" % (E164_MIN_LENGTH, E164_MAX_LENGTH))
class SetupStep(StrEnum):
STATUS = "status"
PREPARE = "prepare"
CLI_PATH = "cli_path"
SIGNAL_NUMBER = "signal_number"
ALLOW_FROM = "allow_from"
COMPLETION = "completion"
class SetupWizard:
def __init__(self, config: dict[str, Any] | None = None):
self.config = config or {}
self._current_step: SetupStep = SetupStep.STATUS
@property
def current_step(self) -> str:
return self._current_step.value
def get_step_result(self) -> dict:
match self._current_step:
case SetupStep.STATUS:
return self._status_step()
case SetupStep.PREPARE:
return self._prepare_step()
case SetupStep.CLI_PATH:
return self._cli_path_step()
case SetupStep.SIGNAL_NUMBER:
return self._signal_number_step()
case SetupStep.ALLOW_FROM:
return self._allow_from_step()
case SetupStep.COMPLETION:
return self._completion_step()
case _:
return {"step": self._current_step.value, "status": "unknown"}
def advance(self, data: dict[str, Any] | None = None) -> dict:
step_order = list(SetupStep)
current_idx = step_order.index(self._current_step)
if current_idx < len(step_order) - 1:
self._current_step = step_order[current_idx + 1]
return self.get_step_result()
def _status_step(self) -> dict:
return {
"step": "status",
"title": "Signal Setup Wizard",
"description": "Configure your Signal channel step by step",
"total_steps": len(SetupStep),
}
def _prepare_step(self) -> dict:
from yuxi.channels.adapters.signal.install import check_java_installed, check_signal_cli_installed
java_ok, _ = check_java_installed()
cli_ok, cli_version = check_signal_cli_installed(self.config.get("cli_path", "signal-cli"))
result = {
"step": "prepare",
"title": "Environment Check",
"fields": [
{"key": "java_installed", "label": "Java 17+", "ok": java_ok},
{"key": "signal_cli_installed", "label": "signal-cli", "ok": cli_ok, "version": cli_version or ""},
],
"ready": java_ok and cli_ok,
}
if not cli_ok and java_ok:
result["can_auto_install"] = True
result["auto_install_hint"] = "signal-cli can be auto-installed in the next step"
return result
def _cli_path_step(self) -> dict:
current_path = self.config.get("cli_path", "signal-cli")
return {
"step": "cli_path",
"title": "signal-cli Path",
"description": "Path to the signal-cli binary",
"fields": [
{
"key": "cli_path",
"label": "Binary Path",
"type": "string",
"default": current_path,
"required": True,
}
],
}
def _signal_number_step(self) -> dict:
current_number = self.config.get("signal_number", "")
return {
"step": "signal_number",
"title": "Signal Phone Number",
"description": "E.164 format phone number (e.g. +1234567890)",
"fields": [
{
"key": "signal_number",
"label": "Phone Number",
"type": "string",
"default": current_number,
"required": True,
"pattern": f"^\\+\\d{{{E164_MIN_LENGTH},{E164_MAX_LENGTH}}}$",
}
],
}
def _completion_step(self) -> dict:
return {
"step": "completion",
"title": "Setup Complete",
"description": "Signal channel is ready to connect",
"next_steps": [
"1. Register your number: signal-cli -a <number> register",
"2. Verify: signal-cli -a <number> verify <CODE>",
"3. Add an account_uuid config for loop prevention",
],
}
def _allow_from_step(self) -> dict:
security = self.config.get("security", {})
current_allow_from = security.get("allow_from", [])
return {
"step": "allow_from",
"title": "Access Control - Allow From",
"description": "Comma-separated E.164 numbers, UUIDs, or '*' to allow DM from (leave empty for pairing mode)",
"fields": [
{
"key": "allow_from",
"label": "Allowed Senders",
"type": "string",
"default": ", ".join(current_allow_from) if current_allow_from else "",
"placeholder": "+8613800138000, uuid:abc123, *",
}
],
}
def auto_install(self) -> dict:
import asyncio
from yuxi.channels.adapters.signal.install import auto_install_signal_cli
try:
target_dir = self.config.get("install_dir")
success = asyncio.run(auto_install_signal_cli(target_dir))
return {
"step": "prepare",
"auto_install_success": success,
"message": "signal-cli installed successfully" if success else "auto-install failed",
}
except Exception as e:
return {
"step": "prepare",
"auto_install_success": False,
"message": str(e),
}
def validate_e164(number: str) -> bool:
return bool(E164_PATTERN.match(number.strip()))
def format_e164(raw: str) -> str:
stripped = raw.strip()
if E164_PATTERN.match(stripped):
return stripped
digits = re.sub(r"[^\d]", "", stripped)
if E164_MIN_LENGTH <= len(digits) <= E164_MAX_LENGTH:
return f"+{digits}"
raise ValueError(f"Invalid E.164 number: {raw}")