ForcePilot/backend/package/yuxi/channels/adapters/twitch/eventsub_client.py
Kris 18d1ea2aac refactor(twitch): 重构Twitch适配器,新增Helix API支持与功能优化
本次提交对Twitch适配器进行了全面升级与优化:
1.  修复UTF8截断逻辑,避免越界访问
2.  重构群聊策略配置,标准化mention相关规则
3.  新增消息缓存管理器,支持通过消息ID查询已发送消息
4.  更新配置schema,新增prefer_helix_send开关和deprecated策略自动转换
5.  新增CLEARMSG和ROOMSTATE IRC消息解析,补充事件订阅支持
6.  优化令牌刷新逻辑,增加重试机制与退避策略
7.  新增Helix API聊天消息发送、删除和公告功能
8.  扩展事件订阅类型,新增直播状态、频道更新等系统事件
9.  新增reply、delete_message、announcement等动作支持,完善操作能力
10. 重构流式发送逻辑,新增进度指示器和配置项
11. 优化重连策略,增加指数退避与计数重置
2026-05-13 16:16:02 +08:00

669 lines
26 KiB
Python

from __future__ import annotations
import asyncio
from datetime import UTC, datetime
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"},
{"type": "stream.online", "version": "1"},
{"type": "stream.offline", "version": "1"},
{"type": "channel.update", "version": "2"},
{"type": "channel.chat.clear", "version": "1"},
{"type": "channel.chat.message_delete", "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)
if subscription_type == "stream.online":
return self._normalize_stream_online(event, broadcaster_id)
if subscription_type == "stream.offline":
return self._normalize_stream_offline(event, broadcaster_id)
if subscription_type == "channel.update":
return self._normalize_channel_update(event, broadcaster_id)
if subscription_type == "channel.chat.clear":
return self._normalize_chat_clear(event, broadcaster_id)
if subscription_type == "channel.chat.message_delete":
return self._normalize_chat_message_delete(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),
)
@staticmethod
def _normalize_stream_online(event: dict, broadcaster_id: str) -> ChannelMessage:
broadcaster_name = event.get("broadcaster_user_name", "")
stream_type = event.get("type", "live")
started_at = event.get("started_at", "")
return ChannelMessage(
identity=ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id=broadcaster_id,
channel_chat_id=f"#broadcaster_{broadcaster_id}",
channel_message_id=f"eventsub:stream_online:{event.get('id')}",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=f"🔴 {broadcaster_name} went live!",
metadata={
"event": "stream_online",
"type": stream_type,
"started_at": started_at,
},
timestamp=datetime.now(UTC),
)
@staticmethod
def _normalize_stream_offline(event: dict, broadcaster_id: str) -> ChannelMessage:
broadcaster_name = event.get("broadcaster_user_name", "")
return ChannelMessage(
identity=ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id=broadcaster_id,
channel_chat_id=f"#broadcaster_{broadcaster_id}",
channel_message_id=f"eventsub:stream_offline:{event.get('id')}",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=f"{broadcaster_name} went offline",
metadata={
"event": "stream_offline",
},
timestamp=datetime.now(UTC),
)
@staticmethod
def _normalize_channel_update(event: dict, broadcaster_id: str) -> ChannelMessage:
broadcaster_name = event.get("broadcaster_user_name", "")
title = event.get("title", "")
category_name = event.get("category_name", "")
language = event.get("language", "")
return ChannelMessage(
identity=ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id=broadcaster_id,
channel_chat_id=f"#broadcaster_{broadcaster_id}",
channel_message_id=f"eventsub:channel_update:{event.get('id')}",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=f"📺 {broadcaster_name} updated channel: {title}",
metadata={
"event": "channel_update",
"title": title,
"category": category_name,
"language": language,
},
timestamp=datetime.now(UTC),
)
@staticmethod
def _normalize_chat_clear(event: dict, broadcaster_id: str) -> ChannelMessage:
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:chat_clear:{event.get('id')}",
),
event_type=EventType.SYSTEM_EVENT,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=f"🧹 Chat cleared in #{event.get('broadcaster_user_name', '')}",
metadata={
"event": "chat_clear",
},
timestamp=datetime.now(UTC),
)
@staticmethod
def _normalize_chat_message_delete(event: dict, broadcaster_id: str) -> ChannelMessage:
target_user_name = event.get("target_user_name", "")
target_user_id = event.get("target_user_id", "")
message_id = event.get("message_id", "")
return ChannelMessage(
identity=ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id=target_user_id or "twitch_system",
channel_chat_id=f"#broadcaster_{broadcaster_id}",
channel_message_id=f"eventsub:msg_delete:{event.get('id')}",
),
event_type=EventType.MESSAGE_DELETED,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content="",
metadata={
"event": "message_delete",
"target_user_name": target_user_name,
"target_message_id": message_id,
},
timestamp=datetime.now(UTC),
)