Automatically detect token kind (#764)

This commit is contained in:
Alexey Golub 2022-01-03 14:52:16 -08:00 committed by GitHub
parent e97151cd19
commit 2156c6cd0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 127 additions and 163 deletions

View file

@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable
{ {
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { channelId }, ChannelIds = new[] { channelId },
ExportFormat = format, ExportFormat = format,
OutputPath = filePath OutputPath = filePath

View file

@ -22,24 +22,5 @@ internal static class Secrets
throw new InvalidOperationException("Discord token not provided for tests."); throw new InvalidOperationException("Discord token not provided for tests.");
}); });
private static readonly Lazy<bool> 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 string DiscordToken => DiscordTokenLazy.Value;
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
} }

View file

@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@ -74,8 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@ -120,8 +118,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,

View file

@ -25,8 +25,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@ -54,8 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@ -83,8 +81,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@ -112,8 +109,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,

View file

@ -25,8 +25,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
@ -50,8 +49,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,

View file

@ -25,8 +25,7 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases }, ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,

View file

@ -1,4 +1,5 @@
using System.Threading.Tasks; using System;
using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@ -9,21 +10,14 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class TokenCommandBase : ICommand public abstract class TokenCommandBase : ICommand
{ {
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")] [CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
public string TokenValue { get; init; } = ""; public string Token { get; init; } = "";
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")] [CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
public bool IsBotToken { get; init; } public bool IsBotToken { get; init; }
private AuthToken? _authToken;
private AuthToken AuthToken => _authToken ??= new AuthToken(
IsBotToken
? AuthTokenKind.Bot
: AuthTokenKind.User,
TokenValue
);
private DiscordClient? _discordClient; private DiscordClient? _discordClient;
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken); protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
public abstract ValueTask ExecuteAsync(IConsole console); public abstract ValueTask ExecuteAsync(IConsole console);
} }

View file

@ -61,7 +61,7 @@ public class GuideCommand : ICommand
// Wiki link // Wiki link
using (console.WithForegroundColor(ConsoleColor.White)) 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)) using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki"); console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");

View file

@ -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)
};
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@ -18,10 +19,30 @@ namespace DiscordChatExporter.Core.Discord;
public class DiscordClient public class DiscordClient
{ {
private readonly AuthToken _token; private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute); 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<HttpResponseMessage> 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<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
@ -29,14 +50,33 @@ public class DiscordClient
{ {
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken => return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); if (_tokenKind == TokenKind.User)
request.Headers.Authorization = _token.GetAuthenticationHeader(); return await GetResponseAsync(url, false, innerCancellationToken);
return await Http.Client.SendAsync( if (_tokenKind == TokenKind.Bot)
request, return await GetResponseAsync(url, true, innerCancellationToken);
HttpCompletionOption.ResponseHeadersRead,
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); }, cancellationToken);
} }

View file

@ -1,7 +1,8 @@
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
public enum AuthTokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }

View file

