新增Twitch IRC协议相关的全套实现,包括: 1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重 2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理 3. API客户端:Helix API封装、认证提供者 4. 配置与部署:配置校验、设置向导 5. 辅助功能:配对管理、健康检查、目标解析等
191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
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
|