本次提交对Twitch适配器进行了全面升级与优化: 1. 修复UTF8截断逻辑,避免越界访问 2. 重构群聊策略配置,标准化mention相关规则 3. 新增消息缓存管理器,支持通过消息ID查询已发送消息 4. 更新配置schema,新增prefer_helix_send开关和deprecated策略自动转换 5. 新增CLEARMSG和ROOMSTATE IRC消息解析,补充事件订阅支持 6. 优化令牌刷新逻辑,增加重试机制与退避策略 7. 新增Helix API聊天消息发送、删除和公告功能 8. 扩展事件订阅类型,新增直播状态、频道更新等系统事件 9. 新增reply、delete_message、announcement等动作支持,完善操作能力 10. 重构流式发送逻辑,新增进度指示器和配置项 11. 优化重连策略,增加指数退避与计数重置
197 lines
7.6 KiB
Python
197 lines
7.6 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
|
|
prefer_helix_send: bool = False
|
|
|
|
|
|
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|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
|
|
prefer_helix_send: bool = False
|
|
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", "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"
|
|
|
|
if group_policy == "mention_only":
|
|
logger.info("[TwitchConfig] 'mention_only' is deprecated, use 'mention' instead (auto-converted)")
|
|
refined["group_policy"] = "mention"
|
|
|
|
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
|