import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/badges.dart'; import 'package:frosty/models/category.dart'; import 'package:frosty/models/channel.dart'; import 'package:frosty/models/emotes.dart'; import 'package:frosty/models/followed_channel.dart'; import 'package:frosty/models/shared_chat_session.dart'; import 'package:frosty/models/stream.dart'; import 'package:frosty/models/user.dart'; /// The Twitch service for making API calls. class TwitchApi extends BaseApiClient { static const String _helixBaseUrl = 'https://api.twitch.tv/helix'; static const String _oauthBaseUrl = 'https://id.twitch.tv/oauth2'; static const String _recentMessagesUrl = 'https://recent-messages.robotty.de/api/v2'; TwitchApi(Dio dio) : super(dio, _helixBaseUrl); /// Returns a list of all Twitch global emotes. Future> getEmotesGlobal() async { final data = await get('/chat/emotes/global'); final decoded = data['data'] as JsonList; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes .map((emote) => Emote.fromTwitch(emote, EmoteType.twitchGlobal)) .toList(); } /// Returns a list of a channel's Twitch emotes given their [id]. Future> getEmotesChannel({required String id}) async { final data = await get( '/chat/emotes', queryParameters: {'broadcaster_id': id}, ); final decoded = data['data'] as JsonList; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes.map((emote) { switch (emote.emoteType) { case 'bitstier': return Emote.fromTwitch(emote, EmoteType.twitchBits); case 'follower': return Emote.fromTwitch(emote, EmoteType.twitchFollower); case 'subscriptions': return Emote.fromTwitch(emote, EmoteType.twitchChannel); default: return Emote.fromTwitch(emote, EmoteType.twitchChannel); } }).toList(); } /// Returns a list of Twitch emotes under the provided [setId]. Future> getEmotesSets({required String setId}) async { final data = await get( '/chat/emotes/set', queryParameters: {'emote_set_id': setId}, ); final decoded = data['data'] as JsonList; final emotes = decoded.map((emote) => EmoteTwitch.fromJson(emote)).toList(); return emotes.map((emote) { switch (emote.emoteType) { case 'globals': case 'smilies': return Emote.fromTwitch(emote, EmoteType.twitchGlobal); case 'subscriptions': return Emote.fromTwitch(emote, EmoteType.twitchSub); default: return Emote.fromTwitch(emote, EmoteType.twitchUnlocked); } }).toList(); } /// Returns a map of global Twitch badges to their [Emote] object. Future> getBadgesGlobal() async { final data = await get('/chat/badges/global'); final result = {}; final decoded = data['data'] as JsonList; for (final badge in decoded) { final id = badge['set_id'] as String; final versions = badge['versions'] as JsonList; for (final version in versions) { final badgeInfo = BadgeInfoTwitch.fromJson(version); result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } } return result; } /// Returns a map of a channel's Twitch badges to their [Emote] object. Future> getBadgesChannel({required String id}) async { final data = await get( '/chat/badges', queryParameters: {'broadcaster_id': id}, ); final result = {}; final decoded = data['data'] as JsonList; for (final badge in decoded) { final id = badge['set_id'] as String; final versions = badge['versions'] as JsonList; for (final version in versions) { final badgeInfo = BadgeInfoTwitch.fromJson(version); result['$id/${badgeInfo.id}'] = ChatBadge.fromTwitch(badgeInfo); } } return result; } /// Returns the user's info given their token. Future getUserInfo() async { final data = await get('/users'); final userData = data['data'] as JsonList; return UserTwitch.fromJson(userData.first); } /// Returns a token for an anonymous user. Future getDefaultToken() async { // Use custom base URL for OAuth final data = await post( '$_oauthBaseUrl/token', queryParameters: { 'client_id': clientId, 'client_secret': secret, 'grant_type': 'client_credentials', }, ); return data['access_token'] as String; } /// Validates [token] with Twitch. /// /// Returns token info (scopes + optional `user_id`) when valid, or `null` /// when invalid/expired. App-access tokens may omit `user_id` and return an /// empty scope list. Future validateToken({required String token}) async { try { final data = await get( '$_oauthBaseUrl/validate', headers: {'Authorization': 'Bearer $token'}, ); final scopes = data['scopes']; final scopeList = scopes is List ? scopes.map((scope) => scope.toString()).toList() : const []; final userId = data['user_id']?.toString(); return TwitchTokenInfo( scopes: scopeList, userId: userId == null || userId.isEmpty ? null : userId, ); } on UnauthorizedException { // 401 -> token is invalid/expired (propagated from interceptor for validate requests) return null; } on ApiException catch (e) { // Network/timeout/server errors -> treat as indeterminate, not invalid debugPrint('Token validation indeterminate: $e'); rethrow; } } /// Returns a [StreamsTwitch] object that contains the top 20 streams and a cursor for further requests. Future getTopStreams({String? cursor}) async { final data = await get( '/streams', queryParameters: cursor != null ? {'after': cursor} : null, ); return StreamsTwitch.fromJson(data); } /// Returns a [StreamsTwitch] object that contains the given user ID's top 20 followed streams and a cursor for further requests. Future getFollowedStreams({ required String id, String? cursor, }) async { final queryParams = {'user_id': id}; if (cursor != null) queryParams['after'] = cursor; final data = await get( '/streams/followed', queryParameters: queryParams, ); return StreamsTwitch.fromJson(data); } /// Returns a [FollowedChannels] object containing all followed channels (including offline ones) for the given user ID. Future getFollowedChannels({ required String userId, String? cursor, }) async { final queryParams = {'user_id': userId, 'first': '20'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( '/channels/followed', queryParameters: queryParams, ); return FollowedChannels.fromJson(data); } /// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID. Future getStreamsUnderCategory({ required String gameId, String? cursor, }) async { final queryParams = {'game_id': gameId}; if (cursor != null) queryParams['after'] = cursor; final data = await get('/streams', queryParameters: queryParams); return StreamsTwitch.fromJson(data); } /// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin]. Future getStream({required String userLogin}) async { final data = await get( '/streams', queryParameters: {'user_login': userLogin}, ); final streamData = data['data'] as JsonList; if (streamData.isNotEmpty) { return StreamTwitch.fromJson(streamData.first); } else { throw ApiException('$userLogin is offline', 404); } } Future getStreamsByIds({required List userIds}) async { // Create query string manually for multiple user_id parameters final userIdParams = userIds.map((id) => 'user_id=$id').join('&'); final url = '/streams?$userIdParams&first=100'; final data = await get(url); return StreamsTwitch.fromJson(data); } /// Returns a [UserTwitch] object containing the user info associated with the given [userLogin]. Future getUser({String? userLogin, String? id}) async { final queryParams = {}; if (id != null) { queryParams['id'] = id; } else if (userLogin != null) { queryParams['login'] = userLogin; } final data = await get('/users', queryParameters: queryParams); final userData = data['data'] as JsonList; if (userData.isNotEmpty) { return UserTwitch.fromJson(userData.first); } else { throw NotFoundException('User does not exist'); } } /// Returns a [Channel] object containing a channels's info associated with the given [userId]. Future getChannel({required String userId}) async { final data = await get( '/channels', queryParameters: {'broadcaster_id': userId}, ); final channelData = data['data'] as JsonList; if (channelData.isNotEmpty) { return Channel.fromJson(channelData.first); } else { throw ApiException('Channel does not exist', 404); } } /// Returns a list of [ChannelQuery] objects closest matching the given [query]. Future> searchChannels({required String query}) async { final data = await get( '/search/channels', queryParameters: {'first': '8', 'query': query}, ); final channelData = data['data'] as JsonList; return channelData.map((e) => ChannelQuery.fromJson(e)).toList(); } /// Returns a [CategoriesTwitch] object containing the next top 20 categories/games and a cursor for further requests. Future getTopCategories({String? cursor}) async { final data = await get( '/games/top', queryParameters: cursor != null ? {'after': cursor} : null, ); return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] object containing the category info corresponding to the provided [gameId]. Future getCategory({required String gameId}) async { final data = await get('/games', queryParameters: {'id': gameId}); return CategoriesTwitch.fromJson(data); } /// Returns a [CategoriesTwitch] containing up to 20 categories/games closest matching the [query] and a cursor for further requests. Future searchCategories({ required String query, String? cursor, }) async { final queryParams = {'first': '8', 'query': query}; if (cursor != null) queryParams['after'] = cursor; final data = await get( '/search/categories', queryParameters: queryParams, ); return CategoriesTwitch.fromJson(data); } /// Returns the sub count associated with the given [userId]. Future getSubscriberCount({required String userId}) async { final data = await get( '/subscriptions', queryParameters: {'broadcaster_id': userId}, ); return data['total'] as int; } /// Returns a user's list of blocked users given their id. Future> getUserBlockedList({ required String id, String? cursor, }) async { final queryParams = {'first': '100', 'broadcaster_id': id}; if (cursor != null) queryParams['after'] = cursor; final data = await get( '/users/blocks', queryParameters: queryParams, ); final paginationCursor = data['pagination']['cursor']; final blockedList = data['data'] as JsonList; if (blockedList.isNotEmpty) { final result = blockedList .map((e) => UserBlockedTwitch.fromJson(e)) .toList(); if (paginationCursor != null) { // Wait a bit (150 milliseconds) before recursively calling. // This will prevent going over the rate limit to due a massive blocked users list. // // With the Twitch API, we can make up to 800 requests per minute. // Waiting 150 milliseconds between requests will cap the rate here at 400 requests per minute. await Future.delayed(const Duration(milliseconds: 150)); result.addAll( await getUserBlockedList(id: id, cursor: paginationCursor), ); } return result; } else { debugPrint('User does not have anyone blocked'); return []; } } // Blocks the user with the given ID and returns true on success or false on failure. Future blockUser({required String userId}) async { try { await put( '/users/blocks', queryParameters: {'target_user_id': userId}, ); return true; // If no exception, operation succeeded } on ApiException { return false; } } // Unblocks the user with the given ID and returns true on success or false on failure. Future unblockUser({required String userId}) async { try { await delete( '/users/blocks', queryParameters: {'target_user_id': userId}, ); return true; // If no exception, operation succeeded } on ApiException { return false; } } Future getSharedChatSession({ required String broadcasterId, }) async { final data = await get( '/shared_chat/session', queryParameters: {'broadcaster_id': broadcasterId}, ); final sessionData = data['data'] as JsonList; if (sessionData.isEmpty) { return null; } return SharedChatSession.fromJson(sessionData.first); } /// Gets the color used for the user's name in chat. /// [userId] - The ID of the user whose chat color to get /// Returns the color as a hex string or empty string if no color is set. Future getUserChatColor({required String userId}) async { try { final data = await get( '/chat/color', queryParameters: {'user_id': userId}, ); final users = data['data'] as JsonList; if (users.isNotEmpty) { final user = users.first as JsonMap; return user['color'] as String? ?? ''; } return ''; } on ApiException { return ''; } } /// Updates the color used for the user's name in chat. /// [userId] - The ID of the user whose chat color to update /// [color] - The color to use. Can be a named color (blue, blue_violet, etc.) or hex code for Turbo/Prime users /// Returns true on success or false on failure. Future updateUserChatColor({ required String userId, required String color, }) async { try { await put( '/chat/color', queryParameters: {'user_id': userId, 'color': color}, ); return true; // If no exception, operation succeeded } on ApiException catch (e) { // Log the specific error for debugging debugPrint('Failed to update chat color: $e'); return false; } } // Gets recent messages from a third-party service. Future getRecentMessages({required String userLogin}) async { // Use custom base URL for third-party service final data = await get( '$_recentMessagesUrl/recent-messages/$userLogin', ); return data['messages'] as JsonList; } /// Returns a list of broadcaster IDs for channels the authenticated user /// moderates, following pagination so moderators of more than one page of /// channels aren't silently truncated at the first 100. Future> getModeratedChannels({ required String id, String? cursor, }) async { try { // Default page size is 20; request the max to keep the request count low. final queryParams = {'user_id': id, 'first': '100'}; if (cursor != null) queryParams['after'] = cursor; final data = await get( '/moderation/channels', queryParameters: queryParams, ); final channels = data['data'] as JsonList; final result = channels .map((channel) => (channel as JsonMap)['broadcaster_id'] as String?) .whereType() .toList(); final paginationCursor = data['pagination']['cursor']; if (paginationCursor != null && channels.isNotEmpty) { // Throttle between pages to stay under Twitch's rate limit, matching // getUserBlockedList above. await Future.delayed(const Duration(milliseconds: 150)); result.addAll( await getModeratedChannels(id: id, cursor: paginationCursor), ); } return result; } on ApiException catch (e) { // Backstop: callers should skip this without moderator scopes, but a // stale/partial token must not take down chat. debugPrint('Failed to get moderated channels: $e'); return []; } } /// Deletes a specific chat message. Future deleteChatMessage({ required String broadcasterId, required String moderatorId, required String messageId, }) async { // Let ApiException propagate so the caller can surface Twitch's reason // (e.g. you can't delete the broadcaster's or another moderator's message) // instead of a generic failure. await delete( '/moderation/chat', queryParameters: { 'broadcaster_id': broadcasterId, 'moderator_id': moderatorId, 'message_id': messageId, }, ); return true; } /// Bans or times out a user from a channel. /// /// The optional `duration` in seconds will timeout the user. If omitted, the user is banned. /// The optional `reason` will be displayed to the banned user and other moderators. Future banUser({ required String broadcasterId, required String moderatorId, required String userIdToBan, int? duration, String? reason, }) async { final requestBody = { 'data': { 'user_id': userIdToBan, 'duration': ?duration, 'reason': ?reason, }, }; // Let ApiException propagate so the caller can surface Twitch's reason // (e.g. the target is a moderator and can't be banned) instead of a // generic failure. final response = await post( '/moderation/bans', data: requestBody, queryParameters: { 'broadcaster_id': broadcasterId, 'moderator_id': moderatorId, }, ); final data = response['data'] as JsonList; return data.isNotEmpty; } /// Removes a ban or timeout for a user in a channel. /// /// A timeout is a time-limited ban, so this lifts both. Lets ApiException /// propagate (e.g. the user isn't currently banned) so the caller can show /// Twitch's reason. Future unbanUser({ required String broadcasterId, required String moderatorId, required String userId, }) async { await delete( '/moderation/bans', queryParameters: { 'broadcaster_id': broadcasterId, 'moderator_id': moderatorId, 'user_id': userId, }, ); return true; } } /// Result of a successful Twitch `/oauth2/validate` call. class TwitchTokenInfo { final List scopes; /// Present for user access tokens; omitted for app access tokens. final String? userId; const TwitchTokenInfo({required this.scopes, this.userId}); }