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