这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
411 lines
16 KiB
Python
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)
|