from __future__ import annotations import json import os import stat import time from dataclasses import dataclass, field from pathlib import Path from typing import Any from yuxi.utils.logging_config import logger @dataclass class SetupWizardStep: step_id: str title: str = "" description: str = "" required: bool = True completed: bool = False config_key: str = "" config_value: Any = None @dataclass class SetupWizardState: steps: list[SetupWizardStep] = field(default_factory=list) current_step: int = 0 @property def is_complete(self) -> bool: return all(s.completed for s in self.steps if s.required) @property def current(self) -> SetupWizardStep | None: if 0 <= self.current_step < len(self.steps): return self.steps[self.current_step] return None def advance(self) -> None: if self.current_step < len(self.steps): self.steps[self.current_step].completed = True self.current_step += 1 def to_config(self) -> dict[str, Any]: config: dict[str, Any] = {} for step in self.steps: if step.config_key and step.config_value is not None: config[step.config_key] = step.config_value return config def create_setup_wizard_steps(config: dict[str, Any] | None = None) -> list[SetupWizardStep]: existing = config or {} return [ SetupWizardStep( step_id="username", title="Bot Username", description="Enter your Twitch bot username (lowercase). " "This must match the username associated with your Twitch application.", required=True, completed=bool(existing.get("bot_username")), config_key="bot_username", ), SetupWizardStep( step_id="token", title="Access Token", description="Enter your Twitch IRC access token (oauth:...) or leave blank to use " "TWITCH_ACCESS_TOKEN / OPENCLAW_TWITCH_ACCESS_TOKEN env var. " "Get it from https://twitchtokengenerator.com/ with scopes: " "chat:read, chat:edit, channel:read:subscriptions, moderator:read:followers", required=True, completed=bool(existing.get("access_token")), config_key="access_token", ), SetupWizardStep( step_id="client_id", title="Client ID", description="Enter your Twitch application Client ID. " "Create a Twitch application at https://dev.twitch.tv/console/apps", required=True, completed=bool(existing.get("client_id")), config_key="client_id", ), SetupWizardStep( step_id="client_secret", title="Client Secret (Optional)", description="Enter your Twitch application Client Secret for EventSub and token refresh. " "Required for EventSub events (follows, subs, raids, etc.) and automatic token rotation.", required=False, completed=bool(existing.get("client_secret")), config_key="client_secret", ), SetupWizardStep( step_id="channels", title="Channels", description="Enter channels to join (comma-separated, e.g. channel1,channel2). " "Do NOT include the # prefix — it will be added automatically.", required=True, completed=bool(existing.get("channels")), config_key="channels", ), SetupWizardStep( step_id="refresh_token", title="Refresh Token (Optional)", description="Enter a refresh token for automatic token rotation, or leave blank to skip. " "Required scopes for token refresh: channel:read:subscriptions, chat:read, chat:edit", required=False, completed=bool(existing.get("refresh_token")), config_key="refresh_token", ), SetupWizardStep( step_id="dm_policy", title="DM Policy", description="DM/whisper policy: 'open' (allow all) or 'pairing' (require approval). Default: 'pairing'", required=False, completed=bool(existing.get("dm_policy")), config_key="dm_policy", ), SetupWizardStep( step_id="group_access", title="Group Access Policy", description="Group access policy: open / allowlist / disabled / mention_only. Default: 'open'. " "'open' = respond to all messages; 'allowlist' = only respond to allowed users; " "'mention_only' = only respond when @mentioned", required=True, completed=bool(existing.get("group_policy")), config_key="group_policy", ), SetupWizardStep( step_id="allowed_roles", title="Allowed Roles", description="Allowed roles for responses (comma-separated): moderator,owner,vip,subscriber,all. " "Default: all", required=False, completed=bool(existing.get("allowedRoles")), config_key="allowedRoles", ), SetupWizardStep( step_id="silent", title="Silent Mode (ACTION)", description="Send messages as /me ACTION instead of normal PRIVMSG. " "Type 'yes' to enable, or leave blank for default (no).", required=False, completed="silent" in existing, config_key="silent", ), SetupWizardStep( step_id="probe_timeout", title="Probe Timeout (ms)", description="Timeout for health probe API calls in milliseconds. Default: 10000 (10s). " "Increase if your Twitch API calls are slow.", required=False, completed=bool(existing.get("probe_timeout_ms")), config_key="probe_timeout_ms", ), ] def setup_wizard_to_config(state: SetupWizardState) -> dict[str, Any]: return state.to_config() class TwitchSetupWizard: def __init__(self, config: dict[str, Any] | None = None): self._config = config or {} self._state = SetupWizardState() self._account_id: str = "" self._disabled = False self._build_steps() def _build_steps(self) -> None: self._state.steps = create_setup_wizard_steps(self._config) def resolve_account_id(self, account_id: str | None = None) -> str: self._account_id = account_id or "default" logger.info(f"[TwitchSetup] Resolved account ID: {self._account_id}") return self._account_id def prompt_username(self, username: str) -> bool: if not username or not username.strip(): logger.warning("[TwitchSetup] Bot username cannot be empty") return False self._config["bot_username"] = username.strip().lower() self._mark_completed("username") logger.info(f"[TwitchSetup] Bot username: {self._config['bot_username']}") return True def prompt_token(self, access_token: str) -> bool: token = access_token.strip() if not token: import os env_token = os.environ.get("TWITCH_ACCESS_TOKEN", "") or os.environ.get("OPENCLAW_TWITCH_ACCESS_TOKEN", "") if env_token: self._config["access_token"] = env_token logger.info("[TwitchSetup] Using token from environment variable") else: logger.warning("[TwitchSetup] No access token provided and no env var found") return False else: from .token_utils import ensure_oauth_prefix self._config["access_token"] = ensure_oauth_prefix(token) self._mark_completed("token") logger.info("[TwitchSetup] Access token configured") return True def prompt_client_id(self, client_id: str) -> bool: if not client_id or not client_id.strip(): logger.warning("[TwitchSetup] Client ID cannot be empty") return False self._config["client_id"] = client_id.strip() self._mark_completed("client_id") logger.info(f"[TwitchSetup] Client ID: {self._config['client_id']}") return True def prompt_client_secret(self, client_secret: str = "") -> bool: if client_secret and client_secret.strip(): self._config["client_secret"] = client_secret.strip() logger.info("[TwitchSetup] Client secret configured") else: logger.info("[TwitchSetup] Client secret skipped (EventSub and token refresh disabled)") self._mark_completed("client_secret") return True def prompt_channels(self, channels_input: str | list[str]) -> bool: if isinstance(channels_input, str): channels = [c.strip().lower() for c in channels_input.split(",") if c.strip()] else: channels = channels_input if not channels: logger.warning("[TwitchSetup] At least one channel is required") return False self._config["channels"] = channels self._mark_completed("channels") logger.info(f"[TwitchSetup] Channels: {channels}") return True def prompt_refresh_token(self, refresh_token: str = "") -> bool: if refresh_token and refresh_token.strip(): self._config["refresh_token"] = refresh_token.strip() logger.info("[TwitchSetup] Refresh token configured") else: logger.info("[TwitchSetup] Refresh token skipped (manual rotation only)") self._mark_completed("refresh_token") return True def prompt_dm_policy(self, policy: str = "pairing") -> bool: valid = {"open", "pairing"} policy = policy.strip().lower() if policy not in valid: logger.warning(f"[TwitchSetup] Invalid DM policy '{policy}', must be one of {valid}") return False self._config["dm_policy"] = policy self._mark_completed("dm_policy") logger.info(f"[TwitchSetup] DM policy: {policy}") return True def prompt_group_access(self, policy: str = "open") -> bool: valid = {"open", "allowlist", "disabled", "mention_only"} policy = policy.strip().lower() if policy not in valid: logger.warning(f"[TwitchSetup] Invalid group policy '{policy}', must be one of {valid}") return False self._config["group_policy"] = policy self._mark_completed("group_access") logger.info(f"[TwitchSetup] Group access policy: {policy}") return True def prompt_allowed_roles(self, roles_input: str | list[str] = "all") -> bool: if isinstance(roles_input, str): roles = [r.strip().lower() for r in roles_input.split(",") if r.strip()] else: roles = roles_input valid_roles = {"moderator", "owner", "vip", "subscriber", "all"} filtered = [r for r in roles if r in valid_roles] if not filtered: logger.warning("[TwitchSetup] No valid roles specified, defaulting to 'all'") filtered = ["all"] self._config["allowedRoles"] = filtered self._mark_completed("allowed_roles") logger.info(f"[TwitchSetup] Allowed roles: {filtered}") return True def prompt_silent(self, silent_input: str = "") -> bool: silent = silent_input.strip().lower() in ("yes", "true", "1", "y", "on") self._config["silent"] = silent self._mark_completed("silent") logger.info(f"[TwitchSetup] Silent mode (ACTION): {silent}") return True def prompt_probe_timeout(self, timeout_input: str | int = 10000) -> bool: if isinstance(timeout_input, str): timeout_input = timeout_input.strip() if not timeout_input: self._config["probe_timeout_ms"] = 10000 self._mark_completed("probe_timeout") return True try: timeout = int(timeout_input) except ValueError: logger.warning(f"[TwitchSetup] Invalid probe timeout: {timeout_input}") return False else: timeout = timeout_input if timeout < 1000: logger.warning("[TwitchSetup] Probe timeout too small, minimum 1000ms") return False self._config["probe_timeout_ms"] = timeout self._mark_completed("probe_timeout") logger.info(f"[TwitchSetup] Probe timeout: {timeout}ms") return True def finalize(self) -> dict[str, Any]: if self._disabled: return {"enabled": False} validation = self._validate_config() return { "enabled": True, "account_id": self._account_id or "default", "config": dict(self._config), "validation": validation, "finalized_at": time.time(), } def finalize_and_write(self, storage_path: str | None = None) -> dict[str, Any]: result = self.finalize() if storage_path: try: config_path = Path(storage_path) / "twitch_config.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( json.dumps(result["config"], indent=2, ensure_ascii=False), encoding="utf-8", ) try: os.chmod(config_path, stat.S_IRUSR | stat.S_IWUSR) except OSError: logger.warning(f"[TwitchSetup] Failed to set file permissions for {config_path}") result["storage"] = { "path": str(config_path), "success": True, "warning": "Config contains sensitive credentials. Ensure file permissions are restricted.", } logger.info(f"[TwitchSetup] Config written to {config_path} (permissions restricted)") except Exception as e: result["storage"] = {"path": str(storage_path), "success": False, "error": str(e)} logger.error(f"[TwitchSetup] Failed to write config: {e}") else: result["storage"] = {"mode": "memory", "success": True} return result def _validate_config(self) -> dict[str, Any]: errors: list[str] = [] warnings: list[str] = [] if not self._config.get("bot_username"): errors.append("bot_username is required") if not self._config.get("access_token"): errors.append("access_token is required") if not self._config.get("client_id"): errors.append("client_id is required") if not self._config.get("channels"): errors.append("at least one channel is required") group_policy = self._config.get("group_policy", "open") if group_policy not in {"open", "allowlist", "disabled", "mention_only"}: errors.append(f"invalid group_policy: {group_policy}") dm_policy = self._config.get("dm_policy", "pairing") if dm_policy not in {"open", "pairing"}: warnings.append(f"unknown dm_policy '{dm_policy}', defaulting to 'pairing'") if not self._config.get("client_secret"): warnings.append("no client_secret — EventSub and token refresh disabled") if not self._config.get("refresh_token"): warnings.append("no refresh_token — manual token rotation required") return { "valid": len(errors) == 0, "errors": errors, "warnings": warnings, } def disable(self) -> dict[str, Any]: self._disabled = True logger.info("[TwitchSetup] Account disabled") return {"enabled": False} def _mark_completed(self, step_id: str) -> None: for step in self._state.steps: if step.step_id == step_id: step.completed = True step.config_value = self._config.get(step.config_key) break @property def state(self) -> SetupWizardState: return self._state @property def config(self) -> dict[str, Any]: return dict(self._config)