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

937 lines
36 KiB
Python

from __future__ import annotations
import asyncio
import os
import ssl
import time
import uuid
from collections.abc import AsyncIterator
from typing import Any, ClassVar
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
)
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
from .auth_provider import create_auth_provider
from .deduplicator import MessageDeduplicator
from .eventsub_client import EventSubListener
from .helix import HelixClient
from .irc_parser import parse_badges, parse_irc_line, extract_nick
from .markdown_utils import strip_twitch_markdown
from .outbound_cache import OutboundCacheManager
from .pairing import PairingStore, check_pairing_policy
from .actions import (
handle_action,
supports_action as _supports_action,
describe_message_tool,
extract_target_from_args,
resolve_execution_mode,
)
from .normalizer import normalize_irc_message, resolve_mentions
from .probe import get_app_access_token, validate_token
from .rate_limiter import RateLimiter
from .send import format_privmsg_line, format_action_line, format_pong, format_cap_req, find_utf8_cut
from .session import check_allowed_roles, check_group_policy
from .token_utils import ensure_oauth_prefix
@register_builtin_adapter
class TwitchAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "twitch"
channel_type: ClassVar[ChannelType] = ChannelType.TWITCH
text_chunk_limit: ClassVar[int] = 500
supports_markdown: ClassVar[bool] = False
supports_media: ClassVar[bool] = False
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "block"]
webhook_path: ClassVar[str | None] = None
delivery_mode: ClassVar[str] = "direct"
capabilities = ChannelCapabilities(
chat_types=["group"],
supports_markdown=False,
supports_streaming=True,
streaming_modes=["off", "block"],
text_chunk_limit=500,
max_media_size_mb=0, # IRC protocol does not support media upload; URLs sent as text
reply=False,
edit=False,
unsend=False,
reactions=False,
polls=False,
native_commands=False,
block_streaming=True,
pin=False,
unpin=False,
)
meta = ChannelMeta(id="twitch", label="Twitch", aliases=["twitch-chat"])
KNOWN_USER_LIMIT = (20, 30)
MOD_VIP_LIMIT = (100, 30)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._eventsub: EventSubListener | None = None
self._helix: HelixClient | None = None
self._irc_connected = False
self._joined_channels: set[str] = set()
self._rate_limiter: RateLimiter | None = None
self._circuit_breaker = CircuitBreaker(failure_threshold=5)
self._bot_user_id: str | None = None
self._bot_username: str | None = None
self._irc_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._deduplicator = MessageDeduplicator()
self._auth_provider = create_auth_provider(self.config)
self._token_source: str = "none"
self._accounts: dict[str, dict[str, Any]] = {}
self._active_account_id: str = ""
self._resolve_accounts_config()
self._pairing_store = PairingStore()
self._pairing_enabled = self.config.get("pairing_enabled", False)
self._outbound_cache = OutboundCacheManager()
self._started_at = time.time()
self._stream_buffers: dict[str, dict[str, Any]] = {}
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[Twitch] Starting channel '{self.channel_id}'")
client_id = self.config.get("client_id", "")
access_token = (
self.config.get("access_token")
or os.environ.get("TWITCH_ACCESS_TOKEN", "")
or os.environ.get("OPENCLAW_TWITCH_ACCESS_TOKEN", "")
)
bot_username = self.config.get("bot_username", "").lower()
if self.config.get("access_token"):
self._token_source = "config"
elif os.environ.get("TWITCH_ACCESS_TOKEN", ""):
self._token_source = "env"
else:
self._token_source = "none"
if self._auth_provider:
token = await self._auth_provider.get_access_token()
if token:
access_token = token
self.config["access_token"] = token
if not client_id or not access_token or not bot_username:
raise ChannelAuthenticationError("Missing client_id, access_token, or bot_username")
user_info = await validate_token(client_id, access_token)
if user_info is None:
raise ChannelAuthenticationError("Invalid Twitch access token")
self._bot_user_id = user_info.get("id", "")
self._bot_username = user_info.get("login", bot_username)
logger.info(f"[Twitch] Bot verified: {self._bot_username} (ID: {self._bot_user_id})")
expires_in = self.config.get("token_expires_in", 0)
if expires_in:
logger.info(f"[Twitch] Token expires in {expires_in}s, will refresh at {expires_in - 300}s before expiry")
self._helix = HelixClient(client_id, access_token)
await self._helix.start()
self._rate_limiter = RateLimiter(
limit=self.config.get("rate_limit", 20),
window=self.config.get("rate_window", 30),
mod_limit=self.config.get("mod_rate_limit", 100),
)
await self._connect_irc()
if self.config.get("client_secret"):
asyncio.create_task(self._connect_eventsub_background())
else:
logger.info("[Twitch] No client_secret, EventSub disabled")
self._status = ChannelStatus.CONNECTED
logger.info(
f"[Twitch] Connected: IRC={self._irc_connected}, "
f"EventSub={'pending' if self.config.get('client_secret') else 'disabled'}"
)
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[Twitch] Stopping channel '{self.channel_id}'")
self._status = ChannelStatus.DISCONNECTED
self._irc_connected = False
for buffer_key, buf in list(self._stream_buffers.items()):
task = buf.get("task")
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._stream_buffers.clear()
if self._irc_task and not self._irc_task.done():
self._irc_task.cancel()
try:
await self._irc_task
except asyncio.CancelledError:
pass
self._irc_task = None
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
if self._writer:
try:
self._writer.write(b"QUIT :ForcePilot shutting down\r\n")
await self._writer.drain()
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
self._writer = None
self._reader = None
if self._eventsub:
await self._eventsub.disconnect()
self._eventsub = None
if self._helix:
await self._helix.close()
self._helix = None
async def _send_with_protection(self, send_fn, *args, **kwargs) -> DeliveryResult:
async def _protected():
return await send_fn(*args, **kwargs)
try:
return await self._circuit_breaker.call(_protected)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="circuit_breaker_open")
except Exception:
refreshed = await self._refresh_token_if_needed()
if not refreshed:
return DeliveryResult(success=False, error="send_failed")
try:
return await self._circuit_breaker.call(_protected)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="circuit_breaker_open")
except Exception as e2:
return DeliveryResult(success=False, error=f"retry_failed:{str(e2)}")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._irc_connected or not self._writer:
return DeliveryResult(success=False, error="not_connected")
await self._refresh_token_if_needed()
content = self._maybe_strip_markdown(response.content)
response_prefix = self.config.get("response_prefix", "")
if response_prefix and not content.startswith(response_prefix):
content = f"{response_prefix} {content}".rstrip()
if not content.strip():
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
async def _do_send():
target = self.format_outbound(response)["target"]
chunks = self._split_irc_text(content, target)
return await self._send_irc_chunks(target, chunks)
return await self._send_with_protection(_do_send)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if not self._irc_connected or not self._writer:
return DeliveryResult(success=False, error="not_connected")
chunk = self._maybe_strip_markdown(chunk)
if not chunk.strip():
return DeliveryResult(success=True, metadata={"messageId": "skipped"})
coalesce_min_chars = self.config.get("stream_coalesce_min_chars", 30)
coalesce_max_delay_ms = self.config.get("stream_coalesce_max_delay_ms", 0)
buffer_key = f"{chat_id}:{msg_id}"
if coalesce_max_delay_ms > 0 and not finished:
buf = self._stream_buffers.get(buffer_key)
if buf is None:
buf = {
"chat_id": chat_id,
"chunks": [],
"task": None,
}
self._stream_buffers[buffer_key] = buf
buf["chunks"].append(chunk)
total = "".join(buf["chunks"])
if len(total.encode("utf-8")) >= coalesce_min_chars:
return await self._send_with_protection(self._flush_stream_buffer, buffer_key)
if buf["task"] is None or buf["task"].done():
buf["task"] = asyncio.create_task(self._delayed_flush(buffer_key, coalesce_max_delay_ms / 1000.0))
return DeliveryResult(success=True)
elif finished and buffer_key in self._stream_buffers:
return await self._send_with_protection(self._flush_stream_buffer, buffer_key)
else:
if finished:
async def _do_send():
chunks = self._split_irc_text(chunk, chat_id)
return await self._send_irc_chunks(chat_id, chunks)
return await self._send_with_protection(_do_send)
else:
chunk_bytes = len(chunk.encode("utf-8"))
if chunk_bytes < coalesce_min_chars:
return DeliveryResult(success=True)
async def _do_send():
silent = self.config.get("silent", False)
fmt = format_action_line if silent else format_privmsg_line
prefix_len = len(f"PRIVMSG {chat_id} :")
suffix = ""
text = chunk[: 510 - prefix_len - len(suffix.encode("utf-8"))] + suffix
if not await self._rate_limiter.acquire():
return DeliveryResult(success=False, error="rate_limit_exceeded")
self._writer.write(fmt(chat_id, text).encode("utf-8") + b"\r\n")
await self._writer.drain()
return DeliveryResult(success=True)
return await self._send_with_protection(_do_send)
async def _delayed_flush(self, buffer_key: str, delay_sec: float) -> None:
await asyncio.sleep(delay_sec)
await self._flush_stream_buffer(buffer_key)
async def _flush_stream_buffer(self, buffer_key: str) -> DeliveryResult:
buf = self._stream_buffers.pop(buffer_key, None)
if buf is None:
return DeliveryResult(success=True)
content = "".join(buf.get("chunks", []))
if not content.strip():
return DeliveryResult(success=True)
chunks = self._split_irc_text(content, buf["chat_id"])
return await self._send_irc_chunks(buf["chat_id"], chunks)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._irc_connected or not self._writer:
return DeliveryResult(success=False, error="not_connected")
url = str(data) if data else ""
if not url:
return DeliveryResult(success=False, error="unknown:empty_media_url")
text = url
async def _do_send():
chunks = self._split_irc_text(text, chat_id)
return await self._send_irc_chunks(chat_id, chunks)
return await self._send_with_protection(_do_send)
async def _send_irc_chunks(self, target: str, chunks: list[str]) -> DeliveryResult:
content = "".join(chunks)
silent = self.config.get("silent", False)
fmt = format_action_line if silent else format_privmsg_line
for chunk in chunks:
if not await self._rate_limiter.acquire():
return DeliveryResult(success=False, error="rate_limit_exceeded")
self._writer.write(fmt(target, chunk).encode("utf-8") + b"\r\n")
await self._writer.drain()
self._record_outbound(target, content)
return DeliveryResult(success=True)
def _record_outbound(self, target: str, content: str) -> None:
self._outbound_cache.record(target, content)
def get_outbound_cache(self) -> list[dict[str, Any]]:
return self._outbound_cache.get_all()
def _audit_log(self, event: str, **kwargs: Any) -> None:
logger.info(f"[Twitch|AUDIT] {event} | " + " | ".join(f"{k}={v}" for k, v in kwargs.items()))
def _maybe_strip_markdown(self, text: str) -> str:
if self.config.get("strip_markdown", True):
return strip_twitch_markdown(text)
return text
def resolve_markdown_table_mode(self) -> str:
return self.config.get("markdown_table_mode", "text")
async def receive(self) -> AsyncIterator[ChannelMessage]:
return
yield # type: ignore[misc] # Twitch 浣跨敤 IRC push + EventSub push 妯″紡锛屼笉閫氳繃 receive 杞
def normalize_inbound(self, raw: Any) -> ChannelMessage:
return normalize_irc_message(raw)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
target = response.identity.channel_chat_id
if not target:
joined = list(self._joined_channels)
if joined:
target = joined[0]
else:
channels = self.config.get("channels", [])
if channels:
ch = channels[0]
target = f"#{ch.lstrip('#')}" if not ch.startswith("#") else ch
return {
"command": "PRIVMSG",
"target": target,
"text": response.content,
}
def resolve_implicit_target(self, mode: str = "implicit") -> str | None:
if mode == "heartbeat" and self._joined_channels:
return next(iter(self._joined_channels))
joined = list(self._joined_channels)
if joined:
return joined[0]
channels = self.config.get("channels", [])
if channels:
ch = channels[0]
return f"#{ch.lstrip('#')}" if not ch.startswith("#") else ch
return None
async def health_check(self) -> HealthStatus:
start = time.monotonic()
issues: list[str] = []
if not self._irc_connected:
issues.append("IRC not connected")
if self._writer and self._writer.is_closing():
issues.append("IRC connection closing")
if self._eventsub and not self._eventsub.connected:
issues.append("EventSub not connected")
token = self.config.get("access_token", "")
client_id = self.config.get("client_id", "")
if token and client_id:
try:
user_info = await validate_token(client_id, token)
if user_info is None:
issues.append("Token invalid")
except Exception as e:
issues.append(f"Helix API unreachable: {e}")
status_issues = self.collect_status_issues()
issues.extend(status_issues)
latency_ms = (time.monotonic() - start) * 1000
if issues:
return HealthStatus(status="unhealthy", last_error="; ".join(issues), latency_ms=latency_ms)
return HealthStatus(
status="healthy",
latency_ms=latency_ms,
metadata={
"irc_connected": self._irc_connected,
"eventsub_connected": self._eventsub.connected if self._eventsub else False,
"joined_channels": list(self._joined_channels),
"token_expires_in": getattr(self._auth_provider, "expires_in", 0),
"token_expires_at": getattr(self._auth_provider, "expires_at", None),
"token_source": self._token_source,
},
)
def collect_status_issues(self) -> list[str]:
issues: list[str] = []
client_id = self.config.get("client_id", "")
access_token = (
self.config.get("access_token", "")
or os.environ.get("TWITCH_ACCESS_TOKEN", "")
or os.environ.get("OPENCLAW_TWITCH_ACCESS_TOKEN", "")
)
if not client_id or not access_token:
issues.append("not_configured: missing client_id or access_token")
channels = self.config.get("channels", [])
if not channels:
issues.append("no_channels_configured")
group_policy = self.config.get("group_policy", "open")
if group_policy == "allowlist":
allowlist = self.config.get("group_allow_from", [])
channels_config = self.config.get("channels_config", {})
has_allow = bool(allowlist) or any(
cfg.get("allow_from") for cfg in channels_config.values() if isinstance(cfg, dict)
)
if not has_allow:
issues.append("allowlist_policy_with_empty_allow_from")
allowed_roles = self.config.get("allowedRoles", [])
if "all" in allowed_roles and group_policy == "allowlist":
issues.append("allowed_roles_all_with_allowlist_policy_conflict")
uptime = time.time() - self._started_at
if uptime > 7 * 86400:
issues.append(f"long_running: uptime {uptime / 86400:.1f} days")
if self._status != ChannelStatus.CONNECTED:
issues.append(f"channel_not_connected: status={self._status}")
if not self._irc_connected:
issues.append("irc_disconnected")
return issues
def _resolve_accounts_config(self) -> None:
raw_accounts = self.config.get("accounts", {})
if not isinstance(raw_accounts, dict) or not raw_accounts:
self._accounts = {}
return
base_overrides = {k: v for k, v in self.config.items() if k != "accounts"}
for account_id, account_conf in raw_accounts.items():
if not isinstance(account_conf, dict):
continue
merged = {**account_conf}
for key in (
"client_id",
"client_secret",
"rate_limit",
"rate_window",
"irc_host",
"irc_port",
"group_policy",
"require_mention",
"allowedRoles",
"group_allow_from",
"channels_config",
):
if key in base_overrides and key not in merged:
merged[key] = base_overrides[key]
self._accounts[account_id] = merged
default_account = self.config.get("defaultAccount", "")
if default_account and default_account in self._accounts:
self._active_account_id = default_account
elif "default" in self._accounts:
self._active_account_id = "default"
elif self._accounts:
self._active_account_id = next(iter(self._accounts))
logger.info(
f"[Twitch] Multi-account mode: {len(self._accounts)} accounts, "
f"active={self._active_account_id}, ids={list(self._accounts.keys())}"
)
def list_account_ids(self) -> list[str]:
if not self._accounts:
return ["default"]
return list(self._accounts.keys())
async def resolve_account_context(self, account_id: str | None = None) -> dict[str, Any] | None:
if not self._accounts:
return None
resolved_id = account_id or self._active_account_id
if not resolved_id or resolved_id not in self._accounts:
return None
account_conf = self._accounts[resolved_id]
self._active_account_id = resolved_id
return account_conf
def _get_active_account_config(self) -> dict[str, Any]:
if self._accounts and self._active_account_id:
account_conf = self._accounts.get(self._active_account_id, {})
merged = {**account_conf}
for key in (
"client_id",
"client_secret",
"rate_limit",
"rate_window",
"irc_host",
"irc_port",
"group_policy",
"require_mention",
"allowedRoles",
"group_allow_from",
"channels_config",
):
if key in self.config and self.config[key]:
merged[key] = self.config[key]
return merged
return self.config
def get_account_snapshot(self, account_id: str | None = None) -> dict[str, Any]:
accounts = self._accounts if self._accounts else {"default": self.config}
resolved_id = account_id or self._active_account_id or next(iter(accounts), "default")
account_conf = accounts.get(resolved_id, {})
return {
"account_id": resolved_id,
"bot_username": account_conf.get("bot_username", ""),
"channels": account_conf.get("channels", []),
"active": resolved_id == self._active_account_id,
"irc_connected": self._irc_connected,
"eventsub_connected": self._eventsub.connected if self._eventsub else False,
}
def supports_action(self, action: str) -> bool:
return _supports_action(action)
def describe_message_tool(self) -> dict:
return describe_message_tool()
async def handle_action(self, ctx) -> DeliveryResult:
return await handle_action(ctx, self)
def extract_target_from_args(self, args: dict) -> dict | None:
return extract_target_from_args(args)
def resolve_execution_mode(self, action: str) -> str:
return resolve_execution_mode(action)
async def _connect_irc(self) -> None:
host = self.config.get("irc_host", "irc.chat.twitch.tv")
port = self.config.get("irc_port", 6697)
token = self.config.get("access_token", "")
username = self.config.get("bot_username", "").lower()
ctx = ssl.create_default_context()
self._reader, self._writer = await asyncio.open_connection(
host=host,
port=port,
ssl=ctx,
)
self._send_line(f"PASS {ensure_oauth_prefix(token)}")
self._send_line(f"NICK {username}")
self._send_line(format_cap_req(["twitch.tv/tags", "twitch.tv/commands", "twitch.tv/membership"]))
await self._wait_for_welcome()
channels = self.config.get("channels", [])
for channel in channels:
channel_name = channel if channel.startswith("#") else f"#{channel}"
self._send_line(f"JOIN {channel_name}")
self._joined_channels.add(channel_name.lower())
self._irc_connected = True
logger.info(f"[Twitch] IRC connected as {username}, joined {len(channels)} channels")
if self._pairing_enabled and channels:
first_channel = list(self._joined_channels)[0]
def _on_pairing_approve(user_name: str, channel: str) -> None:
if self._writer and self._irc_connected:
try:
notify_channel = channel or first_channel
text = f"{user_name} has been approved to interact!"
self._writer.write(format_privmsg_line(notify_channel, text).encode("utf-8") + b"\r\n")
logger.info(f"[Twitch] Pairing approval notification sent for {user_name}")
except Exception as e:
logger.warning(f"[Twitch] Failed to send pairing notification: {e}")
self._pairing_store.set_on_approve(_on_pairing_approve, first_channel)
self._irc_task = asyncio.create_task(self._irc_message_loop())
async def _wait_for_welcome(self) -> None:
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ChannelAuthenticationError("IRC welcome timeout")
try:
line = await asyncio.wait_for(self._reader.readline(), timeout=remaining)
except TimeoutError:
raise ChannelAuthenticationError("IRC welcome timeout")
if not line:
raise ChannelAuthenticationError("IRC connection closed before welcome")
raw = line.decode("utf-8", errors="replace").rstrip("\r\n")
if " 001 " in raw or " 376 " in raw or " 372 " in raw:
continue
if " 002 " in raw or " 003 " in raw or " 004 " in raw:
return
if "NOTICE" in raw and "Login" in raw:
return
raise ChannelAuthenticationError("IRC welcome timeout")
async def _irc_message_loop(self) -> None:
IRC_READ_TIMEOUT = 360.0
while self._irc_connected and self._reader:
try:
line = await asyncio.wait_for(self._reader.readline(), timeout=IRC_READ_TIMEOUT)
except TimeoutError:
logger.warning("[Twitch] IRC read timeout (no data for 360s), reconnecting")
self._irc_connected = False
self._start_reconnect_task()
break
try:
if not line:
logger.warning("[Twitch] IRC connection closed")
self._irc_connected = False
break
raw_line = line.decode("utf-8", errors="replace").rstrip("\r\n")
if not raw_line:
continue
if raw_line.startswith("PING"):
token_str = raw_line[5:].lstrip(":")
self._send_line(format_pong(token_str))
continue
parsed = parse_irc_line(raw_line)
if not parsed.command:
continue
if parsed.command == "RECONNECT":
logger.warning("[Twitch] Reconnect requested by server")
self._start_reconnect_task()
continue
if parsed.command == "PRIVMSG":
channel = parsed.params[0] if parsed.params else ""
user_id = parsed.tags.get("user-id") or extract_nick(parsed.prefix)
content = parsed.trailing
if self._bot_user_id and user_id == self._bot_user_id:
continue
if self._pairing_enabled and not check_pairing_policy(self._pairing_store, user_id):
display_name = parsed.tags.get("display-name", user_id)
self._pairing_store.add_pending(user_id, display_name, channel)
logger.info(f"[Twitch] Unpaired user {display_name} ({user_id}) in {channel}")
self._audit_log("pairing_request", user_id=user_id, display_name=display_name, channel=channel)
continue
policy_result = check_group_policy(
self.config,
channel,
user_id,
content,
user_name=parsed.tags.get("display-name", ""),
)
if policy_result is False:
self._audit_log("policy_rejected", user_id=user_id, channel=channel, reason="group_policy")
continue
badges = parse_badges(parsed.tags.get("badges", ""))
if check_allowed_roles(self.config, channel, badges) is False:
self._audit_log("policy_rejected", user_id=user_id, channel=channel, reason="invalid_role")
continue
if "bits" in parsed.tags or (
"badges" in parsed.tags
and any(badge in parsed.tags.get("badges", "") for badge in ("broadcaster", "moderator", "vip"))
):
if self._rate_limiter:
await self._rate_limiter.switch_to_mod()
message_id = parsed.tags.get("id") or parsed.tags.get("tmi-sent-ts") or str(uuid.uuid4())
if message_id and self._deduplicator.is_duplicate(message_id):
continue
msg = normalize_irc_message(parsed)
if msg:
if self._bot_username and msg.mentions:
msg.mentions = resolve_mentions(msg.content, self._bot_username)
await self._handle_message(msg)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"[Twitch] IRC message loop error: {e}")
await asyncio.sleep(1)
if not self._irc_connected or not self._reader:
logger.info("[Twitch] IRC message loop exiting (disconnected)")
break
async def _connect_eventsub(self) -> None:
client_id = self.config.get("client_id", "")
client_secret = self.config.get("client_secret", "")
if not client_secret:
logger.warning("[Twitch] No client_secret, skipping EventSub")
return
app_token = await get_app_access_token(client_id, client_secret)
if not app_token:
logger.warning("[Twitch] Failed to get app access token, skipping EventSub")
return
channels = self.config.get("channels", [])
broadcaster_ids: list[str] = []
for ch in channels:
ch_name = ch.lstrip("#")
user = await self._helix.get_user_by_name(ch_name) if self._helix else None
if user:
broadcaster_ids.append(user["id"])
if not broadcaster_ids:
logger.warning("[Twitch] No broadcaster IDs resolved, skipping EventSub")
return
self._eventsub = EventSubListener(
helix=self._helix,
app_access_token=app_token,
broadcaster_ids=broadcaster_ids,
)
self._eventsub.on_event(self._handle_message)
await self._eventsub.connect()
async def _connect_eventsub_background(self) -> None:
try:
await self._connect_eventsub()
logger.info("[Twitch] EventSub connected successfully")
except Exception as e:
logger.warning(f"[Twitch] EventSub connection failed (degraded mode): {e}")
async def _reconnect_irc(self) -> None:
self._status = ChannelStatus.RECONNECTING
self._irc_connected = False
if self._writer:
try:
self._writer.close()
except Exception:
pass
self._writer = None
self._reader = None
await self._refresh_token_if_needed()
await asyncio.sleep(3)
try:
await self._connect_irc()
self._status = ChannelStatus.CONNECTED
logger.info("[Twitch] IRC reconnected")
except asyncio.CancelledError:
self._status = ChannelStatus.DISCONNECTED
raise
except Exception as e:
self._status = ChannelStatus.ERROR
logger.error(f"[Twitch] IRC reconnection failed: {e}")
def _send_line(self, line: str) -> None:
if self._writer:
self._writer.write(line.encode("utf-8") + b"\r\n")
def _start_reconnect_task(self) -> None:
async def _reconnect_with_timeout():
try:
await asyncio.wait_for(self._reconnect_irc(), timeout=60.0)
except TimeoutError:
logger.error("[Twitch] IRC reconnection timed out after 60s")
self._status = ChannelStatus.ERROR
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"[Twitch] IRC reconnect task failed: {e}")
self._reconnect_task = asyncio.create_task(_reconnect_with_timeout())
async def _refresh_token_if_needed(self) -> bool:
token = await self._auth_provider.get_access_token()
if token:
self.config["access_token"] = token
if self._helix:
self._helix._access_token = token
self._token_source = "refreshed"
self._audit_log("token_refresh", expires_in=str(getattr(self._auth_provider, "expires_in", 0)))
logger.info("[Twitch] Access token refreshed")
return True
return False
async def reload_config(self, new_config: dict[str, Any]) -> None:
old_channels = set(self.config.get("channels", []))
new_channels = set(new_config.get("channels", []))
self.config.update(new_config)
for ch in new_channels - old_channels:
channel_name = f"#{ch.lstrip('#')}" if not ch.startswith("#") else ch
self._send_line(f"JOIN {channel_name}")
self._joined_channels.add(channel_name.lower())
logger.info(f"[Twitch] Joined new channel: {channel_name}")
for ch in old_channels - new_channels:
channel_name = f"#{ch.lstrip('#')}" if not ch.startswith("#") else ch
self._send_line(f"PART {channel_name}")
self._joined_channels.discard(channel_name.lower())
logger.info(f"[Twitch] Parted channel: {channel_name}")
logger.info("[Twitch] Config reloaded (safety policies already take effect via pure function design)")
@staticmethod
def _split_irc_text(text: str, target: str) -> list[str]:
prefix_len = len(f"PRIVMSG {target} :")
available = max(200, 510 - prefix_len)
if available < 1:
available = 200
chunks: list[str] = []
if not text:
return [""]
remaining = text
while remaining:
encoded = remaining.encode("utf-8")
if len(encoded) <= available:
chunks.append(remaining)
break
cut = find_utf8_cut(encoded, available)
split_pos = len(encoded[:cut].decode("utf-8", errors="replace"))
nl = remaining.rfind("\n", 0, split_pos)
if nl > split_pos * 0.5:
split_pos = nl + 1
else:
sp = remaining.rfind(" ", 0, split_pos)
if sp > split_pos * 0.5:
split_pos = sp + 1
chunk = remaining[:split_pos].rstrip()
chunks.append(chunk)
remaining = remaining[split_pos:].lstrip()
return chunks