@ -17,8 +17,6 @@ public class ChannelExporter
public ChannelExporter(DiscordClient discord) => _discord = discord; public ChannelExporter(DiscordClient discord) => _discord = discord;
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
public async ValueTask ExportChannelAsync( public async ValueTask ExportChannelAsync(
ExportRequest request, ExportRequest request,
IProgress<double>? progress = null, IProgress<double>? progress = null,

View file

@ -1,5 +1,4 @@
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting;
using Tyrrrz.Settings; using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
@ -18,7 +17,7 @@ public class SettingsService : SettingsManager
public bool ShouldReuseMedia { get; set; } public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; } public string? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;

View file

@ -25,6 +25,8 @@ public class RootViewModel : Screen
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
private readonly UpdateService _updateService; private readonly UpdateService _updateService;
private DiscordClient? _discord;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5)); public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public IProgressManager ProgressManager { get; } = new ProgressManager(); public IProgressManager ProgressManager { get; } = new ProgressManager();
@ -33,9 +35,7 @@ public class RootViewModel : Screen
public bool IsProgressIndeterminate { get; private set; } public bool IsProgressIndeterminate { get; private set; }
public bool IsBotToken { get; set; } public string? Token { get; set; }
public string? TokenValue { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; } private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
@ -111,8 +111,7 @@ public class RootViewModel : Screen
if (_settingsService.LastToken is not null) if (_settingsService.LastToken is not null)
{ {
IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot; Token = _settingsService.LastToken;
TokenValue = _settingsService.LastToken.Value;
} }
if (_settingsService.IsDarkModeEnabled) if (_settingsService.IsDarkModeEnabled)
@ -144,7 +143,7 @@ public class RootViewModel : Screen
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels => public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue); !IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels() public async void PopulateGuildsAndChannels()
{ {
@ -152,15 +151,10 @@ public class RootViewModel : Screen
try try
{ {
var tokenValue = TokenValue?.Trim('"', ' '); var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue)) if (string.IsNullOrWhiteSpace(token))
return; return;
var token = new AuthToken(
IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User,
tokenValue
);
_settingsService.LastToken = token; _settingsService.LastToken = token;
var discord = new DiscordClient(token); var discord = new DiscordClient(token);
@ -172,6 +166,7 @@ public class RootViewModel : Screen
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray(); guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
} }
_discord = discord;
GuildChannelMap = guildChannelMap; GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
} }
@ -191,21 +186,24 @@ public class RootViewModel : Screen
} }
public bool CanExportChannels => 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() public async void ExportChannels()
{ {
try try
{ {
var token = _settingsService.LastToken; if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return; return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true) if (await _dialogManager.ShowDialogAsync(dialog) != true)
return; return;
var exporter = new ChannelExporter(token); var exporter = new ChannelExporter(_discord);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0; var successfulExportCount = 0;

View file

@ -64,39 +64,28 @@
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Token type --> <!-- Token icon -->
<ToggleButton <materialDesign:PackIcon
Grid.Column="0" Grid.Column="0"
Margin="6" Width="24"
IsChecked="{Binding IsBotToken}" Height="24"
Style="{DynamicResource MaterialDesignFlatActionToggleButton}" Margin="8"
ToolTip="Switch between user token and bot token"> VerticalAlignment="Center"
<ToggleButton.Content> Foreground="{DynamicResource PrimaryHueMidBrush}"
<materialDesign:PackIcon Kind="Password" />
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value --> <!-- Token value -->
<TextBox <TextBox
x:Name="TokenValueTextBox" x:Name="TokenValueTextBox"
Grid.Column="1" Grid.Column="1"
Margin="2,6,6,7" Margin="0,6,6,8"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token" materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden" materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0" materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0" BorderThickness="0"
FontSize="16" FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button --> <!-- Pull data button -->
<Button <Button
@ -152,11 +141,22 @@
</Style> </Style>
</Grid.Resources> </Grid.Resources>
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token --> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <TextBlock Margin="32,16" FontSize="14">
<TextBlock FontSize="18" Text="Please provide your user token to authorize" /> <Run FontSize="18" Text="Please provide authentication token to continue" />
<TextBlock Margin="8,8,0,0" FontSize="14"> <LineBreak />
<LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord" /> <Run Text="1. Open Discord" />
<LineBreak /> <LineBreak />
<Run Text="2. Press" /> <Run Text="2. Press" />
@ -189,35 +189,20 @@
<Run Text="8. Copy the value of the" /> <Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" /> <Run FontWeight="SemiBold" Text="token" />
<Run Text="key" /> <Run Text="key" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="Automating user accounts is technically against TOS, use at your own risk." />
<LineBreak /> <LineBreak />
<Run Text="To authorize using bot token instead, click" /> <Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon <materialDesign:PackIcon
Margin="1,0,0,-3" Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." /> <Run FontSize="16" Text="Authenticate as a bot" />
</TextBlock> <LineBreak />
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
<!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock
FontSize="18"
FontWeight="Light"
Text="Please provide your bot token to authorize" />
<TextBlock
Margin="8,8,0,0"
FontSize="14"
FontWeight="Light">
<Run Text="1. Open Discord developer portal" /> <Run Text="1. Open Discord developer portal" />
<LineBreak /> <LineBreak />
<Run Text="2. Open your application's settings" /> <Run Text="2. Open your application's settings" />
@ -230,22 +215,13 @@
<Run FontWeight="SemiBold" Text="Token" /> <Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" /> <Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> </ScrollViewer>
<Run Text="To authorize using user token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-1"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
</Grid> </Grid>
<!-- Guilds and channels --> <!-- Guilds and channels -->