ForcePilot/backend/package/yuxi/channels/adapters/twitch/adapter.py
Kris 59cd13cf84 feat(twitch): 实现完整的Twitch聊天适配器模块
新增Twitch IRC协议相关的全套实现,包括:
1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重
2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理
3. API客户端:Helix API封装、认证提供者
4. 配置与部署:配置校验、设置向导
5. 辅助功能:配对管理、健康检查、目标解析等
2026-05-12 00:50:10 +08:00

936 lines
36 KiB
Python

from __future__ import annotations
import asyncio
import os
import ssl
import time
import uuid
from collections import OrderedDict
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 .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 .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, refresh_access_token, validate_token
from .rate_limiter import RateLimiter
from .send import format_privmsg_line, format_action_line, format_pong, format_cap_req
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._token_expires_at: float | None = None
self._token_obtained_at: float | None = None
self._token_expires_in: int = 0
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._token_source: str = "none"
self._outbound_cache: OrderedDict[str, dict[str, Any]] = OrderedDict()
self._outbound_cache_max = 500
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 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:
self._token_obtained_at = time.time()
self._token_expires_in = expires_in
self._token_expires_at = self._token_obtained_at + expires_in - 300
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),
)
irc_task = asyncio.create_task(self._connect_irc())
eventsub_task = asyncio.create_task(self._connect_eventsub())
await irc_task
try:
await eventsub_task
except Exception as e:
logger.warning(f"[Twitch] EventSub failed (non-fatal): {e}")
self._status = ChannelStatus.CONNECTED
eventsub_status = self._eventsub.connected if self._eventsub else False
logger.info(f"[Twitch] Connected: IRC={self._irc_connected}, EventSub={eventsub_status}")
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
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(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)
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="circuit_breaker_open")
except Exception as e:
refreshed = await self._refresh_token_if_needed()
if not refreshed:
return DeliveryResult(success=False, error=f"unknown:{str(e)}")
try:
return await self._circuit_breaker.call(_do_send)
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_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)
silent = self.config.get("silent", False)
fmt = format_action_line if silent else format_privmsg_line
try:
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._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._flush_stream_buffer(buffer_key)
else:
if finished:
chunks = self._split_irc_text(chunk, chat_id)
return await self._send_irc_chunks(chat_id, chunks)
else:
chunk_bytes = len(chunk.encode("utf-8"))
if chunk_bytes < coalesce_min_chars:
return DeliveryResult(success=True)
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)
except Exception as e:
return DeliveryResult(success=False, error=f"unknown:{str(e)}")
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)
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="circuit_breaker_open")
except Exception as e:
return DeliveryResult(success=False, error=f"unknown:{str(e)}")
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:
entry = {
"channel": target,
"content": content,
"timestamp": time.time(),
}
cache_key = f"{target}:{len(self._outbound_cache)}"
self._outbound_cache[cache_key] = entry
while len(self._outbound_cache) > self._outbound_cache_max:
self._outbound_cache.popitem(last=False)
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 get_outbound_cache(self) -> list[dict[str, Any]]:
return list(self._outbound_cache.values())
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": self._token_expires_in,
"token_expires_at": self._token_expires_at,
"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)
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)
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 _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:
if self._token_expires_at is not None and time.time() < self._token_expires_at:
return True
client_id = self.config.get("client_id", "")
client_secret = self.config.get("client_secret", "")
refresh_token = self.config.get("refresh_token", "")
if not client_id or not client_secret or not refresh_token:
return False
try:
new_tokens = await refresh_access_token(client_id, client_secret, refresh_token)
if new_tokens is None:
logger.warning("[Twitch] Token refresh failed")
return False
new_access_token = new_tokens.get("access_token", "")
new_refresh_token = new_tokens.get("refresh_token", "")
expires_in = new_tokens.get("expires_in", 0)
if new_access_token:
self.config["access_token"] = new_access_token
if new_refresh_token:
self.config["refresh_token"] = new_refresh_token
if expires_in:
self._token_obtained_at = time.time()
self._token_expires_in = expires_in
self._token_expires_at = self._token_obtained_at + expires_in - 300
self.config["token_expires_in"] = expires_in
logger.info(f"[Twitch] Token refreshed, expires in {expires_in}s")
if self._helix:
self._helix._access_token = new_access_token
self._token_source = "refreshed"
self._audit_log("token_refresh", expires_in=str(expires_in))
logger.info("[Twitch] Access token refreshed")
return True
except Exception as e:
logger.error(f"[Twitch] Token refresh error: {e}")
return False
@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
def _find_utf8_cut(encoded: bytes, byte_limit: int) -> int:
cut = byte_limit
while cut > 0 and (encoded[cut - 1] & 0xC0) == 0x80:
cut -= 1
while cut > 0 and (encoded[cut - 1] & 0xC0) == 0xC0:
cut -= 1
if cut == 0:
cut = max(1, byte_limit)
return cut