from __future__ import annotations from typing import Any from pydantic import BaseModel, Field from yuxi.utils.logging_config import logger class TwitchAccountSchema(BaseModel): bot_username: str = "" access_token: str = "" client_id: str = "" client_secret: str = "" refresh_token: str = "" channels: list[str] = [] group_policy: str = "open" require_mention: bool = True allowedRoles: list[str] = [] group_allow_from: list[str] = [] channels_config: dict[str, Any] = {} pairing_enabled: bool = False rate_limit: int = 20 rate_window: int = 30 mod_rate_limit: int = 100 irc_host: str = "irc.chat.twitch.tv" irc_port: int = 6697 strip_markdown: bool = True response_prefix: str = "" stream_coalesce_min_chars: int = 30 stream_coalesce_max_delay_ms: int = 0 silent: bool = False dm_policy: str = "pairing" probe_timeout_ms: int = 10000 class TwitchConfigSchema(BaseModel): bot_username: str = "" access_token: str = "" client_id: str = "" client_secret: str = "" refresh_token: str = "" channels: list[str] = [] group_policy: str = Field(default="open", pattern=r"^(open|allowlist|disabled|mention_only)$") require_mention: bool = True allowedRoles: list[str] = [] group_allow_from: list[str] = [] channels_config: dict[str, Any] = {} pairing_enabled: bool = False rate_limit: int = Field(default=20, ge=1) rate_window: int = Field(default=30, ge=1) mod_rate_limit: int = Field(default=100, ge=1) irc_host: str = "irc.chat.twitch.tv" irc_port: int = 6697 strip_markdown: bool = True response_prefix: str = "" stream_coalesce_min_chars: int = 30 stream_coalesce_max_delay_ms: int = 0 silent: bool = False dm_policy: str = "pairing" probe_timeout_ms: int = 10000 accounts: dict[str, TwitchAccountSchema] = {} defaultAccount: str = "" def validate_twitch_config(config: dict[str, Any]) -> list[str]: errors: list[str] = [] bot_username = config.get("bot_username", "") access_token = config.get("access_token", "") client_id = config.get("client_id", "") if not client_id: errors.append("client_id is required") if not access_token: errors.append("access_token is required") if not bot_username: errors.append("bot_username is required") channels = config.get("channels", []) if not channels: errors.append("at least one channel is required in 'channels' list") group_policy = config.get("group_policy", "open") valid_policies = {"open", "allowlist", "disabled", "mention_only"} if group_policy not in valid_policies: errors.append(f"group_policy value invalid: '{group_policy}', valid values: {valid_policies}") group_allow_from = config.get("group_allow_from", []) if group_policy == "allowlist" and not group_allow_from: channels_config = config.get("channels_config", {}) has_per_channel = any(cfg.get("allow_from") for cfg in channels_config.values() if isinstance(cfg, dict)) if not has_per_channel: errors.append("group_policy is 'allowlist' but group_allow_from is empty") allowed_roles = config.get("allowedRoles", []) valid_roles = {"moderator", "owner", "vip", "subscriber", "all"} for role in allowed_roles: if role.lower() not in valid_roles: errors.append(f"allowedRoles contains invalid role: '{role}', valid values: {valid_roles}") if "all" in allowed_roles and group_policy == "allowlist": errors.append("allowedRoles contains 'all' but group_policy is 'allowlist' — this combination is redundant") rate_limit = config.get("rate_limit", 20) rate_window = config.get("rate_window", 30) if not isinstance(rate_limit, int) or rate_limit < 1: errors.append(f"rate_limit must be a positive integer, got: {rate_limit}") if not isinstance(rate_window, int) or rate_window < 1: errors.append(f"rate_window must be a positive integer, got: {rate_window}") irc_port = config.get("irc_port", 6697) if not isinstance(irc_port, int) or irc_port < 1 or irc_port > 65535: errors.append(f"irc_port must be 1-65535, got: {irc_port}") accounts = config.get("accounts", {}) if isinstance(accounts, dict): for acct_id, acct_cfg in accounts.items(): if not isinstance(acct_cfg, dict): continue if not acct_cfg.get("bot_username"): errors.append(f"account '{acct_id}' missing bot_username") if not acct_cfg.get("access_token"): errors.append(f"account '{acct_id}' missing access_token") default_account = config.get("defaultAccount", "") if default_account and isinstance(accounts, dict) and default_account not in accounts: errors.append(f"defaultAccount '{default_account}' not found in accounts") dm_policy = config.get("dm_policy", "pairing") if dm_policy not in ("open", "pairing"): errors.append(f"dm_policy value invalid: '{dm_policy}', valid values: open, pairing") probe_timeout = config.get("probe_timeout_ms", 10000) if not isinstance(probe_timeout, int) or probe_timeout < 1000: errors.append(f"probe_timeout_ms must be >= 1000, got: {probe_timeout}") return errors def super_refine_twitch_config(config: dict[str, Any]) -> dict[str, Any]: refined = dict(config) group_policy = refined.get("group_policy", refined.get("groupPolicy", "open")) if group_policy == "allowall": logger.info("[TwitchConfig] Converting legacy 'allowall' to 'open'") refined["group_policy"] = "open" group_allow_from = refined.get("group_allow_from", refined.get("groupAllowFrom", [])) if group_policy == "open" and ("*" in group_allow_from): logger.info("[TwitchConfig] group_policy is 'open', wildcard in allow_from is redundant") access_token = refined.get("access_token", "") if access_token and not access_token.startswith("oauth:"): refined["access_token"] = f"oauth:{access_token}" channels = refined.get("channels", []) if channels: refined["channels"] = [ch.lstrip("#@").strip().lower() if isinstance(ch, str) else ch for ch in channels] accounts = refined.get("accounts", {}) if isinstance(accounts, dict): for acct_id, acct_cfg in list(accounts.items()): if not isinstance(acct_cfg, dict): continue acct_token = acct_cfg.get("access_token", "") if acct_token and not acct_token.startswith("oauth:"): acct_cfg["access_token"] = f"oauth:{acct_token}" acct_channels = acct_cfg.get("channels", []) if acct_channels: acct_cfg["channels"] = [ ch.lstrip("#@").strip().lower() if isinstance(ch, str) else ch for ch in acct_channels ] for field in ( "client_id", "client_secret", "rate_limit", "rate_window", "irc_host", "irc_port", "group_policy", "require_mention", "allowedRoles", "group_allow_from", "channels_config", ): if field not in acct_cfg and field in refined: acct_cfg[field] = refined[field] return refined