ForcePilot/backend/package/yuxi/channels/adapters/twitch/setup.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

411 lines
16 KiB
Python

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)