/// Core OAuth scopes Frosty requests on a normal Twitch login. /// /// Matches the pre-moderator-tools set so existing tokens remain valid without /// a re-consent nag for non-moderators. const twitchUserScopes = [ 'chat:read', 'chat:edit', 'user:read:follows', 'user:read:blocked_users', 'user:manage:blocked_users', 'user:manage:chat_color', ]; /// Moderator-only scopes requested via an explicit opt-in upgrade. const twitchModeratorScopes = [ 'user:read:moderated_channels', 'moderator:manage:chat_messages', 'moderator:manage:banned_users', ]; /// Core + moderator scopes for an opted-in moderator session. const twitchAllUserScopes = [ ...twitchUserScopes, ...twitchModeratorScopes, ]; /// Space-separated scope string for Twitch's authorize URL (core only). String get twitchUserScopeQuery => twitchUserScopes.join(' '); /// Space-separated scope string for Twitch's authorize URL (core + mod). String get twitchAllUserScopeQuery => twitchAllUserScopes.join(' '); /// Human-readable labels for Frosty-requested scopes. const twitchScopeLabels = { 'chat:read': 'Read chat messages', 'chat:edit': 'Send chat messages', 'user:read:follows': 'View followed channels', 'user:read:blocked_users': 'View blocked users', 'user:manage:blocked_users': 'Block and unblock users', 'user:manage:chat_color': 'Change chat name color', 'user:read:moderated_channels': 'View channels you moderate', 'moderator:manage:chat_messages': 'Delete chat messages as a moderator', 'moderator:manage:banned_users': 'Timeout and ban users as a moderator', }; /// Whether [granted] includes every moderator scope Frosty needs. bool hasModeratorScopes(Iterable granted) => missingTwitchScopes( granted: granted, required: twitchModeratorScopes, ).isEmpty; /// Scopes in [required] that are not present in [granted]. List missingTwitchScopes({ required Iterable granted, Iterable required = twitchUserScopes, }) { final grantedSet = granted.toSet(); return required.where((scope) => !grantedSet.contains(scope)).toList(); } String _scopeBullets(Iterable scopes) => scopes .map((scope) => '• ${twitchScopeLabels[scope] ?? scope}') .join('\n'); /// Dialog body for a Twitch Helix/OAuth 401 on a core (non-mod) request. /// /// Missing-permissions copy only lists **core** scopes. A token that already /// has core scopes is treated as expired/invalid rather than "missing mod /// permissions" so non-mods are never nagged about moderator consent. String unauthorizedDialogMessage({ required bool isLoggedIn, required Iterable grantedScopes, }) { if (!isLoggedIn) { return 'Your session has expired. Please log in again to continue.'; } final missing = missingTwitchScopes(granted: grantedScopes); if (missing.isEmpty) { return 'Your session has expired. Please log in again to continue.'; } return 'Frosty needs additional Twitch permissions:\n\n' '${_scopeBullets(missing)}\n\n' 'Log in again to grant them.'; } /// Copy for the explicit moderator-tools opt-in dialog. String enableModeratorToolsDialogMessage() { return 'Enable moderator tools to manage chat in channels you moderate:\n\n' '${_scopeBullets(twitchModeratorScopes)}\n\n' 'You will be asked to authorize these permissions with Twitch.'; }