Load tag (and language) directories via Helix with client-side tag matching so scroll can continue past the GQL integrity wall. Re-sort lists locally after each fetch and store streamViewerSort in Settings. Co-authored-by: Cursor <cursoragent@cursor.com>
722 lines
23 KiB
Dart
722 lines
23 KiB
Dart
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<List<Emote>> getEmotesGlobal() async {
|
||
final data = await get<JsonMap>('/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<List<Emote>> getEmotesChannel({required String id}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<List<Emote>> getEmotesSets({required String setId}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<Map<String, ChatBadge>> getBadgesGlobal() async {
|
||
final data = await get<JsonMap>('/chat/badges/global');
|
||
|
||
final result = <String, ChatBadge>{};
|
||
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<Map<String, ChatBadge>> getBadgesChannel({required String id}) async {
|
||
final data = await get<JsonMap>(
|
||
'/chat/badges',
|
||
queryParameters: {'broadcaster_id': id},
|
||
);
|
||
|
||
final result = <String, ChatBadge>{};
|
||
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<UserTwitch> getUserInfo() async {
|
||
final data = await get<JsonMap>('/users');
|
||
|
||
final userData = data['data'] as JsonList;
|
||
return UserTwitch.fromJson(userData.first);
|
||
}
|
||
|
||
/// Returns a token for an anonymous user.
|
||
Future<String> getDefaultToken() async {
|
||
// Use custom base URL for OAuth
|
||
final data = await post<JsonMap>(
|
||
'$_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<TwitchTokenInfo?> validateToken({required String token}) async {
|
||
try {
|
||
final data = await get<JsonMap>(
|
||
'$_oauthBaseUrl/validate',
|
||
headers: {'Authorization': 'Bearer $token'},
|
||
);
|
||
final scopes = data['scopes'];
|
||
final scopeList = scopes is List
|
||
? scopes.map((scope) => scope.toString()).toList()
|
||
: const <String>[];
|
||
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 top streams and a cursor for further requests.
|
||
///
|
||
/// [language] is an optional ISO 639-1 code (Helix accepts only one).
|
||
Future<StreamsTwitch> getTopStreams({
|
||
String? cursor,
|
||
String? language,
|
||
int first = 100,
|
||
}) async {
|
||
final queryParams = <String, dynamic>{
|
||
'first': first,
|
||
if (cursor != null) 'after': cursor,
|
||
if (language != null && language.isNotEmpty) 'language': language,
|
||
};
|
||
|
||
final data = await get<JsonMap>(
|
||
'/streams',
|
||
queryParameters: queryParams,
|
||
);
|
||
|
||
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<StreamsTwitch> getFollowedStreams({
|
||
required String id,
|
||
String? cursor,
|
||
}) async {
|
||
final queryParams = {'user_id': id, 'first': '100'};
|
||
if (cursor != null) queryParams['after'] = cursor;
|
||
|
||
final data = await get<JsonMap>(
|
||
'/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<FollowedChannels> getFollowedChannels({
|
||
required String userId,
|
||
String? cursor,
|
||
}) async {
|
||
final queryParams = {'user_id': userId, 'first': '100'};
|
||
if (cursor != null) queryParams['after'] = cursor;
|
||
|
||
final data = await get<JsonMap>(
|
||
'/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<StreamsTwitch> getStreamsUnderCategory({
|
||
required String gameId,
|
||
String? cursor,
|
||
String? language,
|
||
int first = 100,
|
||
}) async {
|
||
final queryParams = <String, dynamic>{
|
||
'game_id': gameId,
|
||
'first': first,
|
||
if (cursor != null) 'after': cursor,
|
||
if (language != null && language.isNotEmpty) 'language': language,
|
||
};
|
||
|
||
final data = await get<JsonMap>('/streams', queryParameters: queryParams);
|
||
|
||
return StreamsTwitch.fromJson(data);
|
||
}
|
||
|
||
/// Fetches Helix directory pages until enough streams match [languages] and
|
||
/// optional [freeformTag] (case-insensitive tag name), or the directory ends.
|
||
///
|
||
/// Helix is always viewer-desc and supports at most one `language` query
|
||
/// param; tags are filtered client-side. Prefer this over GQL for pagination
|
||
/// (GQL page-2 requires a non-bot Client-Integrity token).
|
||
Future<StreamsTwitch> getDirectoryStreams({
|
||
List<String> languages = const [],
|
||
String? gameId,
|
||
String? freeformTag,
|
||
String? cursor,
|
||
int minCount = 50,
|
||
int maxPages = 12,
|
||
}) async {
|
||
final wantedLangs = languages
|
||
.map((l) => l.trim().toLowerCase())
|
||
.where((l) => l.isNotEmpty)
|
||
.toSet();
|
||
final tagNeedle = freeformTag?.trim().toLowerCase();
|
||
final hasTag = tagNeedle != null && tagNeedle.isNotEmpty;
|
||
|
||
bool matches(StreamTwitch stream) {
|
||
if (wantedLangs.isNotEmpty &&
|
||
!wantedLangs.contains(stream.language.toLowerCase())) {
|
||
return false;
|
||
}
|
||
if (hasTag) {
|
||
final hit = stream.tags.any((t) => t.toLowerCase() == tagNeedle);
|
||
if (!hit) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Fast path: no tag filter and 0–1 languages → one Helix call.
|
||
if (!hasTag && wantedLangs.length <= 1) {
|
||
final lang = wantedLangs.isEmpty ? null : wantedLangs.first;
|
||
return gameId == null
|
||
? getTopStreams(cursor: cursor, language: lang)
|
||
: getStreamsUnderCategory(
|
||
gameId: gameId,
|
||
cursor: cursor,
|
||
language: lang,
|
||
);
|
||
}
|
||
|
||
// When filtering by tag and exactly one language, still pass language to
|
||
// Helix so each page is denser in matches.
|
||
final helixLanguage =
|
||
wantedLangs.length == 1 ? wantedLangs.first : null;
|
||
|
||
final matched = <StreamTwitch>[];
|
||
String? pageCursor = cursor;
|
||
String? nextCursor;
|
||
var pages = 0;
|
||
|
||
while (matched.length < minCount && pages < maxPages) {
|
||
final page = gameId == null
|
||
? await getTopStreams(cursor: pageCursor, language: helixLanguage)
|
||
: await getStreamsUnderCategory(
|
||
gameId: gameId,
|
||
cursor: pageCursor,
|
||
language: helixLanguage,
|
||
);
|
||
pages++;
|
||
nextCursor = page.pagination['cursor'];
|
||
for (final stream in page.data) {
|
||
if (matches(stream)) matched.add(stream);
|
||
}
|
||
if (nextCursor == null || page.data.isEmpty) {
|
||
nextCursor = null;
|
||
break;
|
||
}
|
||
pageCursor = nextCursor;
|
||
}
|
||
|
||
return StreamsTwitch(
|
||
matched,
|
||
nextCursor != null ? {'cursor': nextCursor} : const {},
|
||
);
|
||
}
|
||
|
||
/// Deprecated name — use [getDirectoryStreams].
|
||
Future<StreamsTwitch> getStreamsMatchingLanguages({
|
||
required List<String> languages,
|
||
String? gameId,
|
||
String? cursor,
|
||
int minCount = 40,
|
||
int maxPages = 8,
|
||
}) =>
|
||
getDirectoryStreams(
|
||
languages: languages,
|
||
gameId: gameId,
|
||
cursor: cursor,
|
||
minCount: minCount,
|
||
maxPages: maxPages,
|
||
);
|
||
|
||
/// Returns a [StreamTwitch] object containing the stream info associated with the given [userLogin].
|
||
Future<StreamTwitch> getStream({required String userLogin}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<StreamsTwitch> getStreamsByIds({required List<String> 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<JsonMap>(url);
|
||
|
||
return StreamsTwitch.fromJson(data);
|
||
}
|
||
|
||
/// Returns a [UserTwitch] object containing the user info associated with the given [userLogin].
|
||
Future<UserTwitch> getUser({String? userLogin, String? id}) async {
|
||
final queryParams = <String, String>{};
|
||
if (id != null) {
|
||
queryParams['id'] = id;
|
||
} else if (userLogin != null) {
|
||
queryParams['login'] = userLogin;
|
||
}
|
||
|
||
final data = await get<JsonMap>('/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<Channel> getChannel({required String userId}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<List<ChannelQuery>> searchChannels({required String query}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<CategoriesTwitch> getTopCategories({String? cursor}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<CategoriesTwitch> getCategory({required String gameId}) async {
|
||
final data = await get<JsonMap>('/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<CategoriesTwitch> searchCategories({
|
||
required String query,
|
||
String? cursor,
|
||
}) async {
|
||
final queryParams = {'first': '8', 'query': query};
|
||
if (cursor != null) queryParams['after'] = cursor;
|
||
|
||
final data = await get<JsonMap>(
|
||
'/search/categories',
|
||
queryParameters: queryParams,
|
||
);
|
||
|
||
return CategoriesTwitch.fromJson(data);
|
||
}
|
||
|
||
/// Returns the sub count associated with the given [userId].
|
||
Future<int> getSubscriberCount({required String userId}) async {
|
||
final data = await get<JsonMap>(
|
||
'/subscriptions',
|
||
queryParameters: {'broadcaster_id': userId},
|
||
);
|
||
|
||
return data['total'] as int;
|
||
}
|
||
|
||
/// Returns a user's list of blocked users given their id.
|
||
Future<List<UserBlockedTwitch>> getUserBlockedList({
|
||
required String id,
|
||
String? cursor,
|
||
}) async {
|
||
final queryParams = {'first': '100', 'broadcaster_id': id};
|
||
if (cursor != null) queryParams['after'] = cursor;
|
||
|
||
final data = await get<JsonMap>(
|
||
'/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<bool> blockUser({required String userId}) async {
|
||
try {
|
||
await put<dynamic>(
|
||
'/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<bool> unblockUser({required String userId}) async {
|
||
try {
|
||
await delete<dynamic>(
|
||
'/users/blocks',
|
||
queryParameters: {'target_user_id': userId},
|
||
);
|
||
return true; // If no exception, operation succeeded
|
||
} on ApiException {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
Future<SharedChatSession?> getSharedChatSession({
|
||
required String broadcasterId,
|
||
}) async {
|
||
final data = await get<JsonMap>(
|
||
'/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<String> getUserChatColor({required String userId}) async {
|
||
try {
|
||
final data = await get<JsonMap>(
|
||
'/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<bool> updateUserChatColor({
|
||
required String userId,
|
||
required String color,
|
||
}) async {
|
||
try {
|
||
await put<dynamic>(
|
||
'/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<JsonList> getRecentMessages({required String userLogin}) async {
|
||
// Use custom base URL for third-party service
|
||
final data = await get<JsonMap>(
|
||
'$_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<List<String>> 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<JsonMap>(
|
||
'/moderation/channels',
|
||
queryParameters: queryParams,
|
||
);
|
||
|
||
final channels = data['data'] as JsonList;
|
||
final result = channels
|
||
.map((channel) => (channel as JsonMap)['broadcaster_id'] as String?)
|
||
.whereType<String>()
|
||
.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<bool> 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<dynamic>(
|
||
'/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<bool> 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<JsonMap>(
|
||
'/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<bool> unbanUser({
|
||
required String broadcasterId,
|
||
required String moderatorId,
|
||
required String userId,
|
||
}) async {
|
||
await delete<dynamic>(
|
||
'/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<String> scopes;
|
||
|
||
/// Present for user access tokens; omitted for app access tokens.
|
||
final String? userId;
|
||
|
||
const TwitchTokenInfo({required this.scopes, this.userId});
|
||
}
|