ForcePilot/backend/package/yuxi/channels/adapters/twitch/adapter.py
Kris 18d1ea2aac refactor(twitch): 重构Twitch适配器,新增Helix API支持与功能优化
本次提交对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. 优化重连策略,增加指数退避与计数重置
2026-05-13 16:16:02 +08:00

1013 lines
40 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.exceptions import (
ChannelAuthenticationError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
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 .actions import (
describe_message_tool,
extract_target_from_args,
handle_action,
resolve_execution_mode,
)
from .actions import (
supports_action as _supports_action,
)
from .auth_provider import create_auth_provider
from .deduplicator import MessageDeduplicator
from .eventsub_client import EventSubListener
from .helix import HelixClient
from .irc_parser import extract_nick, parse_badges, parse_irc_line
from .markdown_utils import strip_twitch_markdown
from .normalizer import normalize_irc_message, resolve_mentions
from .outbound_cache import OutboundCacheManager
from .pairing import PairingStore, check_pairing_policy
from .probe import get_app_access_token, validate_token
from .rate_limiter import RateLimiter
from .send import find_utf8_cut, format_action_line, format_cap_req, format_pong, format_privmsg_line
from .session import check_allowed_roles, check_group_policy
from .token_utils import ensure_oauth_prefix
PROGRESS_BAR_WIDTH = 8
def _progress_bar(text_len_hint: int, max_chars: int) -> str:
ratio = min(text_len_hint / max(max_chars, 1), 1.0)
filled = int(ratio * PROGRESS_BAR_WIDTH)
return "" * filled + "" * (PROGRESS_BAR_WIDTH - filled)
@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", "direct"],
supports_markdown=False,
supports_streaming=True,
streaming_modes=["off", "block"],
text_chunk_limit=500,
max_media_size_mb=0,
reply=True,
edit=False,
unsend=True,
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._broadcaster_ids: dict[str, str] = {}
self._irc_task: asyncio.Task | None = None
self._reconnect_task: asyncio.Task | None = None
self._reconnect_attempts: int = 0
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"})
prefer_helix = self.config.get("prefer_helix_send", False)
if prefer_helix:
target = self.format_outbound(response)["target"]
result = await self._send_via_helix(target, content)
if result.success:
return result
logger.info("[Twitch] Helix send failed, falling back to IRC PRIVMSG")
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"})
stream_config = self.config.get("streaming", {})
block_cfg = stream_config.get("block", {}) if isinstance(stream_config, dict) else {}
coalesce_min_chars = self.config.get(
"stream_coalesce_min_chars",
block_cfg.get("coalesce_min_chars", 30),
)
coalesce_max_delay_ms = self.config.get(
"stream_coalesce_max_delay_ms",
block_cfg.get("coalesce_idle_ms", 0),
)
show_progress = stream_config.get("progress_indicator", True)
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, progress=show_progress)
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, progress=show_progress)
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} :")
max_chars = 500 - prefix_len
suffix = _progress_bar(len(chunk), max_chars) if show_progress else ""
suffix_len = len(suffix.encode("utf-8"))
text = chunk[: max_chars - suffix_len] + 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, progress: bool = True) -> 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)
async def _resolve_broadcaster_id(self, channel: str) -> str | None:
channel_name = channel.lstrip("#").lower()
if channel_name in self._broadcaster_ids:
return self._broadcaster_ids[channel_name]
if not self._helix:
return None
user = await self._helix.get_user_by_name(channel_name)
if user:
self._broadcaster_ids[channel_name] = user["id"]
return user["id"]
return None
async def _send_via_helix(self, target: str, content: str, reply_msg_id: str | None = None) -> DeliveryResult:
if not self._helix:
return DeliveryResult(success=False, error="helix_not_available")
broadcaster_id = await self._resolve_broadcaster_id(target)
if not broadcaster_id:
return DeliveryResult(success=False, error="broadcaster_not_found")
sender_id = self._bot_user_id
if not sender_id:
return DeliveryResult(success=False, error="bot_user_id_unknown")
result = await self._helix.send_chat_message(
broadcaster_id=broadcaster_id,
sender_id=sender_id,
message=content,
reply_parent_msg_id=reply_msg_id,
)
if result is None:
return DeliveryResult(success=False, error="helix_send_failed")
message_id = result.get("message_id", "")
self._outbound_cache.record(target, content, message_id)
return DeliveryResult(success=True, metadata={"messageId": message_id})
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()
delay = min(3 * (2**self._reconnect_attempts), 120)
self._reconnect_attempts += 1
logger.info(f"[Twitch] IRC reconnecting in {delay}s (attempt {self._reconnect_attempts})")
await asyncio.sleep(delay)
try:
await self._connect_irc()
self._status = ChannelStatus.CONNECTED
self._reconnect_attempts = 0
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