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

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),
)