这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
937 lines
36 KiB
Python
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
|