import asyncio import logging import uuid from yuxi.channel.extensions.twitch.config import TwitchAccountConfig, normalize_twitch_channel from yuxi.channel.extensions.twitch.token import denormalize_token, resolve_twitch_token from yuxi.channel.extensions.twitch.types import TwitchChatMessage, TwitchSendResult logger = logging.getLogger(__name__) class TwitchClientManager: def __init__(self, logger_=None): self._logger = logger_ or logger self._clients: dict[str, object] = {} self._message_handlers: dict[str, callable] = {} self._http_clients: dict[str, object] = {} async def get_client(self, account: TwitchAccountConfig, account_id: str) -> object: key = f"{account.username}:{account.channel}" if key in self._clients: return self._clients[key] token, _source = resolve_twitch_token(account, account_id) bare_token = denormalize_token(token) from twitchio import Client client = Client( token=bare_token, initial_channels=[account.channel], ) self._setup_client_handlers(client, account, account_id) self._clients[key] = client asyncio.create_task(client.start()) return client def _setup_client_handlers(self, client, account: TwitchAccountConfig, account_id: str): manager = self @client.event() async def event_message(message): if message.author is None: return emotes = _parse_emotes_from_tags(getattr(message, "tags", {})) badges = _parse_badges_from_tags(getattr(message, "tags", {})) handler = manager._message_handlers.get(f"{account.username}:{account.channel}") if handler: await handler( TwitchChatMessage( username=message.author.name, display_name=message.author.display_name, user_id=message.author.id, message=message.content, channel=normalize_twitch_channel(message.channel.name), id=message.id, is_mod=message.author.is_mod, is_owner=message.author.is_broadcaster, is_vip=message.author.is_vip, is_sub=message.author.is_subscriber, chat_type="group", emotes=emotes, badges=badges, ) ) @client.event() async def event_clearchat(message): tags = getattr(message, "tags", {}) target_user = getattr(message, "target_user", None) duration = tags.get("ban-duration") handler = manager._message_handlers.get(f"{account.username}:{account.channel}") if handler: await handler( TwitchChatMessage( username="", user_id=None, display_name=None, message="", channel=normalize_twitch_channel(message.channel.name), id=None, chat_type="group", event_type="clearchat", target_user=target_user.name if target_user else None, duration=int(duration) if duration else None, ) ) def on_message(self, account: TwitchAccountConfig, handler): key = f"{account.username}:{account.channel}" self._message_handlers[key] = handler def get_http_client(self, account: TwitchAccountConfig): if not account.client_id or not account.client_secret: self._logger.debug("HTTPClient not available: missing client_id/client_secret") return None key = f"http:{account.username}:{account.channel}" if key not in self._http_clients: from twitchio import HTTPClient self._http_clients[key] = HTTPClient( client_id=account.client_id, client_secret=account.client_secret, ) return self._http_clients[key] async def send_message( self, account: TwitchAccountConfig, channel: str, text: str, reply_to_id: str | None = None ) -> TwitchSendResult: if reply_to_id: return await self.send_reply(account, channel, text, reply_to_id) key = f"{account.username}:{account.channel}" client = self._clients.get(key) if not client: return TwitchSendResult(ok=False, error="Client not connected") normalized_channel = normalize_twitch_channel(channel) target = client.get_channel(normalized_channel) if target is None: return TwitchSendResult(ok=False, error=f"Channel {normalized_channel} not found") message_id = _generate_message_id() await target.send(text) return TwitchSendResult(ok=True, message_id=message_id) async def send_reply( self, account: TwitchAccountConfig, channel: str, text: str, reply_to_id: str, ) -> TwitchSendResult: http = self.get_http_client(account) if http is None: return TwitchSendResult(ok=False, error="HTTPClient not available (need client_id + client_secret)") import asyncio try: users = await asyncio.wait_for(http.get_users(logins=[channel]), timeout=10) if not users: return TwitchSendResult(ok=False, error=f"User '{channel}' not found") broadcaster_id = users[0].id bot_users = await asyncio.wait_for(http.get_users(logins=[account.username]), timeout=10) if not bot_users: return TwitchSendResult(ok=False, error=f"Bot user '{account.username}' not found") moderator_id = bot_users[0].id token, _ = resolve_twitch_token(account) await http.post_chat_messages( token=denormalize_token(token), broadcaster_id=broadcaster_id, moderator_id=moderator_id, message=text, reply_parent_message_id=reply_to_id, ) return TwitchSendResult(ok=True, message_id=_generate_message_id()) except Exception as e: self._logger.warning("send_reply failed: %s", e) return TwitchSendResult(ok=False, error=str(e)) async def disconnect(self, account: TwitchAccountConfig): key = f"{account.username}:{account.channel}" client = self._clients.pop(key, None) if client: await client.close() self._message_handlers.pop(key, None) http_key = f"http:{account.username}:{account.channel}" http = self._http_clients.pop(http_key, None) if http: await http.close() async def disconnect_all(self): for client in list(self._clients.values()): await client.close() self._clients.clear() self._message_handlers.clear() for http in list(self._http_clients.values()): await http.close() self._http_clients.clear() class ClientManagerRegistry: def __init__(self): self._registry: dict[str, TwitchClientManager] = {} self._eventsub_registry: dict[str, object] = {} def get_or_create(self, account_id: str, logger_=None) -> TwitchClientManager: if account_id not in self._registry: self._registry[account_id] = TwitchClientManager(logger_) return self._registry[account_id] def get(self, account_id: str) -> TwitchClientManager | None: return self._registry.get(account_id) def get_eventsub(self, account_id: str) -> object | None: return self._eventsub_registry.get(account_id) def set_eventsub(self, account_id: str, manager: object): self._eventsub_registry[account_id] = manager async def remove(self, account_id: str): manager = self._registry.pop(account_id, None) if manager: await manager.disconnect_all() eventsub = self._eventsub_registry.pop(account_id, None) if eventsub: await eventsub.stop() async def remove_all(self): for manager in list(self._registry.values()): await manager.disconnect_all() self._registry.clear() for eventsub in list(self._eventsub_registry.values()): await eventsub.stop() self._eventsub_registry.clear() client_manager_registry = ClientManagerRegistry() def _generate_message_id() -> str: return uuid.uuid4().hex def _parse_emotes_from_tags(tags: dict) -> list[dict]: emotes_raw = tags.get("emotes", "") if not emotes_raw: return [] result = [] for emote_entry in emotes_raw.split("/"): if ":" not in emote_entry: continue emote_id, positions = emote_entry.split(":", 1) result.append({"id": emote_id, "positions": positions}) return result def _parse_badges_from_tags(tags: dict) -> list[str]: badges_raw = tags.get("badges", "") if not badges_raw: return [] return [b.split("/")[0] for b in badges_raw.split(",") if b]