import asyncio import logging import time from yuxi.channel.message.models import ( GroupContext, MessageType, PeerInfo, PeerKind, UnifiedMessage, ) from yuxi.channel.extensions.twitch.access_control import check_twitch_access_control from yuxi.channel.extensions.twitch.client import client_manager_registry from yuxi.channel.extensions.twitch.config import TwitchAccountConfig, TwitchConfig from yuxi.channel.extensions.twitch.token import refresh_twitch_token, validate_twitch_token from yuxi.channel.extensions.twitch.types import TwitchChatMessage, TwitchEventSubNotification logger = logging.getLogger(__name__) async def monitor_twitch_provider( account: TwitchAccountConfig, account_id: str, config: TwitchConfig, runtime, abort_signal, ): manager = client_manager_registry.get_or_create(account_id, runtime.logger) stopped = False async def on_abort(): nonlocal stopped stopped = True abort_signal.add_done_callback(lambda _: asyncio.create_task(on_abort())) bot_username = account.username.lower() async def message_handler(message: TwitchChatMessage): if stopped: return if message.username.lower() == bot_username: return access_result = check_twitch_access_control( message=message, account=account, bot_username=bot_username, ) if not access_result.allowed: return runtime.logger.info( "Twitch inbound: channel=%s user=%s msg_id=%s", message.channel, message.username, message.id, ) asyncio.create_task( process_twitch_message( message=message, account=account, account_id=account_id, config=config, runtime=runtime, ) ) manager.on_message(account, message_handler) validation = await validate_twitch_token(account) if not validation.get("valid"): logger.warning( "Twitch token invalid for account=%s: %s", account_id, validation.get("message") or validation.get("error") or validation.get("reason", "unknown"), ) if account.refresh_token and account.client_id and account.client_secret: refresh_result = await refresh_twitch_token(account) if refresh_result.get("ok"): logger.info("Twitch token refreshed for account=%s", account_id) account.access_token = refresh_result["access_token"] account.refresh_token = refresh_result.get("refresh_token", account.refresh_token) account.expires_in = refresh_result.get("expires_in") account.obtainment_timestamp = time.time() else: logger.error( "Twitch token refresh failed for account=%s: %s", account_id, refresh_result.get("error"), ) raise ValueError(f"Twitch token invalid and refresh failed: {refresh_result.get('error')}") else: raise ValueError("Twitch token invalid and no refresh_token configured") _client = await manager.get_client(account, account_id) if config.eventsub_enabled and account.client_id and account.client_secret: from yuxi.channel.extensions.twitch.eventsub import TwitchEventSubManager eventsub = TwitchEventSubManager(runtime.logger) async def eventsub_handler(notification: TwitchEventSubNotification): if stopped: return runtime.logger.info( "Twitch EventSub: type=%s channel=%s", notification.subscription_type, notification.channel, ) asyncio.create_task( process_eventsub_notification( notification=notification, account=account, account_id=account_id, config=config, runtime=runtime, ) ) eventsub.on_notification(eventsub_handler) client_manager_registry.set_eventsub(account_id, eventsub) asyncio.create_task(eventsub.start(account, account_id)) while not stopped: await asyncio.sleep(1) async def process_twitch_message( message: TwitchChatMessage, account: TwitchAccountConfig, account_id: str, config: TwitchConfig, runtime, ): sender_id = message.user_id or message.username metadata = { "twitch_channel": message.channel, "twitch_is_mod": message.is_mod, "twitch_is_vip": message.is_vip, "twitch_is_owner": message.is_owner, "twitch_is_sub": message.is_sub, } if message.emotes: metadata["twitch_emotes"] = message.emotes if message.badges: metadata["twitch_badges"] = message.badges if message.event_type: metadata["twitch_event_type"] = message.event_type if message.target_user: metadata["twitch_target_user"] = message.target_user if message.duration is not None: metadata["twitch_duration"] = message.duration unified = UnifiedMessage( msg_id=message.id or f"twitch:{int(time.time() * 1000)}:{sender_id}", channel_type="twitch", account_id=account_id, content=message.message, sender=PeerInfo( kind=PeerKind.USER, id=sender_id, display_name=message.display_name or message.username, username=message.username, ), group=GroupContext( id=message.channel, name=message.channel, route_peer_kind="group", route_peer_id=message.channel, ), message_type=MessageType.TEXT, timestamp=time.time(), metadata=metadata, ) if runtime.queue is not None: await runtime.queue.put(unified) async def process_eventsub_notification( notification: TwitchEventSubNotification, account: TwitchAccountConfig, account_id: str, config: TwitchConfig, runtime, ): event = notification.event content = _format_eventsub_content(notification.subscription_type, event) sender_name = event.get("user_name") or event.get("broadcaster_user_name") or "twitch" sender_id = event.get("user_id") or event.get("broadcaster_user_id") or "system" channel = notification.channel or account.channel unified = UnifiedMessage( msg_id=notification.id, channel_type="twitch", account_id=account_id, content=content, sender=PeerInfo( kind=PeerKind.USER, id=sender_id, display_name=sender_name, username=sender_name, ), group=GroupContext( id=channel, name=channel, route_peer_kind="group", route_peer_id=channel, ), message_type=MessageType.TEXT, timestamp=time.time(), metadata={ "twitch_channel": channel, "twitch_event_type": notification.subscription_type, "twitch_event_raw": event, }, ) if runtime.queue is not None: await runtime.queue.put(unified) def _format_eventsub_content(sub_type: str, event: dict) -> str: formatters = { "channel.follow": lambda e: f"[follow] {e.get('user_name', 'Someone')} followed the channel!", "channel.subscribe": lambda e: f"[subscribe] {e.get('user_name', 'Someone')} subscribed!", "channel.subscription.gift": lambda e: ( f"[gift] {e.get('user_name', 'Someone')} gifted {e.get('total', 1)} subscriptions!" ), "channel.subscription.message": lambda e: ( f"[resub] {e.get('user_name', 'Someone')} resubscribed for " f"{e.get('cumulative_months', '?')} months! " f"Message: {e.get('message', {}).get('text', '')}" ), "channel.cheer": lambda e: f"[cheer] {e.get('user_name', 'Anonymous')} cheered {e.get('bits', 0)} bits!", "stream.online": lambda e: ( f"[stream.online] Stream started! Title: {e.get('title', 'N/A')}, Category: {e.get('category_name', 'N/A')}" ), "stream.offline": lambda e: "[stream.offline] Stream ended.", "channel.raid": lambda e: ( f"[raid] {e.get('from_broadcaster_user_name', 'Someone')} raided with {e.get('viewers', 0)} viewers!" ), "channel.update": lambda e: ( f"[update] Channel updated. Title: {e.get('title', 'N/A')}, Category: {e.get('category_name', 'N/A')}" ), "channel.ban": lambda e: f"[ban] {e.get('user_name', 'Someone')} was banned. Reason: {e.get('reason', 'N/A')}", "channel.unban": lambda e: f"[unban] {e.get('user_name', 'Someone')} was unbanned.", } formatter = formatters.get(sub_type) if formatter: return formatter(event) return f"[eventsub:{sub_type}] {event}"