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