diff --git a/DiscordChatExporter.Cli.Tests/Fixtures/ExportWrapperFixture.cs b/DiscordChatExporter.Cli.Tests/Fixtures/ExportWrapperFixture.cs index 90a842b..e8fbaea 100644 --- a/DiscordChatExporter.Cli.Tests/Fixtures/ExportWrapperFixture.cs +++ b/DiscordChatExporter.Cli.Tests/Fixtures/ExportWrapperFixture.cs @@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable { await new ExportChannelsCommand { - TokenValue = Secrets.DiscordToken, - IsBotToken = Secrets.IsDiscordTokenBot, + Token = Secrets.DiscordToken, ChannelIds = new[] { channelId }, ExportFormat = format, OutputPath = filePath diff --git a/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs b/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs index 6e2bd46..5d3180d 100644 --- a/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs +++ b/DiscordChatExporter.Cli.Tests/Infra/Secrets.cs @@ -22,24 +22,5 @@ internal static class Secrets throw new InvalidOperationException("Discord token not provided for tests."); }); - private static readonly Lazy IsDiscordTokenBotLazy = new(() => - { - var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT"); - if (!string.IsNullOrWhiteSpace(fromEnvironment)) - return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase); - - var secretFilePath = Path.Combine( - Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(), - "DiscordTokenBot.secret" - ); - - if (File.Exists(secretFilePath)) - return true; - - return false; - }); - public static string DiscordToken => DiscordTokenLazy.Value; - - public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value; } \ No newline at end of file diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs index 23af76d..63b98e0 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs @@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture _authToken ??= new AuthToken( - IsBotToken - ? AuthTokenKind.Bot - : AuthTokenKind.User, - TokenValue - ); - private DiscordClient? _discordClient; - protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken); + protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token); public abstract ValueTask ExecuteAsync(IConsole console); } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/GuideCommand.cs b/DiscordChatExporter.Cli/Commands/GuideCommand.cs index ead49b5..a234916 100644 --- a/DiscordChatExporter.Cli/Commands/GuideCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GuideCommand.cs @@ -61,7 +61,7 @@ public class GuideCommand : ICommand // Wiki link using (console.WithForegroundColor(ConsoleColor.White)) - console.Output.WriteLine("For more information, check out the wiki:"); + console.Output.WriteLine("If you have questions or issues, please refer to the wiki:"); using (console.WithForegroundColor(ConsoleColor.DarkCyan)) console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki"); diff --git a/DiscordChatExporter.Core/Discord/AuthToken.cs b/DiscordChatExporter.Core/Discord/AuthToken.cs deleted file mode 100644 index 6d10c6b..0000000 --- a/DiscordChatExporter.Core/Discord/AuthToken.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Net.Http.Headers; - -namespace DiscordChatExporter.Core.Discord; - -public record AuthToken(AuthTokenKind Kind, string Value) -{ - public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch - { - AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value), - _ => new AuthenticationHeaderValue(Value) - }; -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 9319d83..0ae41ff 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -18,10 +19,30 @@ namespace DiscordChatExporter.Core.Discord; public class DiscordClient { - private readonly AuthToken _token; + private readonly string _token; private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute); - public DiscordClient(AuthToken token) => _token = token; + private TokenKind _tokenKind = TokenKind.Unknown; + + public DiscordClient(string token) => _token = token; + + private async ValueTask GetResponseAsync( + string url, + bool isBot, + CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); + + request.Headers.Authorization = isBot + ? new AuthenticationHeaderValue("Bot", _token) + : new AuthenticationHeaderValue(_token); + + return await Http.Client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + } private async ValueTask GetResponseAsync( string url, @@ -29,14 +50,33 @@ public class DiscordClient { return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken => { - using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); - request.Headers.Authorization = _token.GetAuthenticationHeader(); - - return await Http.Client.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - innerCancellationToken - ); + if (_tokenKind == TokenKind.User) + return await GetResponseAsync(url, false, innerCancellationToken); + + if (_tokenKind == TokenKind.Bot) + return await GetResponseAsync(url, true, innerCancellationToken); + + // Try to authenticate as user + var userResponse = await GetResponseAsync(url, false, innerCancellationToken); + if (userResponse.StatusCode != HttpStatusCode.Unauthorized) + { + _tokenKind = TokenKind.User; + return userResponse; + } + + userResponse.Dispose(); + + // Otherwise, try to authenticate as bot + var botResponse = await GetResponseAsync(url, true, innerCancellationToken); + if (botResponse.StatusCode != HttpStatusCode.Unauthorized) + { + _tokenKind = TokenKind.Bot; + return botResponse; + } + + // The token is probably invalid altogether. + // Return the last response anyway, upstream should handle the error. + return botResponse; }, cancellationToken); } diff --git a/DiscordChatExporter.Core/Discord/AuthTokenKind.cs b/DiscordChatExporter.Core/Discord/TokenKind.cs similarity index 65% rename from DiscordChatExporter.Core/Discord/AuthTokenKind.cs rename to DiscordChatExporter.Core/Discord/TokenKind.cs index 4aa841a..f22afcc 100644 --- a/DiscordChatExporter.Core/Discord/AuthTokenKind.cs +++ b/DiscordChatExporter.Core/Discord/TokenKind.cs @@ -1,7 +1,8 @@ namespace DiscordChatExporter.Core.Discord; -public enum AuthTokenKind +public enum TokenKind { + Unknown, User, Bot } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 165b610..e6dcb7d 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -17,8 +17,6 @@ public class ChannelExporter public ChannelExporter(DiscordClient discord) => _discord = discord; - public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {} - public async ValueTask ExportChannelAsync( ExportRequest request, IProgress? progress = null, diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index dda9c09..9ee9886 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -1,5 +1,4 @@ -using DiscordChatExporter.Core.Discord; -using DiscordChatExporter.Core.Exporting; +using DiscordChatExporter.Core.Exporting; using Tyrrrz.Settings; namespace DiscordChatExporter.Gui.Services; @@ -18,7 +17,7 @@ public class SettingsService : SettingsManager public bool ShouldReuseMedia { get; set; } - public AuthToken? LastToken { get; set; } + public string? LastToken { get; set; } public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs index d611836..c9cc78a 100644 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs @@ -25,6 +25,8 @@ public class RootViewModel : Screen private readonly SettingsService _settingsService; private readonly UpdateService _updateService; + private DiscordClient? _discord; + public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5)); public IProgressManager ProgressManager { get; } = new ProgressManager(); @@ -33,9 +35,7 @@ public class RootViewModel : Screen public bool IsProgressIndeterminate { get; private set; } - public bool IsBotToken { get; set; } - - public string? TokenValue { get; set; } + public string? Token { get; set; } private IReadOnlyDictionary>? GuildChannelMap { get; set; } @@ -111,8 +111,7 @@ public class RootViewModel : Screen if (_settingsService.LastToken is not null) { - IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot; - TokenValue = _settingsService.LastToken.Value; + Token = _settingsService.LastToken; } if (_settingsService.IsDarkModeEnabled) @@ -144,7 +143,7 @@ public class RootViewModel : Screen public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); public bool CanPopulateGuildsAndChannels => - !IsBusy && !string.IsNullOrWhiteSpace(TokenValue); + !IsBusy && !string.IsNullOrWhiteSpace(Token); public async void PopulateGuildsAndChannels() { @@ -152,15 +151,10 @@ public class RootViewModel : Screen try { - var tokenValue = TokenValue?.Trim('"', ' '); - if (string.IsNullOrWhiteSpace(tokenValue)) + var token = Token?.Trim('"', ' '); + if (string.IsNullOrWhiteSpace(token)) return; - var token = new AuthToken( - IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User, - tokenValue - ); - _settingsService.LastToken = token; var discord = new DiscordClient(token); @@ -172,6 +166,7 @@ public class RootViewModel : Screen guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray(); } + _discord = discord; GuildChannelMap = guildChannelMap; SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); } @@ -191,21 +186,24 @@ public class RootViewModel : Screen } public bool CanExportChannels => - !IsBusy && SelectedGuild is not null && SelectedChannels is not null && SelectedChannels.Any(); + !IsBusy && + _discord is not null && + SelectedGuild is not null && + SelectedChannels is not null && + SelectedChannels.Any(); public async void ExportChannels() { try { - var token = _settingsService.LastToken; - if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any()) + if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any()) return; var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); if (await _dialogManager.ShowDialogAsync(dialog) != true) return; - var exporter = new ChannelExporter(token); + var exporter = new ChannelExporter(_discord); var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); var successfulExportCount = 0; diff --git a/DiscordChatExporter.Gui/Views/RootView.xaml b/DiscordChatExporter.Gui/Views/RootView.xaml index 8688aac..0083d7b 100644 --- a/DiscordChatExporter.Gui/Views/RootView.xaml +++ b/DiscordChatExporter.Gui/Views/RootView.xaml @@ -64,39 +64,28 @@ - - + - - - - - - - + Width="24" + Height="24" + Margin="8" + VerticalAlignment="Center" + Foreground="{DynamicResource PrimaryHueMidBrush}" + Kind="Password" /> + Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />