feat(twitch): 实现完整的Twitch聊天适配器模块
新增Twitch IRC协议相关的全套实现,包括: 1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重 2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理 3. API客户端:Helix API封装、认证提供者 4. 配置与部署:配置校验、设置向导 5. 辅助功能:配对管理、健康检查、目标解析等
This commit is contained in:
parent
71fec609bb
commit
59cd13cf84
@ -0,0 +1,3 @@
|
||||
from yuxi.channels.adapters.twitch.adapter import TwitchAdapter
|
||||
|
||||
__all__ = ["TwitchAdapter"]
|
||||
104
backend/package/yuxi/channels/adapters/twitch/actions.py
Normal file
104
backend/package/yuxi/channels/adapters/twitch/actions.py
Normal file
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.models import ChannelIdentity, ChannelResponse, DeliveryResult
|
||||
from yuxi.channels.protocols.actions import ActionContext
|
||||
|
||||
_ACTION_HANDLERS: dict[str, str] = {}
|
||||
|
||||
|
||||
def _build_response(ctx: ActionContext, adapter: Any, content_key: str = "content") -> ChannelResponse | None:
|
||||
content = ctx.get(content_key, ctx.get("media_url", ""))
|
||||
if not ctx.chat_id or not content:
|
||||
return None
|
||||
identity = ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=adapter.channel_type,
|
||||
channel_user_id="",
|
||||
channel_chat_id=ctx.chat_id,
|
||||
)
|
||||
return ChannelResponse(identity=identity, content=content)
|
||||
|
||||
|
||||
async def handle_action(ctx: ActionContext, adapter: Any) -> DeliveryResult:
|
||||
if ctx.action == "send":
|
||||
response = _build_response(ctx, adapter, "content")
|
||||
if response is None:
|
||||
return DeliveryResult(success=False, error="missing chat_id or content")
|
||||
return await adapter.send(response)
|
||||
if ctx.action == "send_media":
|
||||
response = _build_response(ctx, adapter, "media_url")
|
||||
if response is None:
|
||||
return DeliveryResult(success=False, error="missing chat_id or media_url")
|
||||
return await adapter.send_media(ctx.chat_id, "url", response.content)
|
||||
|
||||
unsupported_hints: dict[str, str] = {
|
||||
"reply": "Twitch IRC does not support message replies",
|
||||
"edit": "Twitch IRC does not support message editing",
|
||||
"unsend": "Twitch IRC does not support message deletion",
|
||||
"delete_message": "Twitch IRC does not support message deletion",
|
||||
"edit_message": "Twitch IRC does not support message editing",
|
||||
"reactions": "Twitch IRC does not support reactions",
|
||||
"send_reaction": "Twitch IRC does not support reactions",
|
||||
"polls": "Twitch IRC does not support polls",
|
||||
"native_commands": "Twitch IRC does not support native commands",
|
||||
"pin": "Twitch IRC does not support message pinning",
|
||||
"unpin": "Twitch IRC does not support message unpinning",
|
||||
}
|
||||
hint = unsupported_hints.get(ctx.action, f"unknown action: {ctx.action}")
|
||||
return DeliveryResult(success=False, error=hint)
|
||||
|
||||
|
||||
def supports_action(action: str) -> bool:
|
||||
return action in {"send", "send_media"}
|
||||
|
||||
|
||||
def describe_message_tool() -> dict[str, Any]:
|
||||
return {
|
||||
"send": {
|
||||
"description": "Send a text message to a Twitch channel",
|
||||
"parameters": {
|
||||
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||
"content": {"type": "string", "description": "Text content to send"},
|
||||
},
|
||||
},
|
||||
"send_media": {
|
||||
"description": "Send a media URL to a Twitch channel (sent as text)",
|
||||
"parameters": {
|
||||
"chat_id": {"type": "string", "description": "Channel name (e.g. #channel)"},
|
||||
"media_url": {"type": "string", "description": "Media URL to send"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def extract_target_from_args(args: dict[str, Any]) -> dict | None:
|
||||
chat_id = args.get("chat_id")
|
||||
if chat_id:
|
||||
return {"chat_id": chat_id}
|
||||
return None
|
||||
|
||||
|
||||
def resolve_execution_mode(action: str) -> str:
|
||||
return "send"
|
||||
|
||||
|
||||
def get_action_stats() -> dict[str, list[str]]:
|
||||
return {
|
||||
"implemented": ["send", "send_media"],
|
||||
"planned": [],
|
||||
"unsupported": [
|
||||
"reply",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reactions",
|
||||
"polls",
|
||||
"native_commands",
|
||||
"pin",
|
||||
"unpin",
|
||||
"send_reaction",
|
||||
"delete_message",
|
||||
"edit_message",
|
||||
],
|
||||
}
|
||||
935
backend/package/yuxi/channels/adapters/twitch/adapter.py
Normal file
935
backend/package/yuxi/channels/adapters/twitch/adapter.py
Normal file
@ -0,0 +1,935 @@
|
||||
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
|
||||
127
backend/package/yuxi/channels/adapters/twitch/auth_provider.py
Normal file
127
backend/package/yuxi/channels/adapters/twitch/auth_provider.py
Normal file
@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .probe import refresh_access_token, validate_token
|
||||
|
||||
|
||||
class AuthProvider(ABC):
|
||||
@abstractmethod
|
||||
async def get_access_token(self) -> str | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get_client_id(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def validate(self) -> bool: ...
|
||||
|
||||
|
||||
class StaticAuthProvider(AuthProvider):
|
||||
def __init__(self, client_id: str, access_token: str):
|
||||
self._client_id = client_id
|
||||
self._access_token = access_token
|
||||
|
||||
async def get_access_token(self) -> str | None:
|
||||
return self._access_token or None
|
||||
|
||||
async def get_client_id(self) -> str:
|
||||
return self._client_id
|
||||
|
||||
async def validate(self) -> bool:
|
||||
if not self._client_id or not self._access_token:
|
||||
return False
|
||||
user_info = await validate_token(self._client_id, self._access_token)
|
||||
return user_info is not None
|
||||
|
||||
|
||||
class RefreshingAuthProvider(AuthProvider):
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
access_token: str = "",
|
||||
refresh_token: str = "",
|
||||
):
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._access_token = access_token
|
||||
self._refresh_token = refresh_token
|
||||
self._token_expires_at: float | None = None
|
||||
self._token_obtained_at: float | None = None
|
||||
self._token_expires_in: int = 0
|
||||
|
||||
async def get_access_token(self) -> str | None:
|
||||
if self._is_expired():
|
||||
refreshed = await self._refresh()
|
||||
if not refreshed:
|
||||
logger.warning("[TwitchAuth] Token expired and refresh failed")
|
||||
return None
|
||||
return self._access_token or None
|
||||
|
||||
async def get_client_id(self) -> str:
|
||||
return self._client_id
|
||||
|
||||
async def validate(self) -> bool:
|
||||
if not self._client_id or not self._access_token:
|
||||
return False
|
||||
user_info = await validate_token(self._client_id, self._access_token)
|
||||
return user_info is not None
|
||||
|
||||
async def _refresh(self) -> bool:
|
||||
if not self._client_id or not self._client_secret or not self._refresh_token:
|
||||
return False
|
||||
try:
|
||||
new_tokens = await refresh_access_token(self._client_id, self._client_secret, self._refresh_token)
|
||||
if new_tokens is None:
|
||||
return False
|
||||
new_access = new_tokens.get("access_token", "")
|
||||
new_refresh = new_tokens.get("refresh_token", "")
|
||||
expires_in = new_tokens.get("expires_in", 0)
|
||||
if new_access:
|
||||
self._access_token = new_access
|
||||
if new_refresh:
|
||||
self._refresh_token = new_refresh
|
||||
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"[TwitchAuth] Token refreshed, expires in {expires_in}s")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[TwitchAuth] Token refresh error: {e}")
|
||||
return False
|
||||
|
||||
def _is_expired(self) -> bool:
|
||||
if self._token_expires_at is None:
|
||||
return False
|
||||
return time.time() >= self._token_expires_at
|
||||
|
||||
@property
|
||||
def expires_at(self) -> float | None:
|
||||
return self._token_expires_at
|
||||
|
||||
@property
|
||||
def expires_in(self) -> int:
|
||||
return self._token_expires_in
|
||||
|
||||
|
||||
def create_auth_provider(config: dict[str, Any]) -> AuthProvider:
|
||||
client_id = config.get("client_id", "")
|
||||
client_secret = config.get("client_secret", "")
|
||||
access_token = config.get("access_token", "")
|
||||
refresh_token = config.get("refresh_token", "")
|
||||
|
||||
if client_secret and refresh_token:
|
||||
logger.info("[TwitchAuth] Using RefreshingAuthProvider (auto-refresh enabled)")
|
||||
return RefreshingAuthProvider(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
logger.info("[TwitchAuth] Using StaticAuthProvider (manual token rotation)")
|
||||
return StaticAuthProvider(client_id=client_id, access_token=access_token)
|
||||
190
backend/package/yuxi/channels/adapters/twitch/config_schema.py
Normal file
190
backend/package/yuxi/channels/adapters/twitch/config_schema.py
Normal file
@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
class TwitchAccountSchema(BaseModel):
|
||||
bot_username: str = ""
|
||||
access_token: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
refresh_token: str = ""
|
||||
channels: list[str] = []
|
||||
group_policy: str = "open"
|
||||
require_mention: bool = True
|
||||
allowedRoles: list[str] = []
|
||||
group_allow_from: list[str] = []
|
||||
channels_config: dict[str, Any] = {}
|
||||
pairing_enabled: bool = False
|
||||
rate_limit: int = 20
|
||||
rate_window: int = 30
|
||||
mod_rate_limit: int = 100
|
||||
irc_host: str = "irc.chat.twitch.tv"
|
||||
irc_port: int = 6697
|
||||
strip_markdown: bool = True
|
||||
response_prefix: str = ""
|
||||
stream_coalesce_min_chars: int = 30
|
||||
stream_coalesce_max_delay_ms: int = 0
|
||||
silent: bool = False
|
||||
dm_policy: str = "pairing"
|
||||
probe_timeout_ms: int = 10000
|
||||
|
||||
|
||||
class TwitchConfigSchema(BaseModel):
|
||||
bot_username: str = ""
|
||||
access_token: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
refresh_token: str = ""
|
||||
channels: list[str] = []
|
||||
group_policy: str = Field(default="open", pattern=r"^(open|allowlist|disabled|mention_only)$")
|
||||
require_mention: bool = True
|
||||
allowedRoles: list[str] = []
|
||||
group_allow_from: list[str] = []
|
||||
channels_config: dict[str, Any] = {}
|
||||
pairing_enabled: bool = False
|
||||
rate_limit: int = Field(default=20, ge=1)
|
||||
rate_window: int = Field(default=30, ge=1)
|
||||
mod_rate_limit: int = Field(default=100, ge=1)
|
||||
irc_host: str = "irc.chat.twitch.tv"
|
||||
irc_port: int = 6697
|
||||
strip_markdown: bool = True
|
||||
response_prefix: str = ""
|
||||
stream_coalesce_min_chars: int = 30
|
||||
stream_coalesce_max_delay_ms: int = 0
|
||||
silent: bool = False
|
||||
dm_policy: str = "pairing"
|
||||
probe_timeout_ms: int = 10000
|
||||
accounts: dict[str, TwitchAccountSchema] = {}
|
||||
defaultAccount: str = ""
|
||||
|
||||
|
||||
def validate_twitch_config(config: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
bot_username = config.get("bot_username", "")
|
||||
access_token = config.get("access_token", "")
|
||||
client_id = config.get("client_id", "")
|
||||
|
||||
if not client_id:
|
||||
errors.append("client_id is required")
|
||||
if not access_token:
|
||||
errors.append("access_token is required")
|
||||
if not bot_username:
|
||||
errors.append("bot_username is required")
|
||||
|
||||
channels = config.get("channels", [])
|
||||
if not channels:
|
||||
errors.append("at least one channel is required in 'channels' list")
|
||||
|
||||
group_policy = config.get("group_policy", "open")
|
||||
valid_policies = {"open", "allowlist", "disabled", "mention_only"}
|
||||
if group_policy not in valid_policies:
|
||||
errors.append(f"group_policy value invalid: '{group_policy}', valid values: {valid_policies}")
|
||||
|
||||
group_allow_from = config.get("group_allow_from", [])
|
||||
if group_policy == "allowlist" and not group_allow_from:
|
||||
channels_config = config.get("channels_config", {})
|
||||
has_per_channel = any(cfg.get("allow_from") for cfg in channels_config.values() if isinstance(cfg, dict))
|
||||
if not has_per_channel:
|
||||
errors.append("group_policy is 'allowlist' but group_allow_from is empty")
|
||||
|
||||
allowed_roles = config.get("allowedRoles", [])
|
||||
valid_roles = {"moderator", "owner", "vip", "subscriber", "all"}
|
||||
for role in allowed_roles:
|
||||
if role.lower() not in valid_roles:
|
||||
errors.append(f"allowedRoles contains invalid role: '{role}', valid values: {valid_roles}")
|
||||
|
||||
if "all" in allowed_roles and group_policy == "allowlist":
|
||||
errors.append("allowedRoles contains 'all' but group_policy is 'allowlist' — this combination is redundant")
|
||||
|
||||
rate_limit = config.get("rate_limit", 20)
|
||||
rate_window = config.get("rate_window", 30)
|
||||
if not isinstance(rate_limit, int) or rate_limit < 1:
|
||||
errors.append(f"rate_limit must be a positive integer, got: {rate_limit}")
|
||||
if not isinstance(rate_window, int) or rate_window < 1:
|
||||
errors.append(f"rate_window must be a positive integer, got: {rate_window}")
|
||||
|
||||
irc_port = config.get("irc_port", 6697)
|
||||
if not isinstance(irc_port, int) or irc_port < 1 or irc_port > 65535:
|
||||
errors.append(f"irc_port must be 1-65535, got: {irc_port}")
|
||||
|
||||
accounts = config.get("accounts", {})
|
||||
if isinstance(accounts, dict):
|
||||
for acct_id, acct_cfg in accounts.items():
|
||||
if not isinstance(acct_cfg, dict):
|
||||
continue
|
||||
if not acct_cfg.get("bot_username"):
|
||||
errors.append(f"account '{acct_id}' missing bot_username")
|
||||
if not acct_cfg.get("access_token"):
|
||||
errors.append(f"account '{acct_id}' missing access_token")
|
||||
|
||||
default_account = config.get("defaultAccount", "")
|
||||
if default_account and isinstance(accounts, dict) and default_account not in accounts:
|
||||
errors.append(f"defaultAccount '{default_account}' not found in accounts")
|
||||
|
||||
dm_policy = config.get("dm_policy", "pairing")
|
||||
if dm_policy not in ("open", "pairing"):
|
||||
errors.append(f"dm_policy value invalid: '{dm_policy}', valid values: open, pairing")
|
||||
|
||||
probe_timeout = config.get("probe_timeout_ms", 10000)
|
||||
if not isinstance(probe_timeout, int) or probe_timeout < 1000:
|
||||
errors.append(f"probe_timeout_ms must be >= 1000, got: {probe_timeout}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def super_refine_twitch_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
refined = dict(config)
|
||||
|
||||
group_policy = refined.get("group_policy", refined.get("groupPolicy", "open"))
|
||||
if group_policy == "allowall":
|
||||
logger.info("[TwitchConfig] Converting legacy 'allowall' to 'open'")
|
||||
refined["group_policy"] = "open"
|
||||
|
||||
group_allow_from = refined.get("group_allow_from", refined.get("groupAllowFrom", []))
|
||||
if group_policy == "open" and ("*" in group_allow_from):
|
||||
logger.info("[TwitchConfig] group_policy is 'open', wildcard in allow_from is redundant")
|
||||
|
||||
access_token = refined.get("access_token", "")
|
||||
if access_token and not access_token.startswith("oauth:"):
|
||||
refined["access_token"] = f"oauth:{access_token}"
|
||||
|
||||
channels = refined.get("channels", [])
|
||||
if channels:
|
||||
refined["channels"] = [ch.lstrip("#@").strip().lower() if isinstance(ch, str) else ch for ch in channels]
|
||||
|
||||
accounts = refined.get("accounts", {})
|
||||
if isinstance(accounts, dict):
|
||||
for acct_id, acct_cfg in list(accounts.items()):
|
||||
if not isinstance(acct_cfg, dict):
|
||||
continue
|
||||
acct_token = acct_cfg.get("access_token", "")
|
||||
if acct_token and not acct_token.startswith("oauth:"):
|
||||
acct_cfg["access_token"] = f"oauth:{acct_token}"
|
||||
acct_channels = acct_cfg.get("channels", [])
|
||||
if acct_channels:
|
||||
acct_cfg["channels"] = [
|
||||
ch.lstrip("#@").strip().lower() if isinstance(ch, str) else ch for ch in acct_channels
|
||||
]
|
||||
for field in (
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"rate_limit",
|
||||
"rate_window",
|
||||
"irc_host",
|
||||
"irc_port",
|
||||
"group_policy",
|
||||
"require_mention",
|
||||
"allowedRoles",
|
||||
"group_allow_from",
|
||||
"channels_config",
|
||||
):
|
||||
if field not in acct_cfg and field in refined:
|
||||
acct_cfg[field] = refined[field]
|
||||
|
||||
return refined
|
||||
@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class MessageDeduplicator:
|
||||
TTL_SECONDS = 60.0
|
||||
|
||||
def __init__(self, max_size: int = 1000):
|
||||
self._max_size = max_size
|
||||
self._cache: OrderedDict[str, float] = OrderedDict()
|
||||
self._lock = Lock()
|
||||
|
||||
def is_duplicate(self, message_id: str) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if message_id in self._cache:
|
||||
ts = self._cache[message_id]
|
||||
if now - ts < self.TTL_SECONDS:
|
||||
return True
|
||||
del self._cache[message_id]
|
||||
|
||||
self._cache[message_id] = now
|
||||
self._cache.move_to_end(message_id)
|
||||
|
||||
if len(self._cache) > self._max_size:
|
||||
self._cleanup_expired(now)
|
||||
|
||||
return False
|
||||
|
||||
def mark_seen(self, message_id: str) -> None:
|
||||
with self._lock:
|
||||
self._cache[message_id] = time.monotonic()
|
||||
self._cache.move_to_end(message_id)
|
||||
|
||||
if len(self._cache) > self._max_size:
|
||||
self._cleanup_expired(time.monotonic())
|
||||
|
||||
def _cleanup_expired(self, now: float) -> None:
|
||||
expired = [k for k, ts in self._cache.items() if now - ts >= self.TTL_SECONDS]
|
||||
for k in expired:
|
||||
del self._cache[k]
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
529
backend/package/yuxi/channels/adapters/twitch/eventsub_client.py
Normal file
529
backend/package/yuxi/channels/adapters/twitch/eventsub_client.py
Normal file
@ -0,0 +1,529 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from yuxi.channels.models import (
|
||||
ChannelIdentity,
|
||||
ChannelMessage,
|
||||
ChannelType,
|
||||
ChatType,
|
||||
EventType,
|
||||
MessageType,
|
||||
)
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .helix import HelixClient
|
||||
|
||||
|
||||
class EventSubListener:
|
||||
WS_URL = "wss://eventsub.wss.twitch.tv/ws"
|
||||
INITIAL_RECONNECT_DELAY = 5
|
||||
MAX_RECONNECT_DELAY = 120
|
||||
KEEPALIVE_TIMEOUT = 15
|
||||
|
||||
SUBSCRIPTION_TYPES: list[dict[str, str]] = [
|
||||
{"type": "channel.follow", "version": "2"},
|
||||
{"type": "channel.subscribe", "version": "1"},
|
||||
{"type": "channel.subscription.message", "version": "1"},
|
||||
{"type": "channel.subscription.gift", "version": "1"},
|
||||
{"type": "channel.cheer", "version": "1"},
|
||||
{"type": "channel.raid", "version": "1"},
|
||||
{"type": "channel.channel_points_custom_reward_redemption.add", "version": "1"},
|
||||
{"type": "channel.channel_points_custom_reward_redemption.update", "version": "1"},
|
||||
{"type": "channel.hype_train.begin", "version": "1"},
|
||||
{"type": "channel.hype_train.progress", "version": "1"},
|
||||
{"type": "channel.hype_train.end", "version": "1"},
|
||||
{"type": "channel.ban", "version": "1"},
|
||||
{"type": "channel.unban", "version": "1"},
|
||||
{"type": "channel.moderator.add", "version": "1"},
|
||||
{"type": "channel.moderator.remove", "version": "1"},
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
helix: HelixClient,
|
||||
app_access_token: str,
|
||||
broadcaster_ids: list[str],
|
||||
):
|
||||
self._helix = helix
|
||||
self._app_token = app_access_token
|
||||
self._broadcaster_ids = broadcaster_ids
|
||||
self._subscription_ids: list[str] = []
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._session_id: str = ""
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
self._on_message: Any = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def on_event(self, handler) -> None:
|
||||
self._on_message = handler
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._running = False
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
if self._subscription_ids:
|
||||
await self._cleanup_subscriptions()
|
||||
|
||||
if self._ws:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self._session:
|
||||
try:
|
||||
await self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
|
||||
async def _cleanup_subscriptions(self) -> None:
|
||||
ids = self._subscription_ids[:]
|
||||
self._subscription_ids.clear()
|
||||
for sub_id in ids:
|
||||
try:
|
||||
await self._helix.delete_eventsub_subscription(sub_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"EventSub failed to delete subscription {sub_id}: {e}")
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
delay = self.INITIAL_RECONNECT_DELAY
|
||||
while self._running:
|
||||
try:
|
||||
await self._connect_ws()
|
||||
delay = self.INITIAL_RECONNECT_DELAY
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"EventSub error, reconnecting in {delay}s: {e}")
|
||||
self._connected = False
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, self.MAX_RECONNECT_DELAY)
|
||||
|
||||
async def _connect_ws(self) -> None:
|
||||
self._session = aiohttp.ClientSession()
|
||||
try:
|
||||
self._ws = await self._session.ws_connect(self.WS_URL)
|
||||
|
||||
welcome = await self._ws.receive_json()
|
||||
payload = welcome.get("payload", {})
|
||||
self._session_id = payload.get("session", {}).get("id", "")
|
||||
logger.info(f"EventSub WebSocket connected, session: {self._session_id}")
|
||||
|
||||
await self._subscribe_all()
|
||||
self._connected = True
|
||||
|
||||
await self._message_loop()
|
||||
finally:
|
||||
self._connected = False
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def _subscribe_all(self) -> None:
|
||||
self._subscription_ids.clear()
|
||||
for broadcaster_id in self._broadcaster_ids:
|
||||
for sub_def in self.SUBSCRIPTION_TYPES:
|
||||
body = {
|
||||
"type": sub_def["type"],
|
||||
"version": sub_def["version"],
|
||||
"condition": {"broadcaster_user_id": broadcaster_id},
|
||||
"transport": {
|
||||
"method": "websocket",
|
||||
"session_id": self._session_id,
|
||||
},
|
||||
}
|
||||
sub_id = await self._helix.create_eventsub_subscription(body)
|
||||
if sub_id:
|
||||
self._subscription_ids.append(sub_id)
|
||||
|
||||
async def _message_loop(self) -> None:
|
||||
while self._running and self._ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(
|
||||
self._ws.receive_json(),
|
||||
timeout=self.KEEPALIVE_TIMEOUT,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning("EventSub keepalive timeout, reconnecting")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"EventSub WebSocket error: {e}")
|
||||
break
|
||||
|
||||
message_type = msg.get("metadata", {}).get("message_type", "")
|
||||
|
||||
if message_type == "session_keepalive":
|
||||
continue
|
||||
|
||||
if message_type == "session_reconnect":
|
||||
logger.warning("EventSub reconnect requested")
|
||||
break
|
||||
|
||||
if message_type == "notification":
|
||||
payload = msg.get("payload", {})
|
||||
subscription = payload.get("subscription", {})
|
||||
event = payload.get("event", {})
|
||||
|
||||
if not event or not subscription:
|
||||
continue
|
||||
|
||||
channel_msg = self._normalize_event(
|
||||
{
|
||||
"subscription": subscription,
|
||||
"event": event,
|
||||
}
|
||||
)
|
||||
if channel_msg and self._on_message:
|
||||
await self._on_message(channel_msg)
|
||||
|
||||
def _normalize_event(self, event_data: dict[str, Any]) -> ChannelMessage | None:
|
||||
event = event_data.get("event", {})
|
||||
subscription_type = event_data.get("subscription", {}).get("type", "")
|
||||
|
||||
broadcaster_id = event.get("broadcaster_user_id", "")
|
||||
|
||||
if subscription_type == "channel.follow":
|
||||
return self._normalize_follow(event, broadcaster_id)
|
||||
if subscription_type.startswith("channel.subscribe"):
|
||||
return self._normalize_subscription(event, broadcaster_id)
|
||||
if subscription_type == "channel.subscription.message":
|
||||
return self._normalize_subscription_message(event, broadcaster_id)
|
||||
if subscription_type == "channel.subscription.gift":
|
||||
return self._normalize_subscription_gift(event, broadcaster_id)
|
||||
if subscription_type == "channel.cheer":
|
||||
return self._normalize_cheer(event, broadcaster_id)
|
||||
if subscription_type == "channel.raid":
|
||||
return self._normalize_raid(event, broadcaster_id)
|
||||
if subscription_type.startswith("channel.channel_points"):
|
||||
return self._normalize_channel_points(event, broadcaster_id)
|
||||
if subscription_type.startswith("channel.hype_train"):
|
||||
return self._normalize_hype_train(event, broadcaster_id)
|
||||
|
||||
if subscription_type == "channel.ban":
|
||||
return self._normalize_ban(event, broadcaster_id)
|
||||
if subscription_type == "channel.unban":
|
||||
return self._normalize_unban(event, broadcaster_id)
|
||||
if subscription_type == "channel.moderator.add":
|
||||
return self._normalize_mod_add(event, broadcaster_id)
|
||||
if subscription_type == "channel.moderator.remove":
|
||||
return self._normalize_mod_remove(event, broadcaster_id)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_follow(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:follow:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"❤️ {user_name} followed the channel!",
|
||||
metadata={"event": "follow"},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subscription(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
tier = event.get("tier", "1000")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:sub:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🎉 {user_name} subscribed at Tier {tier}!",
|
||||
metadata={"event": "subscription", "tier": tier},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subscription_message(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
tier = event.get("tier", "1000")
|
||||
cumulative_months = event.get("cumulative_months", 0)
|
||||
message_content = event.get("message", {}).get("text", "")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:resub:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🔁 {user_name} resubscribed for {cumulative_months} months! Message: {message_content}",
|
||||
metadata={
|
||||
"event": "resubscription",
|
||||
"tier": tier,
|
||||
"cumulative_months": cumulative_months,
|
||||
"message": message_content,
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subscription_gift(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
total = event.get("total", 1)
|
||||
tier = event.get("tier", "1000")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:gift:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🎁 {user_name} gifted {total} Tier {tier} subs!",
|
||||
metadata={"event": "subscription_gift", "tier": tier, "total": total},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_cheer(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
bits = event.get("bits", 0)
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:cheer:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"💎 {user_name} cheered {bits} Bits!",
|
||||
metadata={
|
||||
"event": "cheer",
|
||||
"bits": bits,
|
||||
"message": event.get("message", ""),
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_raid(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
from_broadcaster_name = event.get("from_broadcaster_user_name", "")
|
||||
from_broadcaster_id = event.get("from_broadcaster_user_id", "")
|
||||
viewers = event.get("viewers", 0)
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=from_broadcaster_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:raid:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🚀 {from_broadcaster_name} raided with {viewers} viewers!",
|
||||
metadata={
|
||||
"event": "raid",
|
||||
"from_broadcaster_name": from_broadcaster_name,
|
||||
"from_broadcaster_id": from_broadcaster_id,
|
||||
"viewers": viewers,
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_channel_points(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_id = event.get("user_id", "")
|
||||
user_name = event.get("user_name", "")
|
||||
reward = event.get("reward", {})
|
||||
status = event.get("status", "unfulfilled")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:points:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=event.get("user_input", ""),
|
||||
metadata={
|
||||
"event": "channel_points",
|
||||
"reward_title": reward.get("title", ""),
|
||||
"reward_cost": reward.get("cost", 0),
|
||||
"reward_id": reward.get("id", ""),
|
||||
"status": status,
|
||||
"user_name": user_name,
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_hype_train(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
level = event.get("level", 1)
|
||||
progress = event.get("progress", 0)
|
||||
goal = event.get("goal", 0)
|
||||
total = event.get("total", 0)
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id="twitch_system",
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:hype_train:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🚂 Hype Train Level {level}! ({progress}/{goal})",
|
||||
metadata={
|
||||
"event": "hype_train",
|
||||
"level": level,
|
||||
"total": total,
|
||||
"progress": progress,
|
||||
"goal": goal,
|
||||
"top_contributions": event.get("top_contributions", []),
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ban(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_name = event.get("user_name", "")
|
||||
user_id = event.get("user_id", "")
|
||||
moderator_name = event.get("moderator_user_name", "")
|
||||
reason = event.get("reason", "")
|
||||
expires_at = event.get("ends_at", "")
|
||||
permanent = event.get("is_permanent", False)
|
||||
duration = "permanent" if permanent else f"until {expires_at}"
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:ban:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🚫 {user_name} banned by {moderator_name} ({duration})" + (f": {reason}" if reason else ""),
|
||||
metadata={
|
||||
"event": "ban",
|
||||
"reason": reason,
|
||||
"permanent": permanent,
|
||||
"expires_at": expires_at,
|
||||
"moderator_name": moderator_name,
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_unban(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_name = event.get("user_name", "")
|
||||
user_id = event.get("user_id", "")
|
||||
moderator_name = event.get("moderator_user_name", "")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:unban:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"✅ {user_name} unbanned by {moderator_name}",
|
||||
metadata={
|
||||
"event": "unban",
|
||||
"moderator_name": moderator_name,
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mod_add(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_name = event.get("user_name", "")
|
||||
user_id = event.get("user_id", "")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:mod_add:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"🛡️ {user_name} is now a moderator",
|
||||
metadata={"event": "moderator_add"},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mod_remove(event: dict, broadcaster_id: str) -> ChannelMessage:
|
||||
user_name = event.get("user_name", "")
|
||||
user_id = event.get("user_id", "")
|
||||
return ChannelMessage(
|
||||
identity=ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=user_id,
|
||||
channel_chat_id=f"#broadcaster_{broadcaster_id}",
|
||||
channel_message_id=f"eventsub:mod_remove:{event.get('id')}",
|
||||
),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=f"⬇️ {user_name} moderator removed",
|
||||
metadata={"event": "moderator_remove"},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
192
backend/package/yuxi/channels/adapters/twitch/helix.py
Normal file
192
backend/package/yuxi/channels/adapters/twitch/helix.py
Normal file
@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
class HelixClient:
|
||||
"""Twitch Helix API 客户端"""
|
||||
|
||||
BASE_URL = "https://api.twitch.tv/helix"
|
||||
AUTH_URL = "https://id.twitch.tv/oauth2/token"
|
||||
|
||||
MAX_RETRIES = 3
|
||||
BASE_BACKOFF = 1.0
|
||||
MAX_BACKOFF = 10.0
|
||||
RETRY_STATUSES = frozenset({401, 429, 500, 502, 503, 504})
|
||||
|
||||
def __init__(self, client_id: str, access_token: str):
|
||||
self._client_id = client_id
|
||||
self._access_token = access_token
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@property
|
||||
def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
raise RuntimeError("HelixClient session not started, call start() first")
|
||||
return self._session
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Client-Id": self._client_id,
|
||||
}
|
||||
|
||||
async def _request_with_backoff(self, method: str, url: str, **kwargs) -> aiohttp.ClientResponse | None:
|
||||
last_status: int | None = None
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
async with self._ensure_session.request(method, url, **kwargs) as resp:
|
||||
if resp.status not in self.RETRY_STATUSES or attempt == self.MAX_RETRIES - 1:
|
||||
return resp
|
||||
last_status = resp.status
|
||||
except (TimeoutError, aiohttp.ClientError) as e:
|
||||
if attempt == self.MAX_RETRIES - 1:
|
||||
raise
|
||||
logger.warning(f"Helix {method} {url} attempt {attempt + 1} failed: {e}")
|
||||
|
||||
delay = min(self.BASE_BACKOFF * (2**attempt), self.MAX_BACKOFF)
|
||||
logger.info(f"Helix {method} {url} retrying in {delay:.1f}s (attempt {attempt + 2}/{self.MAX_RETRIES})")
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
return None
|
||||
|
||||
async def _get(self, path: str, **params: str) -> dict[str, Any] | None:
|
||||
url = f"{self.BASE_URL}{path}"
|
||||
resp = await self._request_with_backoff("GET", url, headers=self._headers(), params=params)
|
||||
if resp is None:
|
||||
return None
|
||||
try:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
if resp.status == 401:
|
||||
logger.warning(f"Helix GET {path} returned 401 after {self.MAX_RETRIES} retries")
|
||||
return None
|
||||
if resp.status == 404:
|
||||
return None
|
||||
logger.error(f"Helix GET {path} failed: {resp.status} {await resp.text()}")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Helix GET {path} connection error: {e}")
|
||||
return None
|
||||
|
||||
async def validate_token(self) -> dict[str, Any] | None:
|
||||
data = await self._get("/users")
|
||||
if data:
|
||||
users = data.get("data", [])
|
||||
return users[0] if users else None
|
||||
return None
|
||||
|
||||
async def get_user_by_name(self, username: str) -> dict[str, Any] | None:
|
||||
data = await self._get("/users", login=username)
|
||||
if data:
|
||||
users = data.get("data", [])
|
||||
return users[0] if users else None
|
||||
return None
|
||||
|
||||
async def get_user_by_id(self, user_id: str) -> dict[str, Any] | None:
|
||||
data = await self._get("/users", id=user_id)
|
||||
if data:
|
||||
users = data.get("data", [])
|
||||
return users[0] if users else None
|
||||
return None
|
||||
|
||||
async def get_channel_info(self, broadcaster_id: str) -> dict[str, Any] | None:
|
||||
data = await self._get("/channels", broadcaster_id=broadcaster_id)
|
||||
if data:
|
||||
channels = data.get("data", [])
|
||||
return channels[0] if channels else None
|
||||
return None
|
||||
|
||||
async def get_chat_badges(self, broadcaster_id: str) -> dict[str, Any] | None:
|
||||
return await self._get("/chat/badges", broadcaster_id=broadcaster_id)
|
||||
|
||||
async def get_global_chat_badges(self) -> dict[str, Any] | None:
|
||||
return await self._get("/chat/badges/global")
|
||||
|
||||
async def create_eventsub_subscription(self, payload: dict[str, Any]) -> str | None:
|
||||
url = f"{self.BASE_URL}/eventsub/subscriptions"
|
||||
resp = await self._request_with_backoff("POST", url, headers=self._headers(), json=payload)
|
||||
if resp is None:
|
||||
return None
|
||||
try:
|
||||
if resp.status in (200, 202):
|
||||
data = await resp.json()
|
||||
subs = data.get("data", [])
|
||||
if subs:
|
||||
return subs[0].get("id")
|
||||
return None
|
||||
if resp.status == 409:
|
||||
return None
|
||||
if resp.status == 429:
|
||||
logger.warning("EventSub subscription rate limited")
|
||||
return None
|
||||
body = await resp.text()
|
||||
logger.error(f"EventSub subscription failed ({resp.status}): {body}")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"EventSub subscription connection error: {e}")
|
||||
return None
|
||||
|
||||
async def get_app_access_token(self, client_secret: str) -> str | None:
|
||||
params = {
|
||||
"client_id": self._client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
resp = await self._request_with_backoff("POST", self.AUTH_URL, json=params)
|
||||
if resp is None:
|
||||
return None
|
||||
try:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get("access_token")
|
||||
logger.error(f"App access token request failed: {resp.status} {await resp.text()}")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"App access token connection error: {e}")
|
||||
return None
|
||||
|
||||
async def refresh_user_token(self, client_secret: str, refresh_token: str) -> dict[str, Any] | None:
|
||||
params = {
|
||||
"client_id": self._client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
resp = await self._request_with_backoff("POST", self.AUTH_URL, json=params)
|
||||
if resp is None:
|
||||
return None
|
||||
try:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
logger.error(f"Token refresh failed: {resp.status} {await resp.text()}")
|
||||
return None
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Token refresh connection error: {e}")
|
||||
return None
|
||||
|
||||
async def delete_eventsub_subscription(self, subscription_id: str) -> bool:
|
||||
url = f"{self.BASE_URL}/eventsub/subscriptions"
|
||||
params = {"id": subscription_id}
|
||||
resp = await self._request_with_backoff("DELETE", url, headers=self._headers(), params=params)
|
||||
if resp is None:
|
||||
return False
|
||||
try:
|
||||
return resp.status in (200, 204)
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"EventSub delete subscription error: {e}")
|
||||
return False
|
||||
142
backend/package/yuxi/channels/adapters/twitch/irc_parser.py
Normal file
142
backend/package/yuxi/channels/adapters/twitch/irc_parser.py
Normal file
@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_IRC_LINE_PATTERN = re.compile(
|
||||
r"^(?:@(?P<tags>[^ ]+) )?(?::(?P<prefix>[^ ]+) )?(?P<command>[a-zA-Z]+|\d{3})"
|
||||
r"(?: (?P<params>.+?))?(?: :(?P<trailing>.*))?$"
|
||||
)
|
||||
|
||||
_PREFIX_PATTERN = re.compile(r"^([^!@]+)(?:!([^@]+))?(?:@(.+))?$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedIRCMessage:
|
||||
command: str
|
||||
prefix: str | None
|
||||
params: list[str]
|
||||
trailing: str
|
||||
tags: dict[str, str]
|
||||
raw: str
|
||||
|
||||
|
||||
def parse_irc_line(line: str) -> ParsedIRCMessage:
|
||||
match = _IRC_LINE_PATTERN.match(line)
|
||||
if match is None:
|
||||
return ParsedIRCMessage(
|
||||
command="",
|
||||
prefix=None,
|
||||
params=[],
|
||||
trailing="",
|
||||
tags={},
|
||||
raw=line,
|
||||
)
|
||||
|
||||
command = match.group("command").upper()
|
||||
prefix = match.group("prefix") or None
|
||||
|
||||
params: list[str] = []
|
||||
params_str = match.group("params") or ""
|
||||
if params_str:
|
||||
params.extend(params_str.split())
|
||||
|
||||
trailing = match.group("trailing") or ""
|
||||
|
||||
tags = _parse_tags(match.group("tags") or "")
|
||||
|
||||
return ParsedIRCMessage(
|
||||
command=command,
|
||||
prefix=prefix,
|
||||
params=params,
|
||||
trailing=trailing,
|
||||
tags=tags,
|
||||
raw=line,
|
||||
)
|
||||
|
||||
|
||||
def _parse_tags(tags_str: str) -> dict[str, str]:
|
||||
if not tags_str:
|
||||
return {}
|
||||
|
||||
tags: dict[str, str] = {}
|
||||
for tag in tags_str.split(";"):
|
||||
if "=" in tag:
|
||||
key, value = tag.split("=", 1)
|
||||
tags[key] = value
|
||||
else:
|
||||
tags[tag] = "1"
|
||||
return tags
|
||||
|
||||
|
||||
def parse_prefix(prefix: str | None) -> dict[str, str | None]:
|
||||
if not prefix:
|
||||
return {"nick": None, "user": None, "host": None}
|
||||
|
||||
m = _PREFIX_PATTERN.match(prefix)
|
||||
if m is None:
|
||||
return {"nick": prefix, "user": None, "host": None}
|
||||
|
||||
return {
|
||||
"nick": m.group(1) or None,
|
||||
"user": m.group(2) or None,
|
||||
"host": m.group(3) or None,
|
||||
}
|
||||
|
||||
|
||||
def extract_nick(prefix: str | None) -> str:
|
||||
if not prefix:
|
||||
return "unknown"
|
||||
return prefix.split("!")[0]
|
||||
|
||||
|
||||
def parse_badges(badges_str: str) -> dict[str, str]:
|
||||
if not badges_str:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for badge in badges_str.split(","):
|
||||
if "/" in badge:
|
||||
name, version = badge.split("/", 1)
|
||||
result[name] = version
|
||||
else:
|
||||
result[badge] = "1"
|
||||
return result
|
||||
|
||||
|
||||
def parse_emotes(emotes_str: str, message_text: str) -> list[dict[str, Any]]:
|
||||
if not emotes_str:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for emote_entry in emotes_str.split("/"):
|
||||
if ":" not in emote_entry:
|
||||
continue
|
||||
emote_id, positions = emote_entry.split(":", 1)
|
||||
for pos in positions.split(","):
|
||||
if "-" not in pos:
|
||||
continue
|
||||
start_str, end_str = pos.split("-", 1)
|
||||
start_idx, end_idx = int(start_str), int(end_str)
|
||||
emote_text = message_text[start_idx : end_idx + 1] if message_text else ""
|
||||
result.append(
|
||||
{
|
||||
"id": emote_id,
|
||||
"text": emote_text,
|
||||
"start": start_idx,
|
||||
"end": end_idx,
|
||||
"url": f"https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/default/dark/1.0",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_identity_tags(**kwargs: str | int | None) -> str:
|
||||
"""生成 Twitch IRC 消息中常用的 identity 标签字符串"""
|
||||
parts = [f"{k}={v}" for k, v in kwargs.items() if v is not None]
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def make_irc_message_id(user_id: str, tmi_sent_ts: str | None = None) -> str:
|
||||
ts = tmi_sent_ts or str(int(time.time() * 1000))
|
||||
return f"{user_id}:{ts}"
|
||||
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_LINK_PATTERN = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
||||
_IMG_PATTERN = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
|
||||
_BOLD_PATTERN = re.compile(r"\*\*(.+?)\*\*")
|
||||
_BOLD_ALT_PATTERN = re.compile(r"__(.+?)__")
|
||||
_ITALIC_PATTERN = re.compile(r"\*(.+?)\*")
|
||||
_ITALIC_ALT_PATTERN = re.compile(r"_(.+?)_")
|
||||
_STRIKETHROUGH_PATTERN = re.compile(r"~~(.+?)~~")
|
||||
_FENCED_CODE_PATTERN = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL)
|
||||
_INLINE_CODE_PATTERN = re.compile(r"`([^`]+)`")
|
||||
_HEADING_PATTERN = re.compile(r"^#{1,6}\s+", re.MULTILINE)
|
||||
_UNORDERED_LIST_PATTERN = re.compile(r"^[\-\*]\s+", re.MULTILINE)
|
||||
_ORDERED_LIST_PATTERN = re.compile(r"^\d+\.\s+", re.MULTILINE)
|
||||
_BLOCKQUOTE_PATTERN = re.compile(r"^>\s?", re.MULTILINE)
|
||||
_HR_PATTERN = re.compile(r"^[\-\*_]{3,}\s*$", re.MULTILINE)
|
||||
_MULTISPACE_PATTERN = re.compile(r"[ \t]+")
|
||||
_MULTINEWLINE_PATTERN = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def strip_twitch_markdown(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = _IMG_PATTERN.sub("", text)
|
||||
|
||||
text = _LINK_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _FENCED_CODE_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _HR_PATTERN.sub("", text)
|
||||
|
||||
text = _BOLD_PATTERN.sub(r"\1", text)
|
||||
text = _BOLD_ALT_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _ITALIC_PATTERN.sub(r"\1", text)
|
||||
text = _ITALIC_ALT_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _STRIKETHROUGH_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _INLINE_CODE_PATTERN.sub(r"\1", text)
|
||||
|
||||
text = _HEADING_PATTERN.sub("", text)
|
||||
|
||||
text = _ORDERED_LIST_PATTERN.sub("", text)
|
||||
text = _UNORDERED_LIST_PATTERN.sub("", text)
|
||||
|
||||
text = _BLOCKQUOTE_PATTERN.sub("", text)
|
||||
|
||||
text = _MULTISPACE_PATTERN.sub(" ", text)
|
||||
text = _MULTINEWLINE_PATTERN.sub("\n\n", text)
|
||||
|
||||
return text.strip()
|
||||
233
backend/package/yuxi/channels/adapters/twitch/normalizer.py
Normal file
233
backend/package/yuxi/channels/adapters/twitch/normalizer.py
Normal file
@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, UTC
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.models import (
|
||||
ChannelIdentity,
|
||||
ChannelMessage,
|
||||
ChannelType,
|
||||
ChatType,
|
||||
EventType,
|
||||
MentionsInfo,
|
||||
MessageType,
|
||||
)
|
||||
|
||||
from .irc_parser import (
|
||||
ParsedIRCMessage,
|
||||
extract_nick,
|
||||
parse_badges,
|
||||
parse_emotes,
|
||||
make_irc_message_id,
|
||||
)
|
||||
|
||||
_FILTERED_NOTICE_MSG_IDS = frozenset(
|
||||
{
|
||||
"subs_on",
|
||||
"subs_off",
|
||||
"emote_only_on",
|
||||
"emote_only_off",
|
||||
"slow_on",
|
||||
"slow_off",
|
||||
"followers_on",
|
||||
"followers_off",
|
||||
"r9k_on",
|
||||
"r9k_off",
|
||||
"host_on",
|
||||
"host_off",
|
||||
}
|
||||
)
|
||||
|
||||
_EVENT_MAP: dict[str, str] = {
|
||||
"sub": "subscription",
|
||||
"resub": "resubscription",
|
||||
"subgift": "subscription_gift",
|
||||
"anonsubgift": "anonymous_subscription_gift",
|
||||
"submysterygift": "subscription_mystery_gift",
|
||||
"raid": "raid",
|
||||
"ritual": "ritual",
|
||||
"announcement": "announcement",
|
||||
}
|
||||
|
||||
_SKIP_COMMANDS = frozenset({"JOIN", "PART", "HOSTTARGET", "ROOMSTATE", "USERSTATE"})
|
||||
|
||||
|
||||
def _make_identity(
|
||||
channel_user_id: str,
|
||||
channel_chat_id: str,
|
||||
channel_message_id: str | None = None,
|
||||
) -> ChannelIdentity:
|
||||
return ChannelIdentity(
|
||||
channel_id="twitch",
|
||||
channel_type=ChannelType.TWITCH,
|
||||
channel_user_id=channel_user_id,
|
||||
channel_chat_id=channel_chat_id,
|
||||
channel_message_id=channel_message_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_timestamp(tmi_sent_ts: str | None) -> datetime:
|
||||
ts = int(tmi_sent_ts or "0")
|
||||
if ts:
|
||||
return datetime.fromtimestamp(ts / 1000, tz=UTC)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def resolve_mentions(content: str, bot_username: str | None = None) -> MentionsInfo:
|
||||
mentioned: list[str] = []
|
||||
is_bot_mentioned = False
|
||||
bot_lower = bot_username.lower() if bot_username else None
|
||||
for word in content.split():
|
||||
if word.startswith("@"):
|
||||
name = word[1:].rstrip(",.")
|
||||
mentioned.append(name)
|
||||
if bot_lower and name.lower() == bot_lower:
|
||||
is_bot_mentioned = True
|
||||
return MentionsInfo(
|
||||
mentioned_user_ids=mentioned,
|
||||
is_bot_mentioned=is_bot_mentioned,
|
||||
)
|
||||
|
||||
|
||||
def normalize_irc_message(parsed: ParsedIRCMessage) -> ChannelMessage | None:
|
||||
command = parsed.command
|
||||
tags = parsed.tags
|
||||
|
||||
# ---- PRIVMSG: 普通聊天消息 ----
|
||||
if command == "PRIVMSG":
|
||||
channel = parsed.params[0] if parsed.params else ""
|
||||
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
|
||||
message_id = tags.get("id") or tags.get("tmi-sent-ts") or make_irc_message_id(user_id)
|
||||
|
||||
identity = _make_identity(user_id, channel, message_id)
|
||||
|
||||
bits = int(tags.get("bits", "0"))
|
||||
badges = parse_badges(tags.get("badges", ""))
|
||||
emotes = parse_emotes(tags.get("emotes", ""), parsed.trailing)
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"display_name": tags.get("display-name", user_id),
|
||||
"color": tags.get("color"),
|
||||
"badges": badges,
|
||||
"emotes": emotes,
|
||||
"bits": bits,
|
||||
"is_mod": tags.get("mod") == "1",
|
||||
"is_subscriber": tags.get("subscriber") == "1",
|
||||
"is_turbo": tags.get("turbo") == "1",
|
||||
"user_type": tags.get("user-type"),
|
||||
"tmi_sent_ts": tags.get("tmi-sent-ts"),
|
||||
}
|
||||
|
||||
return ChannelMessage(
|
||||
identity=identity,
|
||||
event_type=EventType.MESSAGE_RECEIVED,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=parsed.trailing,
|
||||
mentions=resolve_mentions(parsed.trailing),
|
||||
metadata=metadata,
|
||||
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
|
||||
)
|
||||
|
||||
# ---- USERNOTICE: 订阅/赠礼/Raid/新人 ----
|
||||
if command == "USERNOTICE":
|
||||
msg_id = tags.get("msg-id", "")
|
||||
channel = parsed.params[0] if parsed.params else ""
|
||||
user_id = tags.get("user-id", "system")
|
||||
message_id = tags.get("id") or make_irc_message_id(user_id, tags.get("tmi-sent-ts"))
|
||||
|
||||
identity = _make_identity(user_id, channel, message_id)
|
||||
|
||||
metadata = {
|
||||
"event": _EVENT_MAP.get(msg_id, msg_id),
|
||||
"display_name": tags.get("display-name", user_id),
|
||||
"msg_param_sub_plan": tags.get("msg-param-sub-plan"),
|
||||
"msg_param_months": tags.get("msg-param-cumulative-months"),
|
||||
"msg_param_recipient": tags.get("msg-param-recipient-user-name"),
|
||||
"msg_param_gift_count": tags.get("msg-param-gift-months"),
|
||||
"msg_param_viewer_count": tags.get("msg-param-viewerCount"),
|
||||
"system_msg": tags.get("system-msg", parsed.trailing),
|
||||
}
|
||||
|
||||
return ChannelMessage(
|
||||
identity=identity,
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=tags.get("system-msg", parsed.trailing),
|
||||
metadata=metadata,
|
||||
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
|
||||
)
|
||||
|
||||
# ---- CLEARCHAT: 消息删除/用户 timeout ----
|
||||
if command == "CLEARCHAT":
|
||||
channel = parsed.params[0] if parsed.params else ""
|
||||
target_user = tags.get("target-user-id", "") or parsed.trailing
|
||||
ban_duration = tags.get("ban-duration", "0")
|
||||
|
||||
return ChannelMessage(
|
||||
identity=_make_identity(target_user, channel),
|
||||
event_type=EventType.MESSAGE_DELETED,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content="",
|
||||
metadata={
|
||||
"event": "clearchat",
|
||||
"ban_duration_sec": int(ban_duration),
|
||||
},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# ---- WHISPER: 私信 ----
|
||||
if command == "WHISPER":
|
||||
user_id = tags.get("user-id") or extract_nick(parsed.prefix)
|
||||
message_id = tags.get("message-id") or make_irc_message_id(user_id)
|
||||
identity = _make_identity(user_id, f"#whisper_{user_id}", message_id)
|
||||
display_name = tags.get("display-name", user_id)
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"display_name": display_name,
|
||||
"color": tags.get("color"),
|
||||
"badges": parse_badges(tags.get("badges", "")),
|
||||
"emotes": parse_emotes(tags.get("emotes", ""), parsed.trailing),
|
||||
"thread_id": tags.get("thread-id"),
|
||||
}
|
||||
|
||||
return ChannelMessage(
|
||||
identity=identity,
|
||||
event_type=EventType.MESSAGE_RECEIVED,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.DIRECT,
|
||||
content=parsed.trailing,
|
||||
mentions=resolve_mentions(parsed.trailing),
|
||||
metadata=metadata,
|
||||
timestamp=_make_timestamp(tags.get("tmi-sent-ts")),
|
||||
)
|
||||
|
||||
# ---- NOTICE: 系统通知 ----
|
||||
if command == "NOTICE":
|
||||
channel = parsed.params[0] if parsed.params else ""
|
||||
msg_id = tags.get("msg-id", "")
|
||||
|
||||
if msg_id in _FILTERED_NOTICE_MSG_IDS:
|
||||
return None
|
||||
|
||||
return ChannelMessage(
|
||||
identity=_make_identity("twitch_system", channel),
|
||||
event_type=EventType.SYSTEM_EVENT,
|
||||
message_type=MessageType.TEXT,
|
||||
chat_type=ChatType.GROUP,
|
||||
content=parsed.trailing,
|
||||
metadata={"event": "notice", "msg_id": msg_id},
|
||||
timestamp=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# ---- PING → 内部处理 ----
|
||||
if command == "PING":
|
||||
return None
|
||||
|
||||
# ---- JOIN/PART/HOSTTARGET/ROOMSTATE/USERSTATE → 内部状态 ----
|
||||
if command in _SKIP_COMMANDS:
|
||||
return None
|
||||
|
||||
return None
|
||||
91
backend/package/yuxi/channels/adapters/twitch/outbound.py
Normal file
91
backend/package/yuxi/channels/adapters/twitch/outbound.py
Normal file
@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.models import DeliveryResult
|
||||
|
||||
from .send import format_privmsg_line, format_action_line
|
||||
|
||||
|
||||
class TwitchOutboundAdapter:
|
||||
def __init__(self, writer: Any, rate_limiter: Any, silent: bool = False):
|
||||
self._writer = writer
|
||||
self._rate_limiter = rate_limiter
|
||||
self._silent = silent
|
||||
|
||||
@property
|
||||
def silent(self) -> bool:
|
||||
return self._silent
|
||||
|
||||
@silent.setter
|
||||
def silent(self, value: bool) -> None:
|
||||
self._silent = value
|
||||
|
||||
def _fmt(self, target: str, text: str) -> str:
|
||||
return format_action_line(target, text) if self._silent else format_privmsg_line(target, text)
|
||||
|
||||
@staticmethod
|
||||
def split_text(text: str, target: str, prefix_len: int | None = None) -> list[str]:
|
||||
if prefix_len is None:
|
||||
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
|
||||
|
||||
async def send_chunks(self, target: str, chunks: list[str]) -> DeliveryResult:
|
||||
for chunk in chunks:
|
||||
if self._rate_limiter and not await self._rate_limiter.acquire():
|
||||
return DeliveryResult(success=False, error="rate_limit_exceeded")
|
||||
self._writer.write(self._fmt(target, chunk).encode("utf-8") + b"\r\n")
|
||||
await self._writer.drain()
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
async def send_text(self, target: str, text: str) -> DeliveryResult:
|
||||
chunks = self.split_text(text, target)
|
||||
return await self.send_chunks(target, chunks)
|
||||
|
||||
async def send_single_line(self, target: str, text: str) -> DeliveryResult:
|
||||
if self._rate_limiter and not await self._rate_limiter.acquire():
|
||||
return DeliveryResult(success=False, error="rate_limit_exceeded")
|
||||
self._writer.write(self._fmt(target, text).encode("utf-8") + b"\r\n")
|
||||
await self._writer.drain()
|
||||
return DeliveryResult(success=True)
|
||||
|
||||
|
||||
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
|
||||
112
backend/package/yuxi/channels/adapters/twitch/pairing.py
Normal file
112
backend/package/yuxi/channels/adapters/twitch/pairing.py
Normal file
@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
_PAIRING_PREFIX_PATTERN = re.compile(r"^(twitch:|user:)", re.IGNORECASE)
|
||||
|
||||
|
||||
def strip_pairing_prefix(user_id: str) -> str:
|
||||
return _PAIRING_PREFIX_PATTERN.sub("", user_id).strip()
|
||||
|
||||
|
||||
class PairingStore:
|
||||
MAX_PENDING = 500
|
||||
PENDING_TTL = 300.0
|
||||
|
||||
def __init__(self):
|
||||
self._approved: dict[str, float] = {}
|
||||
self._pending: OrderedDict[str, float] = OrderedDict()
|
||||
self._lock = Lock()
|
||||
self._on_approve: Any = None
|
||||
self._on_approve_channel: str = ""
|
||||
|
||||
def set_on_approve(self, callback: Any, channel: str = "") -> None:
|
||||
self._on_approve = callback
|
||||
self._on_approve_channel = channel
|
||||
|
||||
def _normalize(self, user_id: str) -> str:
|
||||
return strip_pairing_prefix(user_id)
|
||||
|
||||
def is_approved(self, user_id: str) -> bool:
|
||||
with self._lock:
|
||||
return self._normalize(user_id) in self._approved
|
||||
|
||||
def is_pending(self, user_id: str) -> bool:
|
||||
with self._lock:
|
||||
return self._normalize(user_id) in self._pending
|
||||
|
||||
def add_pending(self, user_id: str, user_name: str, channel: str) -> None:
|
||||
uid = self._normalize(user_id)
|
||||
with self._lock:
|
||||
self._pending[uid] = time.monotonic()
|
||||
self._pending.move_to_end(uid)
|
||||
self._evict_expired_pending()
|
||||
|
||||
def approve(self, user_id: str, user_name: str = "") -> bool:
|
||||
uid = self._normalize(user_id)
|
||||
with self._lock:
|
||||
if uid in self._pending:
|
||||
del self._pending[uid]
|
||||
self._approved[uid] = time.monotonic()
|
||||
if self._on_approve:
|
||||
try:
|
||||
self._on_approve(user_name or uid, self._on_approve_channel)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def reject(self, user_id: str) -> bool:
|
||||
uid = self._normalize(user_id)
|
||||
with self._lock:
|
||||
if uid in self._pending:
|
||||
del self._pending[uid]
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_approval(self, user_id: str) -> bool:
|
||||
uid = self._normalize(user_id)
|
||||
with self._lock:
|
||||
if uid in self._approved:
|
||||
del self._approved[uid]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_approved_users(self) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._approved.keys())
|
||||
|
||||
def get_pending_users(self) -> list[str]:
|
||||
with self._lock:
|
||||
self._evict_expired_pending()
|
||||
return list(self._pending.keys())
|
||||
|
||||
def _evict_expired_pending(self) -> None:
|
||||
now = time.monotonic()
|
||||
expired = [uid for uid, ts in self._pending.items() if now - ts >= self.PENDING_TTL]
|
||||
for uid in expired:
|
||||
del self._pending[uid]
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._approved)
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._pending)
|
||||
|
||||
|
||||
def check_pairing_policy(
|
||||
pairings: PairingStore,
|
||||
user_id: str,
|
||||
content: str | None = None,
|
||||
) -> bool:
|
||||
return pairings.is_approved(user_id)
|
||||
|
||||
|
||||
def format_pairing_notification(user_name: str, approval_code: str) -> str:
|
||||
return f"{user_name} wants to interact. Approve with: !approve {approval_code}"
|
||||
89
backend/package/yuxi/channels/adapters/twitch/probe.py
Normal file
89
backend/package/yuxi/channels/adapters/twitch/probe.py
Normal file
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.models import HealthStatus
|
||||
|
||||
from .helix import HelixClient
|
||||
|
||||
|
||||
async def health_check(config: dict[str, Any], timeout_ms: int = 10000) -> HealthStatus:
|
||||
start = time.monotonic()
|
||||
issues: list[str] = []
|
||||
|
||||
token = config.get("access_token", "")
|
||||
client_id = config.get("client_id", "")
|
||||
|
||||
if not token or not client_id:
|
||||
return HealthStatus(
|
||||
status="unhealthy",
|
||||
last_error="Missing client_id or access_token",
|
||||
latency_ms=(time.monotonic() - start) * 1000,
|
||||
)
|
||||
|
||||
helix = HelixClient(client_id, token)
|
||||
try:
|
||||
await helix.start()
|
||||
user_info = await helix.validate_token()
|
||||
if user_info is None:
|
||||
issues.append("Token invalid or expired (401)")
|
||||
except Exception as e:
|
||||
issues.append(f"Helix API unreachable: {e}")
|
||||
finally:
|
||||
await helix.close()
|
||||
|
||||
latency_ms = (time.monotonic() - start) * 1000
|
||||
probe_timeout_ms = config.get("probe_timeout_ms", timeout_ms)
|
||||
if latency_ms > probe_timeout_ms:
|
||||
issues.append(f"Probe timeout: {latency_ms:.0f}ms > {probe_timeout_ms}ms")
|
||||
|
||||
if issues:
|
||||
return HealthStatus(
|
||||
status="unhealthy",
|
||||
last_error="; ".join(issues),
|
||||
latency_ms=latency_ms,
|
||||
metadata={"timeout_ms": probe_timeout_ms},
|
||||
)
|
||||
return HealthStatus(
|
||||
status="healthy",
|
||||
latency_ms=latency_ms,
|
||||
metadata={"api": "helix", "timeout_ms": probe_timeout_ms},
|
||||
)
|
||||
|
||||
|
||||
async def validate_token(client_id: str, access_token: str) -> dict[str, Any] | None:
|
||||
helix = HelixClient(client_id, access_token)
|
||||
try:
|
||||
await helix.start()
|
||||
return await helix.validate_token()
|
||||
finally:
|
||||
await helix.close()
|
||||
|
||||
|
||||
async def get_broadcaster_id(client_id: str, access_token: str, channel_name: str) -> str | None:
|
||||
helix = HelixClient(client_id, access_token)
|
||||
try:
|
||||
await helix.start()
|
||||
user = await helix.get_user_by_name(channel_name)
|
||||
return user["id"] if user else None
|
||||
finally:
|
||||
await helix.close()
|
||||
|
||||
|
||||
async def refresh_access_token(client_id: str, client_secret: str, refresh_token: str) -> dict[str, Any] | None:
|
||||
helix = HelixClient(client_id, "")
|
||||
try:
|
||||
await helix.start()
|
||||
return await helix.refresh_user_token(client_secret, refresh_token)
|
||||
finally:
|
||||
await helix.close()
|
||||
|
||||
|
||||
async def get_app_access_token(client_id: str, client_secret: str) -> str | None:
|
||||
helix = HelixClient(client_id, "")
|
||||
try:
|
||||
await helix.start()
|
||||
return await helix.get_app_access_token(client_secret)
|
||||
finally:
|
||||
await helix.close()
|
||||
@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Twitch 速率限制令牌桶
|
||||
|
||||
默认 20msg/30s (known user),Mod/VIP 切换至 100msg/30s。
|
||||
mod 模式 60 秒后自动切回 known 模式。
|
||||
"""
|
||||
|
||||
MOD_TIMEOUT = 60.0
|
||||
|
||||
def __init__(self, limit: int = 20, window: float = 30.0, mod_limit: int = 100):
|
||||
self._default_limit = limit
|
||||
self._mod_limit = mod_limit
|
||||
self._current_limit = limit
|
||||
self._window = window
|
||||
self._tokens = float(limit)
|
||||
self._last_refill = time.monotonic()
|
||||
self._mod_until: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def switch_to_mod(self) -> None:
|
||||
async with self._lock:
|
||||
if self._current_limit == self._mod_limit:
|
||||
return
|
||||
self._current_limit = self._mod_limit
|
||||
self._tokens = float(self._mod_limit)
|
||||
self._last_refill = time.monotonic()
|
||||
self._mod_until = time.monotonic() + self.MOD_TIMEOUT
|
||||
|
||||
async def switch_to_known(self) -> None:
|
||||
async with self._lock:
|
||||
self._current_limit = self._default_limit
|
||||
self._tokens = float(self._default_limit)
|
||||
self._last_refill = time.monotonic()
|
||||
self._mod_until = None
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
|
||||
if self._mod_until is not None and now >= self._mod_until:
|
||||
self._current_limit = self._default_limit
|
||||
self._mod_until = None
|
||||
|
||||
elapsed = now - self._last_refill
|
||||
refill = elapsed * (self._current_limit / self._window)
|
||||
self._tokens = min(float(self._current_limit), self._tokens + refill)
|
||||
self._last_refill = now
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
return self._tokens
|
||||
47
backend/package/yuxi/channels/adapters/twitch/resolver.py
Normal file
47
backend/package/yuxi/channels/adapters/twitch/resolver.py
Normal file
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
from .helix import HelixClient
|
||||
|
||||
|
||||
def _is_numeric_id(value: str) -> bool:
|
||||
return value.isdigit()
|
||||
|
||||
|
||||
async def resolve_twitch_target(
|
||||
input_value: str,
|
||||
client_id: str,
|
||||
access_token: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""将输入(用户名或用户 ID)解析为 Twitch 用户信息"""
|
||||
value = input_value.strip().lstrip("@").lower()
|
||||
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if not client_id or not access_token:
|
||||
logger.warning("resolve_twitch_target: missing credentials")
|
||||
return None
|
||||
|
||||
helix = HelixClient(client_id, access_token)
|
||||
try:
|
||||
await helix.start()
|
||||
|
||||
if _is_numeric_id(value):
|
||||
user = await helix.get_user_by_id(value)
|
||||
if user:
|
||||
return user
|
||||
return None
|
||||
|
||||
user = await helix.get_user_by_name(value)
|
||||
if user:
|
||||
return user
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"resolve_twitch_target error for '{input_value}': {e}")
|
||||
return None
|
||||
finally:
|
||||
await helix.close()
|
||||
17
backend/package/yuxi/channels/adapters/twitch/send.py
Normal file
17
backend/package/yuxi/channels/adapters/twitch/send.py
Normal file
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_privmsg_line(target: str, text: str) -> str:
|
||||
return f"PRIVMSG {target} :{text}"
|
||||
|
||||
|
||||
def format_action_line(target: str, text: str) -> str:
|
||||
return f"PRIVMSG {target} :\x01ACTION {text}\x01"
|
||||
|
||||
|
||||
def format_pong(token: str) -> str:
|
||||
return f"PONG :{token}"
|
||||
|
||||
|
||||
def format_cap_req(capabilities: list[str]) -> str:
|
||||
return f"CAP REQ :{' '.join(capabilities)}"
|
||||
110
backend/package/yuxi/channels/adapters/twitch/session.py
Normal file
110
backend/package/yuxi/channels/adapters/twitch/session.py
Normal file
@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
TWITCH_ROLES = frozenset({"moderator", "owner", "vip", "subscriber", "all"})
|
||||
|
||||
|
||||
def normalize_twitch_channel(channel: str) -> str:
|
||||
return channel.lstrip("#@").strip().lower()
|
||||
|
||||
|
||||
def resolve_channel_name(channel_chat_id: str) -> str:
|
||||
return normalize_twitch_channel(channel_chat_id)
|
||||
|
||||
|
||||
def resolve_route(
|
||||
agent_id: str,
|
||||
channel_name: str,
|
||||
) -> str:
|
||||
channel_name = normalize_twitch_channel(channel_name)
|
||||
return f"agent:{agent_id}:twitch:channel:{channel_name}"
|
||||
|
||||
|
||||
def resolve_agent_id_for_channel(
|
||||
config: dict[str, Any],
|
||||
channel_name: str,
|
||||
default_agent_id: str | None = None,
|
||||
) -> str:
|
||||
channel_name = normalize_twitch_channel(channel_name)
|
||||
channels_config = config.get("channels_config", {})
|
||||
channel_config = channels_config.get(channel_name, {})
|
||||
return channel_config.get("agent_id") or default_agent_id or ""
|
||||
|
||||
|
||||
def check_require_mention(
|
||||
config: dict[str, Any],
|
||||
content: str,
|
||||
) -> bool:
|
||||
bot_name = config.get("bot_username", "").lower()
|
||||
require_mention = config.get("require_mention", True)
|
||||
if not require_mention:
|
||||
return True
|
||||
return bool(bot_name) and bot_name in content.lower()
|
||||
|
||||
|
||||
def check_allowed_roles(
|
||||
config: dict[str, Any],
|
||||
channel: str,
|
||||
badges: dict[str, str],
|
||||
) -> bool:
|
||||
allowed_roles: list[str] = config.get("allowedRoles", [])
|
||||
|
||||
if "all" in allowed_roles:
|
||||
return True
|
||||
|
||||
channels_config = config.get("channels_config", {})
|
||||
channel_name = normalize_twitch_channel(channel)
|
||||
per_channel_roles: list[str] = channels_config.get(channel_name, {}).get("allowedRoles", [])
|
||||
if "all" in per_channel_roles:
|
||||
return True
|
||||
|
||||
effective_roles = list(dict.fromkeys(allowed_roles + per_channel_roles))
|
||||
if not effective_roles:
|
||||
return True
|
||||
|
||||
for role in effective_roles:
|
||||
role_lower = role.lower()
|
||||
if role_lower not in TWITCH_ROLES:
|
||||
continue
|
||||
if role_lower == "all":
|
||||
return True
|
||||
if role_lower == "owner" and "broadcaster" in badges:
|
||||
return True
|
||||
if role_lower == "moderator" and "moderator" in badges:
|
||||
return True
|
||||
if role_lower == "vip" and "vip" in badges:
|
||||
return True
|
||||
if role_lower == "subscriber" and ("subscriber" in badges or "founder" in badges):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_group_policy(
|
||||
config: dict[str, Any],
|
||||
channel: str,
|
||||
user_id: str,
|
||||
content: str,
|
||||
) -> bool:
|
||||
channel_name = normalize_twitch_channel(channel)
|
||||
group_policy = config.get("group_policy", "open")
|
||||
bot_name = config.get("bot_username", "").lower()
|
||||
|
||||
match group_policy:
|
||||
case "open":
|
||||
return True
|
||||
case "disabled":
|
||||
return False
|
||||
case "mention_only":
|
||||
require_mention = config.get("require_mention", True)
|
||||
if not require_mention:
|
||||
return True
|
||||
return bool(bot_name) and bot_name in content.lower()
|
||||
case "allowlist":
|
||||
allowlist = config.get("group_allow_from", [])
|
||||
channels_config = config.get("channels_config", {})
|
||||
per_channel_allow = channels_config.get(channel_name, {}).get("allow_from", [])
|
||||
return bool(f"twitch:{user_id}" in allowlist or f"twitch:{user_id}" in per_channel_allow)
|
||||
case _:
|
||||
return False
|
||||
400
backend/package/yuxi/channels/adapters/twitch/setup.py
Normal file
400
backend/package/yuxi/channels/adapters/twitch/setup.py
Normal file
@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetupWizardStep:
|
||||
step_id: str
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
completed: bool = False
|
||||
config_key: str = ""
|
||||
config_value: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetupWizardState:
|
||||
steps: list[SetupWizardStep] = field(default_factory=list)
|
||||
current_step: int = 0
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return all(s.completed for s in self.steps if s.required)
|
||||
|
||||
@property
|
||||
def current(self) -> SetupWizardStep | None:
|
||||
if 0 <= self.current_step < len(self.steps):
|
||||
return self.steps[self.current_step]
|
||||
return None
|
||||
|
||||
def advance(self) -> None:
|
||||
if self.current_step < len(self.steps):
|
||||
self.steps[self.current_step].completed = True
|
||||
self.current_step += 1
|
||||
|
||||
def to_config(self) -> dict[str, Any]:
|
||||
config: dict[str, Any] = {}
|
||||
for step in self.steps:
|
||||
if step.config_key and step.config_value is not None:
|
||||
config[step.config_key] = step.config_value
|
||||
return config
|
||||
|
||||
|
||||
def create_setup_wizard_steps(config: dict[str, Any] | None = None) -> list[SetupWizardStep]:
|
||||
existing = config or {}
|
||||
return [
|
||||
SetupWizardStep(
|
||||
step_id="username",
|
||||
title="Bot Username",
|
||||
description="Enter your Twitch bot username (lowercase). "
|
||||
"This must match the username associated with your Twitch application.",
|
||||
required=True,
|
||||
completed=bool(existing.get("bot_username")),
|
||||
config_key="bot_username",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="token",
|
||||
title="Access Token",
|
||||
description="Enter your Twitch IRC access token (oauth:...) or leave blank to use "
|
||||
"TWITCH_ACCESS_TOKEN / OPENCLAW_TWITCH_ACCESS_TOKEN env var. "
|
||||
"Get it from https://twitchtokengenerator.com/ with scopes: "
|
||||
"chat:read, chat:edit, channel:read:subscriptions, moderator:read:followers",
|
||||
required=True,
|
||||
completed=bool(existing.get("access_token")),
|
||||
config_key="access_token",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="client_id",
|
||||
title="Client ID",
|
||||
description="Enter your Twitch application Client ID. "
|
||||
"Create a Twitch application at https://dev.twitch.tv/console/apps",
|
||||
required=True,
|
||||
completed=bool(existing.get("client_id")),
|
||||
config_key="client_id",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="client_secret",
|
||||
title="Client Secret (Optional)",
|
||||
description="Enter your Twitch application Client Secret for EventSub and token refresh. "
|
||||
"Required for EventSub events (follows, subs, raids, etc.) and automatic token rotation.",
|
||||
required=False,
|
||||
completed=bool(existing.get("client_secret")),
|
||||
config_key="client_secret",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="channels",
|
||||
title="Channels",
|
||||
description="Enter channels to join (comma-separated, e.g. channel1,channel2). "
|
||||
"Do NOT include the # prefix — it will be added automatically.",
|
||||
required=True,
|
||||
completed=bool(existing.get("channels")),
|
||||
config_key="channels",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="refresh_token",
|
||||
title="Refresh Token (Optional)",
|
||||
description="Enter a refresh token for automatic token rotation, or leave blank to skip. "
|
||||
"Required scopes for token refresh: channel:read:subscriptions, chat:read, chat:edit",
|
||||
required=False,
|
||||
completed=bool(existing.get("refresh_token")),
|
||||
config_key="refresh_token",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="dm_policy",
|
||||
title="DM Policy",
|
||||
description="DM/whisper policy: 'open' (allow all) or 'pairing' (require approval). Default: 'pairing'",
|
||||
required=False,
|
||||
completed=bool(existing.get("dm_policy")),
|
||||
config_key="dm_policy",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="group_access",
|
||||
title="Group Access Policy",
|
||||
description="Group access policy: open / allowlist / disabled / mention_only. Default: 'open'. "
|
||||
"'open' = respond to all messages; 'allowlist' = only respond to allowed users; "
|
||||
"'mention_only' = only respond when @mentioned",
|
||||
required=True,
|
||||
completed=bool(existing.get("group_policy")),
|
||||
config_key="group_policy",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="allowed_roles",
|
||||
title="Allowed Roles",
|
||||
description="Allowed roles for responses (comma-separated): moderator,owner,vip,subscriber,all. "
|
||||
"Default: all",
|
||||
required=False,
|
||||
completed=bool(existing.get("allowedRoles")),
|
||||
config_key="allowedRoles",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="silent",
|
||||
title="Silent Mode (ACTION)",
|
||||
description="Send messages as /me ACTION instead of normal PRIVMSG. "
|
||||
"Type 'yes' to enable, or leave blank for default (no).",
|
||||
required=False,
|
||||
completed="silent" in existing,
|
||||
config_key="silent",
|
||||
),
|
||||
SetupWizardStep(
|
||||
step_id="probe_timeout",
|
||||
title="Probe Timeout (ms)",
|
||||
description="Timeout for health probe API calls in milliseconds. Default: 10000 (10s). "
|
||||
"Increase if your Twitch API calls are slow.",
|
||||
required=False,
|
||||
completed=bool(existing.get("probe_timeout_ms")),
|
||||
config_key="probe_timeout_ms",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def setup_wizard_to_config(state: SetupWizardState) -> dict[str, Any]:
|
||||
return state.to_config()
|
||||
|
||||
|
||||
class TwitchSetupWizard:
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
self._config = config or {}
|
||||
self._state = SetupWizardState()
|
||||
self._account_id: str = ""
|
||||
self._disabled = False
|
||||
self._build_steps()
|
||||
|
||||
def _build_steps(self) -> None:
|
||||
self._state.steps = create_setup_wizard_steps(self._config)
|
||||
|
||||
def resolve_account_id(self, account_id: str | None = None) -> str:
|
||||
self._account_id = account_id or "default"
|
||||
logger.info(f"[TwitchSetup] Resolved account ID: {self._account_id}")
|
||||
return self._account_id
|
||||
|
||||
def prompt_username(self, username: str) -> bool:
|
||||
if not username or not username.strip():
|
||||
logger.warning("[TwitchSetup] Bot username cannot be empty")
|
||||
return False
|
||||
self._config["bot_username"] = username.strip().lower()
|
||||
self._mark_completed("username")
|
||||
logger.info(f"[TwitchSetup] Bot username: {self._config['bot_username']}")
|
||||
return True
|
||||
|
||||
def prompt_token(self, access_token: str) -> bool:
|
||||
token = access_token.strip()
|
||||
if not token:
|
||||
import os
|
||||
|
||||
env_token = os.environ.get("TWITCH_ACCESS_TOKEN", "") or os.environ.get("OPENCLAW_TWITCH_ACCESS_TOKEN", "")
|
||||
if env_token:
|
||||
self._config["access_token"] = env_token
|
||||
logger.info("[TwitchSetup] Using token from environment variable")
|
||||
else:
|
||||
logger.warning("[TwitchSetup] No access token provided and no env var found")
|
||||
return False
|
||||
else:
|
||||
from .token_utils import ensure_oauth_prefix
|
||||
|
||||
self._config["access_token"] = ensure_oauth_prefix(token)
|
||||
self._mark_completed("token")
|
||||
logger.info("[TwitchSetup] Access token configured")
|
||||
return True
|
||||
|
||||
def prompt_client_id(self, client_id: str) -> bool:
|
||||
if not client_id or not client_id.strip():
|
||||
logger.warning("[TwitchSetup] Client ID cannot be empty")
|
||||
return False
|
||||
self._config["client_id"] = client_id.strip()
|
||||
self._mark_completed("client_id")
|
||||
logger.info(f"[TwitchSetup] Client ID: {self._config['client_id']}")
|
||||
return True
|
||||
|
||||
def prompt_client_secret(self, client_secret: str = "") -> bool:
|
||||
if client_secret and client_secret.strip():
|
||||
self._config["client_secret"] = client_secret.strip()
|
||||
logger.info("[TwitchSetup] Client secret configured")
|
||||
else:
|
||||
logger.info("[TwitchSetup] Client secret skipped (EventSub and token refresh disabled)")
|
||||
self._mark_completed("client_secret")
|
||||
return True
|
||||
|
||||
def prompt_channels(self, channels_input: str | list[str]) -> bool:
|
||||
if isinstance(channels_input, str):
|
||||
channels = [c.strip().lower() for c in channels_input.split(",") if c.strip()]
|
||||
else:
|
||||
channels = channels_input
|
||||
|
||||
if not channels:
|
||||
logger.warning("[TwitchSetup] At least one channel is required")
|
||||
return False
|
||||
|
||||
self._config["channels"] = channels
|
||||
self._mark_completed("channels")
|
||||
logger.info(f"[TwitchSetup] Channels: {channels}")
|
||||
return True
|
||||
|
||||
def prompt_refresh_token(self, refresh_token: str = "") -> bool:
|
||||
if refresh_token and refresh_token.strip():
|
||||
self._config["refresh_token"] = refresh_token.strip()
|
||||
logger.info("[TwitchSetup] Refresh token configured")
|
||||
else:
|
||||
logger.info("[TwitchSetup] Refresh token skipped (manual rotation only)")
|
||||
self._mark_completed("refresh_token")
|
||||
return True
|
||||
|
||||
def prompt_dm_policy(self, policy: str = "pairing") -> bool:
|
||||
valid = {"open", "pairing"}
|
||||
policy = policy.strip().lower()
|
||||
if policy not in valid:
|
||||
logger.warning(f"[TwitchSetup] Invalid DM policy '{policy}', must be one of {valid}")
|
||||
return False
|
||||
self._config["dm_policy"] = policy
|
||||
self._mark_completed("dm_policy")
|
||||
logger.info(f"[TwitchSetup] DM policy: {policy}")
|
||||
return True
|
||||
|
||||
def prompt_group_access(self, policy: str = "open") -> bool:
|
||||
valid = {"open", "allowlist", "disabled", "mention_only"}
|
||||
policy = policy.strip().lower()
|
||||
if policy not in valid:
|
||||
logger.warning(f"[TwitchSetup] Invalid group policy '{policy}', must be one of {valid}")
|
||||
return False
|
||||
self._config["group_policy"] = policy
|
||||
self._mark_completed("group_access")
|
||||
logger.info(f"[TwitchSetup] Group access policy: {policy}")
|
||||
return True
|
||||
|
||||
def prompt_allowed_roles(self, roles_input: str | list[str] = "all") -> bool:
|
||||
if isinstance(roles_input, str):
|
||||
roles = [r.strip().lower() for r in roles_input.split(",") if r.strip()]
|
||||
else:
|
||||
roles = roles_input
|
||||
|
||||
valid_roles = {"moderator", "owner", "vip", "subscriber", "all"}
|
||||
filtered = [r for r in roles if r in valid_roles]
|
||||
if not filtered:
|
||||
logger.warning("[TwitchSetup] No valid roles specified, defaulting to 'all'")
|
||||
filtered = ["all"]
|
||||
|
||||
self._config["allowedRoles"] = filtered
|
||||
self._mark_completed("allowed_roles")
|
||||
logger.info(f"[TwitchSetup] Allowed roles: {filtered}")
|
||||
return True
|
||||
|
||||
def prompt_silent(self, silent_input: str = "") -> bool:
|
||||
silent = silent_input.strip().lower() in ("yes", "true", "1", "y", "on")
|
||||
self._config["silent"] = silent
|
||||
self._mark_completed("silent")
|
||||
logger.info(f"[TwitchSetup] Silent mode (ACTION): {silent}")
|
||||
return True
|
||||
|
||||
def prompt_probe_timeout(self, timeout_input: str | int = 10000) -> bool:
|
||||
if isinstance(timeout_input, str):
|
||||
timeout_input = timeout_input.strip()
|
||||
if not timeout_input:
|
||||
self._config["probe_timeout_ms"] = 10000
|
||||
self._mark_completed("probe_timeout")
|
||||
return True
|
||||
try:
|
||||
timeout = int(timeout_input)
|
||||
except ValueError:
|
||||
logger.warning(f"[TwitchSetup] Invalid probe timeout: {timeout_input}")
|
||||
return False
|
||||
else:
|
||||
timeout = timeout_input
|
||||
|
||||
if timeout < 1000:
|
||||
logger.warning("[TwitchSetup] Probe timeout too small, minimum 1000ms")
|
||||
return False
|
||||
self._config["probe_timeout_ms"] = timeout
|
||||
self._mark_completed("probe_timeout")
|
||||
logger.info(f"[TwitchSetup] Probe timeout: {timeout}ms")
|
||||
return True
|
||||
|
||||
def finalize(self) -> dict[str, Any]:
|
||||
if self._disabled:
|
||||
return {"enabled": False}
|
||||
validation = self._validate_config()
|
||||
return {
|
||||
"enabled": True,
|
||||
"account_id": self._account_id or "default",
|
||||
"config": dict(self._config),
|
||||
"validation": validation,
|
||||
"finalized_at": time.time(),
|
||||
}
|
||||
|
||||
def finalize_and_write(self, storage_path: str | None = None) -> dict[str, Any]:
|
||||
result = self.finalize()
|
||||
if storage_path:
|
||||
try:
|
||||
config_path = Path(storage_path) / "twitch_config.json"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(
|
||||
json.dumps(result["config"], indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result["storage"] = {"path": str(config_path), "success": True}
|
||||
logger.info(f"[TwitchSetup] Config written to {config_path}")
|
||||
except Exception as e:
|
||||
result["storage"] = {"path": str(storage_path), "success": False, "error": str(e)}
|
||||
logger.error(f"[TwitchSetup] Failed to write config: {e}")
|
||||
else:
|
||||
result["storage"] = {"mode": "memory", "success": True}
|
||||
return result
|
||||
|
||||
def _validate_config(self) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not self._config.get("bot_username"):
|
||||
errors.append("bot_username is required")
|
||||
if not self._config.get("access_token"):
|
||||
errors.append("access_token is required")
|
||||
if not self._config.get("client_id"):
|
||||
errors.append("client_id is required")
|
||||
if not self._config.get("channels"):
|
||||
errors.append("at least one channel is required")
|
||||
|
||||
group_policy = self._config.get("group_policy", "open")
|
||||
if group_policy not in {"open", "allowlist", "disabled", "mention_only"}:
|
||||
errors.append(f"invalid group_policy: {group_policy}")
|
||||
|
||||
dm_policy = self._config.get("dm_policy", "pairing")
|
||||
if dm_policy not in {"open", "pairing"}:
|
||||
warnings.append(f"unknown dm_policy '{dm_policy}', defaulting to 'pairing'")
|
||||
|
||||
if not self._config.get("client_secret"):
|
||||
warnings.append("no client_secret — EventSub and token refresh disabled")
|
||||
|
||||
if not self._config.get("refresh_token"):
|
||||
warnings.append("no refresh_token — manual token rotation required")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
def disable(self) -> dict[str, Any]:
|
||||
self._disabled = True
|
||||
logger.info("[TwitchSetup] Account disabled")
|
||||
return {"enabled": False}
|
||||
|
||||
def _mark_completed(self, step_id: str) -> None:
|
||||
for step in self._state.steps:
|
||||
if step.step_id == step_id:
|
||||
step.completed = True
|
||||
step.config_value = self._config.get(step.config_key)
|
||||
break
|
||||
|
||||
@property
|
||||
def state(self) -> SetupWizardState:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
return dict(self._config)
|
||||
12
backend/package/yuxi/channels/adapters/twitch/token_utils.py
Normal file
12
backend/package/yuxi/channels/adapters/twitch/token_utils.py
Normal file
@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def ensure_oauth_prefix(token: str) -> str:
|
||||
token = token.strip()
|
||||
if token.startswith("oauth:"):
|
||||
return token
|
||||
return f"oauth:{token}"
|
||||
|
||||
|
||||
def normalize_token(token: str) -> str:
|
||||
return ensure_oauth_prefix(token)
|
||||
Loading…
Reference in New Issue
Block a user