新增Twitch IRC协议相关的全套实现,包括: 1. 基础工具类:令牌处理、消息格式化、速率限制、消息去重 2. 核心适配器组件:IRC解析器、消息归一化、外发消息处理 3. API客户端:Helix API封装、认证提供者 4. 配置与部署:配置校验、设置向导 5. 辅助功能:配对管理、健康检查、目标解析等
530 lines
20 KiB
Python
530 lines
20 KiB
Python
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),
|
|
)
|