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 register", "2. Verify: signal-cli -a verify ", "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}")