Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor <cursoragent@cursor.com>
352 lines
10 KiB
Dart
352 lines
10 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:frosty/apis/base_api_client.dart';
|
|
import 'package:frosty/apis/twitch_api.dart';
|
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
|
import 'package:frosty/apis/twitch_integrity.dart';
|
|
import 'package:frosty/models/user.dart';
|
|
import 'package:mobx/mobx.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
part 'user_store.g.dart';
|
|
|
|
class UserStore = UserStoreBase with _$UserStore;
|
|
|
|
abstract class UserStoreBase with Store {
|
|
final TwitchApi twitchApi;
|
|
final TwitchGqlApi twitchGqlApi;
|
|
|
|
/// Whether the active auth token includes Frosty's moderator OAuth scopes.
|
|
final bool Function() hasModeratorScopes;
|
|
|
|
/// The current user's info.
|
|
@readonly
|
|
UserTwitch? _details;
|
|
|
|
/// The user's list of blocked users.
|
|
@readonly
|
|
var _blockedUsers = ObservableList<UserBlockedTwitch>();
|
|
|
|
/// The list of channel IDs the user moderates.
|
|
@readonly
|
|
var _moderatedChannels = ObservableList<String>();
|
|
|
|
/// Cached follow state for channels queried this session (id → following).
|
|
final _followCache = ObservableMap<String, bool>();
|
|
|
|
static const _deviceIdKey = 'twitch_gql_device_id';
|
|
|
|
String? _deviceId;
|
|
String? _integrityToken;
|
|
int _integrityExpirationMs = 0;
|
|
|
|
ReactionDisposer? _disposeReaction;
|
|
|
|
UserStoreBase({
|
|
required this.twitchApi,
|
|
required this.twitchGqlApi,
|
|
required this.hasModeratorScopes,
|
|
});
|
|
|
|
@action
|
|
Future<void> init() async {
|
|
// Get and update the current user's info.
|
|
_details = await twitchApi.getUserInfo();
|
|
|
|
// Get and update non-critical user info.
|
|
// Don't use await because having a huge list of blocked users will block the UI.
|
|
if (_details?.id != null) {
|
|
twitchApi
|
|
.getUserBlockedList(id: _details!.id)
|
|
.then((blockedUsers) => _blockedUsers = blockedUsers.asObservable());
|
|
// Never hit moderated-channels Helix without moderator scopes — that 401
|
|
// is what nags non-mod users after an app upgrade.
|
|
if (hasModeratorScopes()) {
|
|
twitchApi
|
|
.getModeratedChannels(id: _details!.id)
|
|
.then(
|
|
(channels) => _moderatedChannels = channels.asObservable(),
|
|
);
|
|
} else {
|
|
_moderatedChannels = ObservableList<String>();
|
|
}
|
|
}
|
|
|
|
_disposeReaction?.call();
|
|
_disposeReaction = autorun(
|
|
(_) => _blockedUsers.sort((a, b) => a.userLogin.compareTo(b.userLogin)),
|
|
);
|
|
}
|
|
|
|
/// Whether we already know follow state for [channelId] this session.
|
|
bool? cachedIsFollowing(String channelId) => _followCache[channelId];
|
|
|
|
@action
|
|
Future<bool> isFollowingChannel(String channelId) async {
|
|
final cached = _followCache[channelId];
|
|
if (cached != null) return cached;
|
|
|
|
final userId = _details?.id;
|
|
if (userId == null) return false;
|
|
|
|
final following = await twitchApi.isFollowingChannel(
|
|
userId: userId,
|
|
broadcasterId: channelId,
|
|
);
|
|
_followCache[channelId] = following;
|
|
return following;
|
|
}
|
|
|
|
/// Follows [targetId] via web GQL. [gqlToken] is the Twitch web cookie token.
|
|
@action
|
|
Future<void> follow({
|
|
required String targetId,
|
|
required String gqlToken,
|
|
}) async {
|
|
await _followMutation(
|
|
targetId: targetId,
|
|
gqlToken: gqlToken,
|
|
follow: true,
|
|
);
|
|
_followCache[targetId] = true;
|
|
}
|
|
|
|
/// Unfollows [targetId] via web GQL.
|
|
@action
|
|
Future<void> unfollow({
|
|
required String targetId,
|
|
required String gqlToken,
|
|
}) async {
|
|
await _followMutation(
|
|
targetId: targetId,
|
|
gqlToken: gqlToken,
|
|
follow: false,
|
|
);
|
|
_followCache[targetId] = false;
|
|
}
|
|
|
|
Future<void> _followMutation({
|
|
required String targetId,
|
|
required String gqlToken,
|
|
required bool follow,
|
|
}) async {
|
|
await runWithGqlIntegrity(
|
|
gqlToken: gqlToken,
|
|
action: ({authToken, integrityToken, deviceId}) {
|
|
if (follow) {
|
|
return twitchGqlApi.followUser(
|
|
targetId: targetId,
|
|
authToken: authToken!,
|
|
integrityToken: integrityToken,
|
|
deviceId: deviceId,
|
|
);
|
|
}
|
|
return twitchGqlApi.unfollowUser(
|
|
targetId: targetId,
|
|
authToken: authToken!,
|
|
integrityToken: integrityToken,
|
|
deviceId: deviceId,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Runs a GQL call with optional Client-Integrity; refreshes once on
|
|
/// [IntegrityCheckException] when a web [gqlToken] is available.
|
|
///
|
|
/// Dio-minted tokens that are `is_bad_bot` are discarded; callers should
|
|
/// then capture credentials via a WebView and [applyWebIntegrity].
|
|
Future<T> runWithGqlIntegrity<T>({
|
|
required String? gqlToken,
|
|
required Future<T> Function({
|
|
required String? authToken,
|
|
required String? integrityToken,
|
|
required String? deviceId,
|
|
}) action,
|
|
}) async {
|
|
final deviceId = await _ensureDeviceId();
|
|
final authToken =
|
|
gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null;
|
|
|
|
Future<T> run(String? integrityToken) => action(
|
|
authToken: authToken,
|
|
integrityToken: integrityToken,
|
|
deviceId: _deviceId ?? deviceId,
|
|
);
|
|
|
|
try {
|
|
return await run(_validIntegrityToken());
|
|
} on IntegrityCheckException {
|
|
if (authToken == null) {
|
|
clearIntegrity();
|
|
rethrow;
|
|
}
|
|
try {
|
|
final integrity = await twitchGqlApi.fetchIntegrityToken(
|
|
authToken: authToken,
|
|
deviceId: _deviceId ?? deviceId,
|
|
);
|
|
_integrityToken = integrity.token;
|
|
_integrityExpirationMs = integrity.expirationMs;
|
|
return await run(integrity.token);
|
|
} on IntegrityCheckException {
|
|
clearIntegrity();
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]).
|
|
Future<void> applyWebIntegrity(GqlIntegrityCredentials credentials) async {
|
|
if (TwitchIntegrity.isBadBot(credentials.integrityToken)) {
|
|
clearIntegrity();
|
|
throw const IntegrityCheckException(
|
|
'Twitch integrity token flagged as bad bot',
|
|
);
|
|
}
|
|
_integrityToken = credentials.integrityToken;
|
|
_integrityExpirationMs = credentials.expirationMs;
|
|
_deviceId = credentials.deviceId;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_deviceIdKey, credentials.deviceId);
|
|
}
|
|
|
|
void clearIntegrity() {
|
|
_integrityToken = null;
|
|
_integrityExpirationMs = 0;
|
|
}
|
|
|
|
String? _validIntegrityToken() {
|
|
if (_integrityToken == null) return null;
|
|
if (TwitchIntegrity.isBadBot(_integrityToken!)) {
|
|
clearIntegrity();
|
|
return null;
|
|
}
|
|
if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) {
|
|
return null;
|
|
}
|
|
return _integrityToken;
|
|
}
|
|
|
|
Future<String> _ensureDeviceId() async {
|
|
if (_deviceId != null) return _deviceId!;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
var id = prefs.getString(_deviceIdKey);
|
|
if (id == null || id.isEmpty) {
|
|
final random = Random.secure();
|
|
id = List.generate(
|
|
32,
|
|
(_) => random.nextInt(16).toRadixString(16),
|
|
).join();
|
|
await prefs.setString(_deviceIdKey, id);
|
|
}
|
|
_deviceId = id;
|
|
return id;
|
|
}
|
|
|
|
@action
|
|
Future<void> block({
|
|
required String targetId,
|
|
required String displayName,
|
|
}) async {
|
|
final success = await twitchApi.blockUser(userId: targetId);
|
|
|
|
if (success) {
|
|
_blockedUsers.add(UserBlockedTwitch(targetId, displayName, displayName));
|
|
}
|
|
}
|
|
|
|
@action
|
|
Future<void> unblock({required String targetId}) async {
|
|
final success = await twitchApi.unblockUser(userId: targetId);
|
|
if (success) await refreshBlockedUsers();
|
|
}
|
|
|
|
@action
|
|
Future<void> refreshBlockedUsers() async => _blockedUsers =
|
|
(await twitchApi.getUserBlockedList(id: _details!.id)).asObservable();
|
|
|
|
/// Re-fetches which channels the user moderates. Mod status is otherwise only
|
|
/// loaded once at login, so call this when entering a channel to reflect mod
|
|
/// status granted or revoked since then.
|
|
@action
|
|
Future<void> refreshModeratedChannels() async {
|
|
if (_details?.id == null || !hasModeratorScopes()) return;
|
|
_moderatedChannels =
|
|
(await twitchApi.getModeratedChannels(id: _details!.id)).asObservable();
|
|
}
|
|
|
|
bool isModerator(String channelId) {
|
|
return _moderatedChannels.contains(channelId);
|
|
}
|
|
|
|
/// Whether the logged-in user can moderate [channelId] — either a moderator
|
|
/// of that channel or its broadcaster (the broadcaster isn't in their own
|
|
/// moderator list but can still use the moderation endpoints).
|
|
///
|
|
/// Role only — does not require moderator OAuth scopes. Use
|
|
/// [canPerformModeratorActions] before calling Helix.
|
|
bool canModerate(String channelId) =>
|
|
_details?.id == channelId || isModerator(channelId);
|
|
|
|
/// Role + moderator OAuth scopes — safe to call Helix mod endpoints.
|
|
bool canPerformModeratorActions(String channelId) =>
|
|
_details?.id != null &&
|
|
hasModeratorScopes() &&
|
|
canModerate(channelId);
|
|
|
|
@action
|
|
Future<bool> deleteMessage({
|
|
required String broadcasterId,
|
|
required String messageId,
|
|
}) async {
|
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
|
return twitchApi.deleteChatMessage(
|
|
broadcasterId: broadcasterId,
|
|
moderatorId: _details!.id,
|
|
messageId: messageId,
|
|
);
|
|
}
|
|
|
|
@action
|
|
Future<bool> banOrTimeoutUser({
|
|
required String broadcasterId,
|
|
required String userIdToBan,
|
|
int? duration,
|
|
String? reason,
|
|
}) async {
|
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
|
return twitchApi.banUser(
|
|
broadcasterId: broadcasterId,
|
|
moderatorId: _details!.id,
|
|
userIdToBan: userIdToBan,
|
|
duration: duration,
|
|
reason: reason,
|
|
);
|
|
}
|
|
|
|
/// Removes a ban or timeout for [userIdToUnban] in the channel.
|
|
@action
|
|
Future<bool> unbanUser({
|
|
required String broadcasterId,
|
|
required String userIdToUnban,
|
|
}) async {
|
|
if (!canPerformModeratorActions(broadcasterId)) return false;
|
|
return twitchApi.unbanUser(
|
|
broadcasterId: broadcasterId,
|
|
moderatorId: _details!.id,
|
|
userId: userIdToUnban,
|
|
);
|
|
}
|
|
|
|
@action
|
|
void dispose() {
|
|
_details = null;
|
|
_blockedUsers.clear();
|
|
_moderatedChannels.clear();
|
|
_followCache.clear();
|
|
clearIntegrity();
|
|
_disposeReaction?.call();
|
|
_disposeReaction = null;
|
|
}
|
|
}
|