From f56c3e819f4bc102d847a19b3443cf3cbbc76ece Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 17 Apr 2020 18:38:12 +0200 Subject: [PATCH 01/21] Replace Discord.Net with DSharpPlus This commit will not compile. --- .gitmodules | 3 --- Discord.Net | 1 - PluralKit.Bot/PluralKit.Bot.csproj | 1 + PluralKit.sln | 18 ------------------ nuget.config | 6 ++++++ 5 files changed, 7 insertions(+), 22 deletions(-) delete mode 160000 Discord.Net create mode 100644 nuget.config diff --git a/.gitmodules b/.gitmodules index 5e9cfa0b..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "Discord.Net"] - path = Discord.Net - url = https://github.com/xSke/Discord.Net diff --git a/Discord.Net b/Discord.Net deleted file mode 160000 index 23567d17..00000000 --- a/Discord.Net +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 23567d17a64d1f4c8bd92cb7b3c7b69f8897a759 diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 1f9aed1f..0daa2514 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -11,6 +11,7 @@ + diff --git a/PluralKit.sln b/PluralKit.sln index bcea86e3..c33f65c8 100644 --- a/PluralKit.sln +++ b/PluralKit.sln @@ -6,12 +6,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Core", "PluralKit EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.API", "PluralKit.API\PluralKit.API.csproj", "{3420F8A9-125C-4F7F-A444-10DD16945754}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.WebSocket", "Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj", "{5FE544F8-310F-410A-8590-7B78469B5B9C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Core", "Discord.Net\src\Discord.Net.Core\Discord.Net.Core.csproj", "{D6376AC6-9BE4-49CE-99CD-061879B400D4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net.Rest", "Discord.Net\src\Discord.Net.Rest\Discord.Net.Rest.csproj", "{4723EEE6-322B-41A1-BD89-03219E181E38}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,17 +24,5 @@ Global {3420F8A9-125C-4F7F-A444-10DD16945754}.Debug|Any CPU.Build.0 = Debug|Any CPU {3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.ActiveCfg = Release|Any CPU {3420F8A9-125C-4F7F-A444-10DD16945754}.Release|Any CPU.Build.0 = Release|Any CPU - {5FE544F8-310F-410A-8590-7B78469B5B9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5FE544F8-310F-410A-8590-7B78469B5B9C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5FE544F8-310F-410A-8590-7B78469B5B9C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5FE544F8-310F-410A-8590-7B78469B5B9C}.Release|Any CPU.Build.0 = Release|Any CPU - {D6376AC6-9BE4-49CE-99CD-061879B400D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6376AC6-9BE4-49CE-99CD-061879B400D4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6376AC6-9BE4-49CE-99CD-061879B400D4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6376AC6-9BE4-49CE-99CD-061879B400D4}.Release|Any CPU.Build.0 = Release|Any CPU - {4723EEE6-322B-41A1-BD89-03219E181E38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4723EEE6-322B-41A1-BD89-03219E181E38}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4723EEE6-322B-41A1-BD89-03219E181E38}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4723EEE6-322B-41A1-BD89-03219E181E38}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/nuget.config b/nuget.config new file mode 100644 index 00000000..5ec2c25d --- /dev/null +++ b/nuget.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From 23cf06df4ca5b41006c7a80992e1fb151ab3480e Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 17 Apr 2020 23:10:01 +0200 Subject: [PATCH 02/21] Port some things, still does not compile --- PluralKit.Bot/Bot.cs | 180 +++++++++--------- PluralKit.Bot/CommandSystem/Context.cs | 75 +++----- PluralKit.Bot/Modules.cs | 22 +-- PluralKit.Bot/PluralKit.Bot.csproj | 1 - PluralKit.Bot/Services/EmbedService.cs | 72 ++++--- .../Services/LastMessageCacheService.cs | 1 + PluralKit.Bot/Services/LogChannelService.cs | 17 +- PluralKit.Bot/Services/LoggerCleanService.cs | 58 +++--- .../Services/PeriodicStatCollector.cs | 36 +++- PluralKit.Bot/Services/ProxyService.cs | 152 +++++++-------- PluralKit.Bot/Services/ShardInfoService.cs | 25 +-- PluralKit.Bot/Services/WebhookCacheService.cs | 36 ++-- .../Services/WebhookExecutorService.cs | 154 ++++++--------- .../Services/WebhookRateLimitService.cs | 6 +- PluralKit.Bot/Utils/ContextUtils.cs | 139 +++++++------- PluralKit.Bot/Utils/DiscordUtils.cs | 79 ++++++-- PluralKit.Bot/Utils/MiscUtils.cs | 5 +- PluralKit.Bot/Utils/StringUtils.cs | 23 ++- 18 files changed, 543 insertions(+), 538 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index ff4b4fdd..6764b5e6 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -7,8 +8,10 @@ using App.Metrics; using Autofac; -using Discord; -using Discord.WebSocket; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; + using Microsoft.Extensions.Configuration; using PluralKit.Core; @@ -61,7 +64,6 @@ namespace PluralKit.Bot SchemaService.Initialize(); var coreConfig = services.Resolve(); - var botConfig = services.Resolve(); var schema = services.Resolve(); using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl); @@ -71,10 +73,9 @@ namespace PluralKit.Bot logger.Information("Connecting to Discord"); var client = services.Resolve(); - await client.LoginAsync(TokenType.Bot, botConfig.Token); - - logger.Information("Initializing bot"); await client.StartAsync(); + + logger.Information("Initializing bot"); await services.Resolve().Init(); try @@ -105,10 +106,10 @@ namespace PluralKit.Bot private WebhookRateLimitService _webhookRateLimit; private int _periodicUpdateCount; - public Bot(ILifetimeScope services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit) + public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit) { _services = services; - _client = client as DiscordShardedClient; + _client = client; _metrics = metrics; _collector = collector; _webhookRateLimit = webhookRateLimit; @@ -117,53 +118,51 @@ namespace PluralKit.Bot public Task Init() { - _client.ShardDisconnected += ShardDisconnected; - _client.ShardReady += ShardReady; - _client.Log += FrameworkLog; + // _client.ShardDisconnected += ShardDisconnected; + // _client.ShardReady += ShardReady; + _client.DebugLogger.LogMessageReceived += FrameworkLog; - _client.MessageReceived += (msg) => HandleEvent(eh => eh.HandleMessage(msg)); - _client.ReactionAdded += (msg, channel, reaction) => HandleEvent(eh => eh.HandleReactionAdded(msg, channel, reaction)); - _client.MessageDeleted += (msg, channel) => HandleEvent(eh => eh.HandleMessageDeleted(msg, channel)); - _client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(eh => eh.HandleMessagesBulkDelete(msgs, channel)); - _client.MessageUpdated += (oldMessage, newMessage, channel) => HandleEvent(eh => eh.HandleMessageEdited(oldMessage, newMessage, channel)); + _client.MessageCreated += args => HandleEvent(eh => eh.HandleMessage(args)); + _client.MessageReactionAdded += args => HandleEvent(eh => eh.HandleReactionAdded(args)); + _client.MessageDeleted += args => HandleEvent(eh => eh.HandleMessageDeleted(args)); + _client.MessagesBulkDeleted += args => HandleEvent(eh => eh.HandleMessagesBulkDelete(args)); + _client.MessageUpdated += args => HandleEvent(eh => eh.HandleMessageEdited(args)); _services.Resolve().Init(_client); return Task.CompletedTask; } - private Task ShardDisconnected(Exception ex, DiscordSocketClient shard) + /*private Task ShardDisconnected(Exception ex, DiscordSocketClient shard) { _logger.Warning(ex, $"Shard #{shard.ShardId} disconnected"); return Task.CompletedTask; - } + }*/ - private Task FrameworkLog(LogMessage msg) + private void FrameworkLog(object sender, DebugLogMessageEventArgs args) { - // Bridge D.NET logging to Serilog + // Bridge D#+ logging to Serilog LogEventLevel level = LogEventLevel.Verbose; - if (msg.Severity == LogSeverity.Critical) + if (args.Level == LogLevel.Critical) level = LogEventLevel.Fatal; - else if (msg.Severity == LogSeverity.Debug) + else if (args.Level == LogLevel.Debug) level = LogEventLevel.Debug; - else if (msg.Severity == LogSeverity.Error) + else if (args.Level == LogLevel.Error) level = LogEventLevel.Error; - else if (msg.Severity == LogSeverity.Info) + else if (args.Level == LogLevel.Info) level = LogEventLevel.Information; - else if (msg.Severity == LogSeverity.Debug) // D.NET's lowest level is Debug and Verbose is greater, Serilog's is the other way around - level = LogEventLevel.Verbose; - else if (msg.Severity == LogSeverity.Verbose) - level = LogEventLevel.Debug; + else if (args.Level == LogLevel.Warning) + level = LogEventLevel.Warning; - _logger.Write(level, msg.Exception, "Discord.Net {Source}: {Message}", msg.Source, msg.Message); - return Task.CompletedTask; + _logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message); } // Method called every 60 seconds private async Task UpdatePeriodic() { // Change bot status - await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers"); + var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); + await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); // Run webhook rate limit GC every 10 minutes if (_periodicUpdateCount++ % 10 == 0) @@ -177,7 +176,7 @@ namespace PluralKit.Bot await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); } - private Task ShardReady(DiscordSocketClient shardClient) + /*private Task ShardReady(DiscordSocketClient shardClient) { _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count); @@ -191,7 +190,7 @@ namespace PluralKit.Bot } return Task.CompletedTask; - } + }*/ private Task HandleEvent(Func handler) { @@ -252,7 +251,7 @@ namespace PluralKit.Bot // This means that the HandleMessage function will either be called once, or not at all // The ReportError function will be called on an error, and needs to refer back to the "trigger message" // hence, we just store it in a local variable, ignoring it entirely if it's null. - private IUserMessage _msg = null; + private DiscordMessage _currentlyHandlingMessage = null; public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, DiscordShardedClient client, DbConnectionFactory connectionFactory, ILifetimeScope services, CommandTree tree, Scope sentryScope, ProxyCache cache, LastMessageCacheService lastMessageCache, LoggerCleanService loggerClean) { @@ -269,42 +268,44 @@ namespace PluralKit.Bot _loggerClean = loggerClean; } - public async Task HandleMessage(SocketMessage arg) + public async Task HandleMessage(MessageCreateEventArgs args) { - var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild); + // TODO + /*var shard = _client.GetShardFor((arg.Channel as IGuildChannel)?.Guild); if (shard.ConnectionState != ConnectionState.Connected || _client.CurrentUser == null) - return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts + return; // Discard messages while the bot "catches up" to avoid unnecessary CPU pressure causing timeouts*/ - RegisterMessageMetrics(arg); + RegisterMessageMetrics(args); // Ignore system messages (member joined, message pinned, etc) - var msg = arg as SocketUserMessage; - if (msg == null) return; + var msg = args.Message; + if (msg.MessageType != MessageType.Default) return; // Fetch information about the guild early, as we need it for the logger cleanup - GuildConfig cachedGuild = default; // todo: is this default correct? - if (msg.Channel is ITextChannel textChannel) cachedGuild = await _cache.GetGuildDataCached(textChannel.GuildId); + GuildConfig cachedGuild = default; + if (msg.Channel.Type == ChannelType.Text) await _cache.GetGuildDataCached(msg.Channel.GuildId); // Pass guild bot/WH messages onto the logger cleanup service, but otherwise ignore - if ((msg.Author.IsBot || msg.Author.IsWebhook) && msg.Channel is ITextChannel) + if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text) { - await _loggerClean.HandleLoggerBotCleanup(arg, cachedGuild); + await _loggerClean.HandleLoggerBotCleanup(msg, cachedGuild); return; } + + _currentlyHandlingMessage = msg; // Add message info as Sentry breadcrumb - _msg = msg; _sentryScope.AddBreadcrumb(msg.Content, "event.message", data: new Dictionary { {"user", msg.Author.Id.ToString()}, {"channel", msg.Channel.Id.ToString()}, - {"guild", ((msg.Channel as IGuildChannel)?.GuildId ?? 0).ToString()}, + {"guild", msg.Channel.GuildId.ToString()}, {"message", msg.Id.ToString()}, }); - _sentryScope.SetTag("shard", shard.ShardId.ToString()); + _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); // Add to last message cache - _lastMessageCache.AddMessage(arg.Channel.Id, arg.Id); + _lastMessageCache.AddMessage(msg.Channel.Id, msg.Id); // We fetch information about the sending account from the cache var cachedAccount = await _cache.GetAccountDataCached(msg.Author.Id); @@ -330,7 +331,7 @@ namespace PluralKit.Bot try { - await _tree.ExecuteCommand(new Context(_services, msg, argPos, cachedAccount?.System)); + await _tree.ExecuteCommand(new Context(_services, args.Client, msg, argPos, cachedAccount?.System)); } catch (PKError) { @@ -345,12 +346,12 @@ namespace PluralKit.Bot // no data = no account = no system = no proxy! try { - await _proxy.HandleMessageAsync(cachedGuild, cachedAccount, msg, doAutoProxy: true); + await _proxy.HandleMessageAsync(args.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true); } catch (PKError e) { - if (arg.Channel.HasPermission(ChannelPermission.SendMessages)) - await arg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}"); + if (msg.Channel.Guild == null || msg.Channel.BotHasPermission(Permissions.SendMessages)) + await msg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}"); } } } @@ -358,98 +359,95 @@ namespace PluralKit.Bot public async Task ReportError(SentryEvent evt, Exception exc) { // If we don't have a "trigger message", bail - if (_msg == null) return; + if (_currentlyHandlingMessage == null) return; // This function *specifically* handles reporting a command execution error to the user. // We'll fetch the event ID and send a user-facing error message. // ONLY IF this error's actually our problem. As for what defines an error as "our problem", // check the extension method :) - if (exc.IsOurProblem() && _msg.Channel.HasPermission(ChannelPermission.SendMessages)) + if (exc.IsOurProblem() && _currentlyHandlingMessage.Channel.BotHasPermission(Permissions.SendMessages)) { var eid = evt.EventId; - await _msg.Channel.SendMessageAsync( + await _currentlyHandlingMessage.Channel.SendMessageAsync( $"{Emojis.Error} Internal error occurred. Please join the support server (), and send the developer this ID: `{eid}`\nBe sure to include a description of what you were doing to make the error occur."); } // If not, don't care. lol. } - private void RegisterMessageMetrics(SocketMessage msg) + private void RegisterMessageMetrics(MessageCreateEventArgs msg) { _metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived); - var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt; + var gatewayLatency = DateTimeOffset.Now - msg.Message.Timestamp; _logger.Verbose("Message received with latency {Latency}", gatewayLatency); } - public Task HandleReactionAdded(Cacheable message, ISocketMessageChannel channel, - SocketReaction reaction) + public Task HandleReactionAdded(MessageReactionAddEventArgs args) { _sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary() { - {"user", reaction.UserId.ToString()}, - {"channel", channel.Id.ToString()}, - {"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()}, - {"message", message.Id.ToString()}, - {"reaction", reaction.Emote.Name} + {"user", args.User.Id.ToString()}, + {"channel", (args.Channel?.Id ?? 0).ToString()}, + {"guild", (args.Channel?.GuildId ?? 0).ToString()}, + {"message", args.Message.Id.ToString()}, + {"reaction", args.Emoji.Name} }); - _sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString()); - - return _proxy.HandleReactionAddedAsync(message, channel, reaction); + _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); + return _proxy.HandleReactionAddedAsync(args); } - public Task HandleMessageDeleted(Cacheable message, ISocketMessageChannel channel) + public Task HandleMessageDeleted(MessageDeleteEventArgs args) { _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() { - {"channel", channel.Id.ToString()}, - {"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()}, - {"message", message.Id.ToString()}, + {"channel", args.Channel.Id.ToString()}, + {"guild", args.Channel.GuildId.ToString()}, + {"message", args.Message.Id.ToString()}, }); - _sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString()); + _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - return _proxy.HandleMessageDeletedAsync(message, channel); + return _proxy.HandleMessageDeletedAsync(args); } - public Task HandleMessagesBulkDelete(IReadOnlyCollection> messages, - IMessageChannel channel) + public Task HandleMessagesBulkDelete(MessageBulkDeleteEventArgs args) { _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary() { - {"channel", channel.Id.ToString()}, - {"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()}, - {"messages", string.Join(",", messages.Select(m => m.Id))}, + {"channel", args.Channel.Id.ToString()}, + {"guild", args.Channel.Id.ToString()}, + {"messages", string.Join(",", args.Messages.Select(m => m.Id))}, }); - _sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString()); + _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); - return _proxy.HandleMessageBulkDeleteAsync(messages, channel); + return _proxy.HandleMessageBulkDeleteAsync(args); } - public async Task HandleMessageEdited(Cacheable oldMessage, SocketMessage newMessage, ISocketMessageChannel channel) + public async Task HandleMessageEdited(MessageUpdateEventArgs args) { - _sentryScope.AddBreadcrumb(newMessage.Content, "event.messageEdit", data: new Dictionary() + _sentryScope.AddBreadcrumb(args.Message.Content ?? "", "event.messageEdit", data: new Dictionary() { - {"channel", channel.Id.ToString()}, - {"guild", ((channel as IGuildChannel)?.GuildId ?? 0).ToString()}, - {"message", newMessage.Id.ToString()} + {"channel", args.Channel.Id.ToString()}, + {"guild", args.Channel.GuildId.ToString()}, + {"message", args.Message.Id.ToString()} }); - _sentryScope.SetTag("shard", _client.GetShardIdFor((channel as IGuildChannel)?.Guild).ToString()); + _sentryScope.SetTag("shard", args.Client.ShardId.ToString()); // If this isn't a guild, bail - if (!(channel is IGuildChannel gc)) return; + if (args.Channel.Guild == null) return; // If this isn't the last message in the channel, don't do anything - if (_lastMessageCache.GetLastMessage(channel.Id) != newMessage.Id) return; + if (_lastMessageCache.GetLastMessage(args.Channel.Id) != args.Message.Id) return; // Fetch account from cache if there is any - var account = await _cache.GetAccountDataCached(newMessage.Author.Id); + var account = await _cache.GetAccountDataCached(args.Author.Id); if (account == null) return; // Again: no cache = no account = no system = no proxy // Also fetch guild cache - var guild = await _cache.GetGuildDataCached(gc.GuildId); + var guild = await _cache.GetGuildDataCached(args.Channel.GuildId); // Just run the normal message handling stuff - await _proxy.HandleMessageAsync(guild, account, newMessage, doAutoProxy: false); + await _proxy.HandleMessageAsync(args.Client, guild, account, args.Message, doAutoProxy: false); } } } diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 710e8f22..1a710c84 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -9,6 +9,9 @@ using Autofac; using Discord; using Discord.WebSocket; +using DSharpPlus; +using DSharpPlus.Entities; + using PluralKit.Core; namespace PluralKit.Bot @@ -18,7 +21,8 @@ namespace PluralKit.Bot private ILifetimeScope _provider; private readonly DiscordShardedClient _client; - private readonly SocketUserMessage _message; + private readonly DiscordClient _shard; + private readonly DiscordMessage _message; private readonly Parameters _parameters; private readonly IDataStore _data; @@ -27,11 +31,12 @@ namespace PluralKit.Bot private Command _currentCommand; - public Context(ILifetimeScope provider, SocketUserMessage message, int commandParseOffset, + public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset, PKSystem senderSystem) { _client = provider.Resolve(); _message = message; + _shard = shard; _data = provider.Resolve(); _senderSystem = senderSystem; _metrics = provider.Resolve(); @@ -39,11 +44,11 @@ namespace PluralKit.Bot _parameters = new Parameters(message.Content.Substring(commandParseOffset)); } - public IUser Author => _message.Author; - public IMessageChannel Channel => _message.Channel; - public IUserMessage Message => _message; - public IGuild Guild => (_message.Channel as ITextChannel)?.Guild; - public DiscordSocketClient Shard => _client.GetShardFor(Guild); + public DiscordUser Author => _message.Author; + public DiscordChannel Channel => _message.Channel; + public DiscordMessage Message => _message; + public DiscordGuild Guild => _message.Channel.Guild; + public DiscordClient Shard => _shard; public DiscordShardedClient Client => _client; public PKSystem System => _senderSystem; @@ -53,13 +58,13 @@ namespace PluralKit.Bot public bool HasNext(bool skipFlags = true) => RemainderOrNull(skipFlags) != null; public string FullCommand => _parameters.FullCommand; - public Task Reply(string text = null, Embed embed = null) + public Task Reply(string text = null, DiscordEmbed embed = null) { - if (!this.BotHasPermission(ChannelPermission.SendMessages)) + if (!this.BotHasPermission(Permissions.SendMessages)) // Will be "swallowed" during the error handler anyway, this message is never shown. throw new PKError("PluralKit does not have permission to send messages in this channel."); - if (embed != null && !this.BotHasPermission(ChannelPermission.EmbedLinks)) + if (embed != null && !this.BotHasPermission(Permissions.EmbedLinks)) throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled."); return Channel.SendMessageAsync(text, embed: embed); @@ -125,11 +130,11 @@ namespace PluralKit.Bot } } - public async Task MatchUser() + public async Task MatchUser() { var text = PeekArgument(); - if (MentionUtils.TryParseUser(text, out var id)) - return await Shard.Rest.GetUserAsync(id); // TODO: this should properly fetch + if (text.TryParseMention(out var id)) + return await Shard.GetUserAsync(id); return null; } @@ -138,11 +143,9 @@ namespace PluralKit.Bot id = 0; var text = PeekArgument(); - if (MentionUtils.TryParseUser(text, out var mentionId)) + if (text.TryParseMention(out var mentionId)) id = mentionId; - else if (ulong.TryParse(text, out var rawId)) - id = rawId; - + return id != 0; } @@ -246,41 +249,19 @@ namespace PluralKit.Bot return this; } - public GuildPermissions GetGuildPermissions(IUser user) + public Context CheckAuthorPermission(Permissions neededPerms, string permissionName) { - if (user is IGuildUser gu) - return gu.GuildPermissions; - if (Channel is SocketGuildChannel gc) - return gc.GetUser(user.Id).GuildPermissions; - return GuildPermissions.None; - } - - public ChannelPermissions GetChannelPermissions(IUser user) - { - if (user is IGuildUser gu && Channel is IGuildChannel igc) - return gu.GetPermissions(igc); - if (Channel is SocketGuildChannel gc) - return gc.GetUser(user.Id).GetPermissions(gc); - return ChannelPermissions.DM; - } - - public Context CheckAuthorPermission(GuildPermission permission, string permissionName) - { - if (!GetGuildPermissions(Author).Has(permission)) - throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command."); - return this; - } - - public Context CheckAuthorPermission(ChannelPermission permission, string permissionName) - { - if (!GetChannelPermissions(Author).Has(permission)) + // TODO: can we always assume Author is a DiscordMember? I would think so, given they always come from a + // message received event... + var hasPerms = Channel.PermissionsInSync(Author); + if ((hasPerms & neededPerms) != neededPerms) throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command."); return this; } public Context CheckGuildContext() { - if (Channel is IGuildChannel) return this; + if (Channel.Guild != null) return this; throw new PKError("This command can not be run in a DM."); } @@ -296,10 +277,10 @@ namespace PluralKit.Bot throw new PKError("You do not have permission to access this information."); } - public ITextChannel MatchChannel() + public DiscordChannel MatchChannel() { if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null; - if (!(_client.GetChannel(channel) is ITextChannel textChannel)) return null; + if (!(_client.GetChannelAsync(channel) is ITextChannel textChannel)) return null; PopArgument(); return textChannel; diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index e4e0c67b..2581a7d0 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -3,9 +3,7 @@ using System.Net.Http; using Autofac; -using Discord; -using Discord.Rest; -using Discord.WebSocket; +using DSharpPlus; using PluralKit.Core; @@ -18,18 +16,12 @@ namespace PluralKit.Bot protected override void Load(ContainerBuilder builder) { // Client - builder.Register(c => new DiscordShardedClient(new DiscordSocketConfig() - { - MessageCacheSize = 0, - ConnectionTimeout = 2 * 60 * 1000, - ExclusiveBulkDelete = true, - LargeThreshold = 50, - GuildSubscriptions = false, - DefaultRetryMode = RetryMode.RetryTimeouts | RetryMode.RetryRatelimit - // Commented this out since Debug actually sends, uh, quite a lot that's not necessary in production - // but leaving it here in case I (or someone else) get[s] confused about why logging isn't working again :p - // LogLevel = LogSeverity.Debug // We filter log levels in Serilog, so just pass everything through (Debug is lower than Verbose) - })).AsSelf().As().As().As().SingleInstance(); + builder.Register(c => new DiscordShardedClient(new DiscordConfiguration + { + Token = c.Resolve().Token, + TokenType = TokenType.Bot, + MessageCacheSize = 0, + })).AsSelf().SingleInstance(); // Commands builder.RegisterType().AsSelf(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 0daa2514..f56c7d05 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -6,7 +6,6 @@ - diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 2124e2cf..7be34979 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -2,8 +2,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Discord; -using Discord.WebSocket; + +using DSharpPlus; +using DSharpPlus.Entities; using Humanizer; using NodaTime; @@ -22,15 +23,15 @@ namespace PluralKit.Bot { _data = data; } - public async Task CreateSystemEmbed(PKSystem system, LookupContext ctx) { + public async Task CreateSystemEmbed(DiscordClient client, PKSystem system, LookupContext ctx) { var accounts = await _data.GetSystemAccounts(system); // Fetch/render info for all accounts simultaneously - var users = await Task.WhenAll(accounts.Select(async uid => (await _client.Rest.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})")); + var users = await Task.WhenAll(accounts.Select(async uid => (await client.GetUserAsync(uid))?.NameAndMention() ?? $"(deleted account {uid})")); var memberCount = await _data.GetSystemMemberCount(system, false); - var eb = new EmbedBuilder() - .WithColor(Color.Blue) + var eb = new DiscordEmbedBuilder() + .WithColor(DiscordColor.Blue) .WithTitle(system.Name ?? null) .WithThumbnailUrl(system.AvatarUrl ?? null) .WithFooter($"System ID: {system.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}"); @@ -61,33 +62,33 @@ namespace PluralKit.Bot { return eb.Build(); } - public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IUser sender, string content, IGuildChannel channel) { + public DiscordEmbed CreateLoggedMessageEmbed(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordUser sender, string content, DiscordChannel channel) { // TODO: pronouns in ?-reacted response using this card - var timestamp = SnowflakeUtils.FromSnowflake(messageId); - return new EmbedBuilder() + var timestamp = DiscordUtils.SnowflakeToInstant(messageId); + return new DiscordEmbedBuilder() .WithAuthor($"#{channel.Name}: {member.Name}", member.AvatarUrl) .WithDescription(content?.NormalizeLineEndSpacing()) .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: {sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: {messageId} | Original Message ID: {originalMsgId}") - .WithTimestamp(timestamp) + .WithTimestamp(timestamp.ToDateTimeOffset()) .Build(); } - public async Task CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild, LookupContext ctx) + public async Task CreateMemberEmbed(PKSystem system, PKMember member, DiscordGuild guild, LookupContext ctx) { var name = member.Name; if (system.Name != null) name = $"{member.Name} ({system.Name})"; - Color color; + DiscordColor color; try { - color = member.Color?.ToDiscordColor() ?? Color.Default; + color = member.Color?.ToDiscordColor() ?? DiscordColor.Gray; } catch (ArgumentException) { // Bad API use can cause an invalid color string // TODO: fix that in the API // for now we just default to a blank color, yolo - color = Color.Default; + color = DiscordColor.Gray; } var messageCount = await _data.GetMemberMessageCount(member); @@ -98,10 +99,10 @@ namespace PluralKit.Bot { var proxyTagsStr = string.Join('\n', member.ProxyTags.Select(t => $"`{t.ProxyString}`")); - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() // TODO: add URL of website when that's up .WithAuthor(name, avatar) - .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : Color.Default) + .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordColor.Gray) .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}"); var description = ""; @@ -119,7 +120,7 @@ namespace PluralKit.Bot { if (guild != null && guildDisplayName != null) eb.AddField($"Server Nickname (for {guild.Name})", guildDisplayName.Truncate(1024), true); if (member.Birthday != null && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Birthdate", member.BirthdayString, true); if (!member.Pronouns.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Pronouns", member.Pronouns.Truncate(1024), true); - if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount, true); + if (messageCount > 0 && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Message Count", messageCount.ToString(), true); if (member.HasProxyTags) eb.AddField("Proxy Tags", string.Join('\n', proxyTagsStr).Truncate(1024), true); if (!member.Color.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Color", $"#{member.Color}", true); if (!member.Description.EmptyOrNull() && member.MemberPrivacy.CanAccess(ctx)) eb.AddField("Description", member.Description.NormalizeLineEndSpacing(), false); @@ -127,48 +128,45 @@ namespace PluralKit.Bot { return eb.Build(); } - public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone) + public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone) { var members = await _data.GetSwitchMembers(sw).ToListAsync(); var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; - return new EmbedBuilder() - .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? Color.Blue) + return new DiscordEmbedBuilder() + .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordColor.Blue) .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") .AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)") .Build(); } - public async Task CreateMessageInfoEmbed(FullMessage msg) + public async Task CreateMessageInfoEmbed(DiscordClient client, FullMessage msg) { - var channel = _client.GetChannel(msg.Message.Channel) as ITextChannel; + var channel = await client.GetChannelAsync(msg.Message.Channel); var serverMsg = channel != null ? await channel.GetMessageAsync(msg.Message.Mid) : null; var memberStr = $"{msg.Member.Name} (`{msg.Member.Hid}`)"; var userStr = $"*(deleted user {msg.Message.Sender})*"; - ICollection roles = null; + ICollection roles = null; if (channel != null) { // Look up the user with the REST client // this ensures we'll still get the information even if the user's not cached, // even if this means an extra API request (meh, it'll be fine) - var shard = _client.GetShardFor(channel.Guild); - var guildUser = await shard.Rest.GetGuildUserAsync(channel.Guild.Id, msg.Message.Sender); + var guildUser = await channel.Guild.GetMemberAsync(msg.Message.Sender); if (guildUser != null) { - if (guildUser.RoleIds.Count > 0) - roles = guildUser.RoleIds - .Select(roleId => channel.Guild.GetRole(roleId)) - .Where(role => role.Name != "@everyone") - .OrderByDescending(role => role.Position) - .ToList(); + roles = guildUser.Roles + .Where(role => role.Name != "@everyone") + .OrderByDescending(role => role.Position) + .ToList(); - userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser?.NameAndMention(); + userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser.NameAndMention(); } } - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithAuthor(msg.Member.Name, msg.Member.AvatarUrl) .WithDescription(serverMsg?.Content?.NormalizeLineEndSpacing() ?? "*(message contents deleted or inaccessible)*") .WithImageUrl(serverMsg?.Attachments?.FirstOrDefault()?.Url) @@ -176,18 +174,18 @@ namespace PluralKit.Bot { msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true) .AddField("Member", memberStr, true) .AddField("Sent by", userStr, inline: true) - .WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid)); + .WithTimestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset()); if (roles != null && roles.Count > 0) eb.AddField($"Account roles ({roles.Count})", string.Join(", ", roles.Select(role => role.Name))); return eb.Build(); } - public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz) + public Task CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz) { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; - var eb = new EmbedBuilder() - .WithColor(Color.Blue) + var eb = new DiscordEmbedBuilder() + .WithColor(DiscordColor.Blue) .WithFooter($"Since {DateTimeFormats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({DateTimeFormats.DurationFormat.Format(actualPeriod)} ago)"); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" diff --git a/PluralKit.Bot/Services/LastMessageCacheService.cs b/PluralKit.Bot/Services/LastMessageCacheService.cs index bc08b717..2ae108e0 100644 --- a/PluralKit.Bot/Services/LastMessageCacheService.cs +++ b/PluralKit.Bot/Services/LastMessageCacheService.cs @@ -7,6 +7,7 @@ namespace PluralKit.Bot // not particularly efficient? It allocates a dictionary *and* a queue for every single channel (500k in prod!) // whereas this is, worst case, one dictionary *entry* of a single ulong per channel, and one dictionary instance // on the whole instance, total. Yeah, much more efficient. + // TODO: is this still needed after the D#+ migration? public class LastMessageCacheService { private IDictionary _cache = new ConcurrentDictionary(); diff --git a/PluralKit.Bot/Services/LogChannelService.cs b/PluralKit.Bot/Services/LogChannelService.cs index 7e6389a1..3eaa0136 100644 --- a/PluralKit.Bot/Services/LogChannelService.cs +++ b/PluralKit.Bot/Services/LogChannelService.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; -using Discord; +using DSharpPlus; +using DSharpPlus.Entities; using PluralKit.Core; @@ -8,20 +9,18 @@ using Serilog; namespace PluralKit.Bot { public class LogChannelService { - private IDiscordClient _client; private EmbedService _embed; private IDataStore _data; private ILogger _logger; - public LogChannelService(IDiscordClient client, EmbedService embed, ILogger logger, IDataStore data) + public LogChannelService(EmbedService embed, ILogger logger, IDataStore data) { - _client = client; _embed = embed; _data = data; _logger = logger.ForContext(); } - public async Task LogMessage(PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, IGuildChannel originalChannel, IUser sender, string content, GuildConfig? guildCfg = null) + public async Task LogMessage(DiscordClient client, PKSystem system, PKMember member, ulong messageId, ulong originalMsgId, DiscordChannel originalChannel, DiscordUser sender, string content, GuildConfig? guildCfg = null) { if (guildCfg == null) guildCfg = await _data.GetOrCreateGuildConfig(originalChannel.GuildId); @@ -31,17 +30,19 @@ namespace PluralKit.Bot { if (guildCfg.Value.LogBlacklist.Contains(originalChannel.Id)) return; // Bail if we can't find the channel - if (!(await _client.GetChannelAsync(guildCfg.Value.LogChannel.Value) is ITextChannel logChannel)) return; + var channel = await client.GetChannelAsync(guildCfg.Value.LogChannel.Value); + if (channel == null || channel.Type != ChannelType.Text) return; // Bail if we don't have permission to send stuff here - if (!logChannel.HasPermission(ChannelPermission.SendMessages) || !logChannel.HasPermission(ChannelPermission.EmbedLinks)) + var neededPermissions = Permissions.SendMessages | Permissions.EmbedLinks; + if ((channel.BotPermissions() & neededPermissions) != neededPermissions) return; var embed = _embed.CreateLoggedMessageEmbed(system, member, messageId, originalMsgId, sender, content, originalChannel); var url = $"https://discordapp.com/channels/{originalChannel.GuildId}/{originalChannel.Id}/{messageId}"; - await logChannel.SendMessageAsync(text: url, embed: embed); + await channel.SendMessageAsync(content: url, embed: embed); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/LoggerCleanService.cs b/PluralKit.Bot/Services/LoggerCleanService.cs index 06d035c9..29563335 100644 --- a/PluralKit.Bot/Services/LoggerCleanService.cs +++ b/PluralKit.Bot/Services/LoggerCleanService.cs @@ -6,8 +6,8 @@ using System.Threading.Tasks; using Dapper; -using Discord; -using Discord.WebSocket; +using DSharpPlus; +using DSharpPlus.Entities; using PluralKit.Core; @@ -61,18 +61,18 @@ namespace PluralKit.Bot public ICollection Bots => _bots.Values; - public async ValueTask HandleLoggerBotCleanup(SocketMessage msg, GuildConfig cachedGuild) + public async ValueTask HandleLoggerBotCleanup(DiscordMessage msg, GuildConfig cachedGuild) { // Bail if not enabled, or if we don't have permission here if (!cachedGuild.LogCleanupEnabled) return; - if (!(msg.Channel is SocketTextChannel channel)) return; - if (!channel.Guild.GetUser(_client.CurrentUser.Id).GetPermissions(channel).ManageMessages) return; + if (msg.Channel.Type != ChannelType.Text) return; + if (!msg.Channel.BotHasPermission(Permissions.ManageMessages)) return; // If this message is from a *webhook*, check if the name matches one of the bots we know // TODO: do we need to do a deeper webhook origin check, or would that be too hard on the rate limit? // If it's from a *bot*, check the bot ID to see if we know it. LoggerBot bot = null; - if (msg.Author.IsWebhook) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot); + if (msg.WebhookMessage) _botsByWebhookName.TryGetValue(msg.Author.Username, out bot); else if (msg.Author.IsBot) _bots.TryGetValue(msg.Author.Id, out bot); // If we didn't find anything before, or what we found is an unsupported bot, bail @@ -95,8 +95,8 @@ namespace PluralKit.Bot new { fuzzy.Value.User, - Guild = (msg.Channel as ITextChannel)?.GuildId ?? 0, - ApproxId = SnowflakeUtils.ToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3)) + Guild = msg.Channel.GuildId, + ApproxId = DiscordUtils.InstantToSnowflake(fuzzy.Value.ApproxTimestamp - TimeSpan.FromSeconds(3)) }); if (mid == null) return; // If we didn't find a corresponding message, bail // Otherwise, we can *reasonably assume* that this is a logged deletion, so delete the log message. @@ -118,7 +118,7 @@ namespace PluralKit.Bot } // else should not happen, but idk, it might } - private static ulong? ExtractAuttaja(SocketMessage msg) + private static ulong? ExtractAuttaja(DiscordMessage msg) { // Auttaja has an optional "compact mode" that logs without embeds // That one puts the ID in the message content, non-compact puts it in the embed description. @@ -130,16 +130,16 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractDyno(SocketMessage msg) + private static ulong? ExtractDyno(DiscordMessage msg) { // Embed *description* contains "Message sent by [mention] deleted in [channel]", contains message ID in footer per regex var embed = msg.Embeds.FirstOrDefault(); if (embed?.Footer == null || !(embed.Description?.Contains("deleted in") ?? false)) return null; - var match = _dynoRegex.Match(embed.Footer.Value.Text ?? ""); + var match = _dynoRegex.Match(embed.Footer.Text ?? ""); return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractLoggerA(SocketMessage msg) + private static ulong? ExtractLoggerA(DiscordMessage msg) { // This is for Logger#6088 (298822483060981760), distinct from Logger#6278 (327424261180620801). // Embed contains title "Message deleted in [channel]", and an ID field containing both message and user ID (see regex). @@ -153,26 +153,26 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractLoggerB(SocketMessage msg) + private static ulong? ExtractLoggerB(DiscordMessage msg) { // This is for Logger#6278 (327424261180620801), distinct from Logger#6088 (298822483060981760). // Embed title ends with "A Message Was Deleted!", footer contains message ID as per regex. var embed = msg.Embeds.FirstOrDefault(); if (embed?.Footer == null || !(embed.Title?.EndsWith("A Message Was Deleted!") ?? false)) return null; - var match = _loggerBRegex.Match(embed.Footer.Value.Text ?? ""); + var match = _loggerBRegex.Match(embed.Footer.Text ?? ""); return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractGenericBot(SocketMessage msg) + private static ulong? ExtractGenericBot(DiscordMessage msg) { // Embed, title is "Message Deleted", ID plain in footer. var embed = msg.Embeds.FirstOrDefault(); if (embed?.Footer == null || !(embed.Title?.Contains("Message Deleted") ?? false)) return null; - var match = _basicRegex.Match(embed.Footer.Value.Text ?? ""); + var match = _basicRegex.Match(embed.Footer.Text ?? ""); return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractBlargBot(SocketMessage msg) + private static ulong? ExtractBlargBot(DiscordMessage msg) { // Embed, title ends with "Message Deleted", contains ID plain in a field. var embed = msg.Embeds.FirstOrDefault(); @@ -182,7 +182,7 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static ulong? ExtractMantaro(SocketMessage msg) + private static ulong? ExtractMantaro(DiscordMessage msg) { // Plain message, "Message (ID: [id]) created by [user] (ID: [id]) in channel [channel] was deleted. if (!(msg.Content?.Contains("was deleted.") ?? false)) return null; @@ -190,19 +190,19 @@ namespace PluralKit.Bot return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static FuzzyExtractResult? ExtractCarlBot(SocketMessage msg) + private static FuzzyExtractResult? ExtractCarlBot(DiscordMessage msg) { // Embed, title is "Message deleted in [channel], **user** ID in the footer, timestamp as, well, timestamp in embed. // This is the *deletion* timestamp, which we can assume is a couple seconds at most after the message was originally sent var embed = msg.Embeds.FirstOrDefault(); if (embed?.Footer == null || embed.Timestamp == null || !(embed.Title?.StartsWith("Message deleted in") ?? false)) return null; - var match = _carlRegex.Match(embed.Footer.Value.Text ?? ""); + var match = _carlRegex.Match(embed.Footer.Text ?? ""); return match.Success ? new FuzzyExtractResult { User = ulong.Parse(match.Groups[1].Value), ApproxTimestamp = embed.Timestamp.Value } : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractCircle(SocketMessage msg) + private static FuzzyExtractResult? ExtractCircle(DiscordMessage msg) { // Like Auttaja, Circle has both embed and compact modes, but the regex works for both. // Compact: "Message from [user] ([id]) deleted in [channel]", no timestamp (use message time) @@ -211,7 +211,7 @@ namespace PluralKit.Bot if (msg.Embeds.Count > 0) { var embed = msg.Embeds.First(); - if (embed.Author?.Name == null || !embed.Author.Value.Name.StartsWith("Message Deleted in")) return null; + if (embed.Author?.Name == null || !embed.Author.Name.StartsWith("Message Deleted in")) return null; var field = embed.Fields.FirstOrDefault(f => f.Name == "Message Author"); if (field.Value == null) return null; stringWithId = field.Value; @@ -224,7 +224,7 @@ namespace PluralKit.Bot : (FuzzyExtractResult?) null; } - private static FuzzyExtractResult? ExtractPancake(SocketMessage msg) + private static FuzzyExtractResult? ExtractPancake(DiscordMessage msg) { // Embed, author is "Message Deleted", description includes a mention, timestamp is *message send time* (but no ID) // so we use the message timestamp to get somewhere *after* the message was proxied @@ -236,16 +236,16 @@ namespace PluralKit.Bot : (FuzzyExtractResult?) null; } - private static ulong? ExtractUnbelievaBoat(SocketMessage msg) + private static ulong? ExtractUnbelievaBoat(DiscordMessage msg) { // Embed author is "Message Deleted", footer contains message ID per regex var embed = msg.Embeds.FirstOrDefault(); if (embed?.Footer == null || embed.Author?.Name != "Message Deleted") return null; - var match = _unbelievaboatRegex.Match(embed.Footer.Value.Text ?? ""); + var match = _unbelievaboatRegex.Match(embed.Footer.Text ?? ""); return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; } - private static FuzzyExtractResult? ExtractVanessa(SocketMessage msg) + private static FuzzyExtractResult? ExtractVanessa(DiscordMessage msg) { // Title is "Message Deleted", embed description contains mention var embed = msg.Embeds.FirstOrDefault(); @@ -261,11 +261,11 @@ namespace PluralKit.Bot { public string Name; public ulong Id; - public Func ExtractFunc; - public Func FuzzyExtractFunc; + public Func ExtractFunc; + public Func FuzzyExtractFunc; public string WebhookName; - public LoggerBot(string name, ulong id, Func extractFunc = null, Func fuzzyExtractFunc = null, string webhookName = null) + public LoggerBot(string name, ulong id, Func extractFunc = null, Func fuzzyExtractFunc = null, string webhookName = null) { Name = name; Id = id; diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index ce4ac953..10a8b225 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -1,10 +1,13 @@ using System.Collections.Generic; +using System.Data; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using App.Metrics; -using Discord; -using Discord.WebSocket; + +using DSharpPlus; +using DSharpPlus.Entities; + using NodaTime.Extensions; using PluralKit.Core; @@ -27,9 +30,9 @@ namespace PluralKit.Bot private ILogger _logger; - public PeriodicStatCollector(IDiscordClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache) + public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache) { - _client = (DiscordShardedClient) client; + _client = client; _metrics = metrics; _webhookCache = webhookCache; _countHolder = countHolder; @@ -45,18 +48,31 @@ namespace PluralKit.Bot stopwatch.Start(); // Aggregate guild/channel stats - _metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, _client.Guilds.Count); - _metrics.Measure.Gauge.SetValue(BotMetrics.Channels, _client.Guilds.Sum(g => g.TextChannels.Count)); - _metrics.Measure.Gauge.SetValue(BotMetrics.ShardsConnected, _client.Shards.Count(shard => shard.ConnectionState == ConnectionState.Connected)); + + var guildCount = 0; + var channelCount = 0; + // No LINQ today, sorry + foreach (var shard in _client.ShardClients.Values) + { + guildCount += shard.Guilds.Count; + foreach (var guild in shard.Guilds.Values) + foreach (var channel in guild.Channels.Values) + if (channel.Type == ChannelType.Text) + channelCount++; + } + + _metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, guildCount); + _metrics.Measure.Gauge.SetValue(BotMetrics.Channels, channelCount); // Aggregate member stats var usersKnown = new HashSet(); var usersOnline = new HashSet(); - foreach (var guild in _client.Guilds) - foreach (var user in guild.Users) + foreach (var shard in _client.ShardClients.Values) + foreach (var guild in shard.Guilds.Values) + foreach (var user in guild.Members.Values) { usersKnown.Add(user.Id); - if (user.Status == UserStatus.Online) usersOnline.Add(user.Id); + if (user.Presence.Status == UserStatus.Online) usersOnline.Add(user.Id); } _metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count); diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index a4e39f8a..b4a51fd6 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -3,12 +3,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Discord; -using Discord.Net; -using Discord.WebSocket; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using DSharpPlus.Exceptions; using NodaTime; -using NodaTime.Extensions; using PluralKit.Core; @@ -83,16 +83,16 @@ namespace PluralKit.Bot return null; } - public async Task HandleMessageAsync(GuildConfig guild, CachedAccount account, IMessage message, bool doAutoProxy) + public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool doAutoProxy) { // Bail early if this isn't in a guild channel - if (!(message.Channel is ITextChannel channel)) return; + if (message.Channel.Guild != null) return; // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, account.System, account.Members); // O(n) lookup since n is small (max ~100 in prod) and we're more constrained by memory (for a dictionary) here - var systemSettingsForGuild = account.SettingsForGuild(channel.GuildId); + var systemSettingsForGuild = account.SettingsForGuild(message.Channel.GuildId); // If we didn't get a match by proxy tags, try to get one by autoproxy // Also try if we *did* get a match, but there's no inner text. This happens if someone sends a message that @@ -102,26 +102,26 @@ namespace PluralKit.Bot // When a normal message is sent, autoproxy is enabled, but if this method is called from a message *edit* // event, then autoproxy is disabled. This is so AP doesn't "retrigger" when the original message was escaped. if (doAutoProxy && (match == null || (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0))) - match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, channel); + match = await GetAutoproxyMatch(account, systemSettingsForGuild, message, message.Channel); // If we still haven't found any, just yeet if (match == null) return; // And make sure the channel's not blacklisted from proxying. - if (guild.Blacklist.Contains(channel.Id)) return; + if (guild.Blacklist.Contains(message.ChannelId)) return; // Make sure the system hasn't blacklisted the guild either if (!systemSettingsForGuild.ProxyEnabled) return; // We know message.Channel can only be ITextChannel as PK doesn't work in DMs/groups // Afterwards we ensure the bot has the right permissions, otherwise bail early - if (!await EnsureBotPermissions(channel)) return; + if (!await EnsureBotPermissions(message.Channel)) return; // Can't proxy a message with no content and no attachment if (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0) return; - var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, channel.GuildId); + var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, message.Channel.GuildId); // Get variables in order and all var proxyName = match.Member.ProxyName(match.System.Tag, memberSettingsForGuild.DisplayName); @@ -138,19 +138,17 @@ namespace PluralKit.Bot : match.InnerText; // Sanitize @everyone, but only if the original user wouldn't have permission to - messageContents = SanitizeEveryoneMaybe(message, messageContents); + messageContents = await SanitizeEveryoneMaybe(message, messageContents); // Execute the webhook itself - var hookMessageId = await _webhookExecutor.ExecuteWebhook( - channel, - proxyName, avatarUrl, + var hookMessageId = await _webhookExecutor.ExecuteWebhook(message.Channel, proxyName, avatarUrl, messageContents, message.Attachments ); // Store the message in the database, and log it in the log channel (if applicable) - await _data.AddMessage(message.Author.Id, hookMessageId, channel.GuildId, message.Channel.Id, message.Id, match.Member); - await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, guild); + await _data.AddMessage(message.Author.Id, hookMessageId, message.Channel.GuildId, message.Channel.Id, message.Id, match.Member); + await _logChannel.LogMessage(client, match.System, match.Member, hookMessageId, message.Id, message.Channel, message.Author, match.InnerText, guild); // Wait a second or so before deleting the original message await Task.Delay(1000); @@ -159,14 +157,14 @@ namespace PluralKit.Bot { await message.DeleteAsync(); } - catch (HttpException) + catch (NotFoundException) { // If it's already deleted, we just log and swallow the exception _logger.Warning("Attempted to delete already deleted proxy trigger message {Message}", message.Id); } } - private async Task GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, IMessage message, IGuildChannel channel) + private async Task GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, DiscordMessage message, DiscordChannel channel) { // For now we use a backslash as an "escape character", subject to change later if ((message.Content ?? "").TrimStart().StartsWith("\\")) return null; @@ -189,7 +187,7 @@ namespace PluralKit.Bot // If the message is older than 6 hours, ignore it and force the sender to "refresh" a proxy // This can be revised in the future, it's a preliminary value. - var timestamp = SnowflakeUtils.FromSnowflake(msg.Message.Mid).ToInstant(); + var timestamp = DiscordUtils.SnowflakeToInstant(msg.Message.Mid); var timeSince = SystemClock.Instance.GetCurrentInstant() - timestamp; if (timeSince > Duration.FromHours(6)) return null; @@ -214,23 +212,23 @@ namespace PluralKit.Bot }; } - private static string SanitizeEveryoneMaybe(IMessage message, string messageContents) + private static async Task SanitizeEveryoneMaybe(DiscordMessage message, + string messageContents) { - var senderPermissions = ((IGuildUser) message.Author).GetPermissions(message.Channel as IGuildChannel); - if (!senderPermissions.MentionEveryone) return messageContents.SanitizeEveryone(); + var member = await message.Channel.Guild.GetMemberAsync(message.Author.Id); + if ((member.PermissionsIn(message.Channel) & Permissions.MentionEveryone) == 0) return messageContents.SanitizeEveryone(); return messageContents; } - private async Task EnsureBotPermissions(ITextChannel channel) + private async Task EnsureBotPermissions(DiscordChannel channel) { - var guildUser = await channel.Guild.GetCurrentUserAsync(); - var permissions = guildUser.GetPermissions(channel); + var permissions = channel.BotPermissions(); // If we can't send messages at all, just bail immediately. // TODO: can you have ManageMessages and *not* SendMessages? What happens then? - if (!permissions.SendMessages && !permissions.ManageMessages) return false; + if ((permissions & (Permissions.SendMessages | Permissions.ManageMessages)) == 0) return false; - if (!permissions.ManageWebhooks) + if ((permissions & Permissions.ManageWebhooks) == 0) { // todo: PKError-ify these await channel.SendMessageAsync( @@ -238,7 +236,7 @@ namespace PluralKit.Bot return false; } - if (!permissions.ManageMessages) + if ((permissions & Permissions.ManageMessages) == 0) { await channel.SendMessageAsync( $"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message. Please contact a server administrator to remedy this."); @@ -248,121 +246,117 @@ namespace PluralKit.Bot return true; } - public Task HandleReactionAddedAsync(Cacheable message, ISocketMessageChannel channel, SocketReaction reaction) + public Task HandleReactionAddedAsync(MessageReactionAddEventArgs args) { // Dispatch on emoji - switch (reaction.Emote.Name) + switch (args.Emoji.Name) { case "\u274C": // Red X - return HandleMessageDeletionByReaction(message, reaction.UserId); + return HandleMessageDeletionByReaction(args); case "\u2753": // Red question mark case "\u2754": // White question mark - return HandleMessageQueryByReaction(message, channel, reaction.UserId, reaction.Emote); + return HandleMessageQueryByReaction(args); case "\U0001F514": // Bell case "\U0001F6CE": // Bellhop bell case "\U0001F3D3": // Ping pong paddle (lol) case "\u23F0": // Alarm clock case "\u2757": // Exclamation mark - return HandleMessagePingByReaction(message, channel, reaction.UserId, reaction.Emote); + return HandleMessagePingByReaction(args); default: return Task.CompletedTask; } } - private async Task HandleMessagePingByReaction(Cacheable message, - ISocketMessageChannel channel, ulong userWhoReacted, - IEmote reactedEmote) + private async Task HandleMessagePingByReaction(MessageReactionAddEventArgs args) { // Bail in DMs - if (!(channel is SocketGuildChannel gc)) return; + if (args.Channel.Type != ChannelType.Text) return; // Find the message in the DB - var msg = await _data.GetMessage(message.Id); + var msg = await _data.GetMessage(args.Message.Id); if (msg == null) return; // Check if the pinger has permission to ping in this channel - var guildUser = await _client.Rest.GetGuildUserAsync(gc.Guild.Id, userWhoReacted); - var permissions = guildUser.GetPermissions(gc); + var guildUser = await args.Guild.GetMemberAsync(args.User.Id); + var permissions = guildUser.PermissionsIn(args.Channel); - var realMessage = await message.GetOrDownloadAsync(); - // If they don't have Send Messages permission, bail (since PK shouldn't send anything on their behalf) - if (!permissions.SendMessages || !permissions.ViewChannel) return; - - var embed = new EmbedBuilder().WithDescription($"[Jump to pinged message]({realMessage.GetJumpUrl()})"); - await channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{userWhoReacted}>.", embed: embed.Build()); + var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages; + if ((permissions & requiredPerms) != requiredPerms) return; + + var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({args.Message.JumpLink})"); + await args.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{args.User.Id}>.", embed: embed.Build()); // Finally remove the original reaction (if we can) - var user = await _client.Rest.GetUserAsync(userWhoReacted); - if (user != null && realMessage.Channel.HasPermission(ChannelPermission.ManageMessages)) - await realMessage.RemoveReactionAsync(reactedEmote, user); + if (args.Channel.BotHasPermission(Permissions.ManageMessages)) + await args.Message.DeleteReactionAsync(args.Emoji, args.User); } - private async Task HandleMessageQueryByReaction(Cacheable message, - ISocketMessageChannel channel, ulong userWhoReacted, - IEmote reactedEmote) + private async Task HandleMessageQueryByReaction(MessageReactionAddEventArgs args) { - // Find the user who sent the reaction, so we can DM them - var user = await _client.Rest.GetUserAsync(userWhoReacted); - if (user == null) return; - + // Bail if not in guild + if (args.Guild == null) return; + // Find the message in the DB - var msg = await _data.GetMessage(message.Id); + var msg = await _data.GetMessage(args.Message.Id); if (msg == null) return; + + // Get guild member so we can DM + var member = await args.Guild.GetMemberAsync(args.User.Id); // DM them the message card try { - await user.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, (channel as IGuildChannel)?.Guild, LookupContext.ByNonOwner)); - await user.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(msg)); + await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, args.Guild, LookupContext.ByNonOwner)); + await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(args.Client, msg)); } - catch (HttpException e) when (e.DiscordCode == 50007) + catch (BadRequestException) { + // TODO: is this the correct exception // Ignore exception if it means we don't have DM permission to this user // not much else we can do here :/ } // And finally remove the original reaction (if we can) - var msgObj = await message.GetOrDownloadAsync(); - if (msgObj.Channel.HasPermission(ChannelPermission.ManageMessages)) - await msgObj.RemoveReactionAsync(reactedEmote, user); + await args.Message.DeleteReactionAsync(args.Emoji, args.User); } - public async Task HandleMessageDeletionByReaction(Cacheable message, ulong userWhoReacted) + public async Task HandleMessageDeletionByReaction(MessageReactionAddEventArgs args) { + // Bail if we don't have permission to delete + if (!args.Channel.BotHasPermission(Permissions.ManageMessages)) return; + // Find the message in the database - var storedMessage = await _data.GetMessage(message.Id); + var storedMessage = await _data.GetMessage(args.Message.Id); if (storedMessage == null) return; // (if we can't, that's ok, no worries) // Make sure it's the actual sender of that message deleting the message - if (storedMessage.Message.Sender != userWhoReacted) return; + if (storedMessage.Message.Sender != args.User.Id) return; - try { - // Then, fetch the Discord message and delete that - // TODO: this could be faster if we didn't bother fetching it and just deleted it directly - // somehow through REST? - await (await message.GetOrDownloadAsync()).DeleteAsync(); + try + { + await args.Message.DeleteAsync(); } catch (NullReferenceException) { // Message was deleted before we got to it... cool, no problem, lmao } // Finally, delete it from our database. - await _data.DeleteMessage(message.Id); + await _data.DeleteMessage(args.Message.Id); } - public async Task HandleMessageDeletedAsync(Cacheable message, ISocketMessageChannel channel) + public async Task HandleMessageDeletedAsync(MessageDeleteEventArgs args) { // Don't delete messages from the store if they aren't webhooks // Non-webhook messages will never be stored anyway. // If we're not sure (eg. message outside of cache), delete just to be sure. - if (message.HasValue && !message.Value.Author.IsWebhook) return; - await _data.DeleteMessage(message.Id); + if (!args.Message.WebhookMessage) return; + await _data.DeleteMessage(args.Message.Id); } - public async Task HandleMessageBulkDeleteAsync(IReadOnlyCollection> messages, IMessageChannel channel) + public async Task HandleMessageBulkDeleteAsync(MessageBulkDeleteEventArgs args) { - _logger.Information("Bulk deleting {Count} messages in channel {Channel}", messages.Count, channel.Id); - await _data.DeleteMessagesBulk(messages.Select(m => m.Id).ToList()); + _logger.Information("Bulk deleting {Count} messages in channel {Channel}", args.Messages.Count, args.Channel.Id); + await _data.DeleteMessagesBulk(args.Messages.Select(m => m.Id).ToList()); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/ShardInfoService.cs b/PluralKit.Bot/Services/ShardInfoService.cs index fd328019..fa5aa46c 100644 --- a/PluralKit.Bot/Services/ShardInfoService.cs +++ b/PluralKit.Bot/Services/ShardInfoService.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Discord.WebSocket; +using DSharpPlus; using NodaTime; @@ -21,35 +21,36 @@ namespace PluralKit.Bot public void Init(DiscordShardedClient client) { - for (var i = 0; i < client.Shards.Count; i++) + foreach (var i in client.ShardClients.Keys) _shardInfo[i] = new ShardInfo(); - - client.ShardConnected += ShardConnected; - client.ShardDisconnected += ShardDisconnected; - client.ShardReady += ShardReady; - client.ShardLatencyUpdated += ShardLatencyUpdated; + + // TODO + // client.ShardConnected += ShardConnected; + // client.ShardDisconnected += ShardDisconnected; + // client.ShardReady += ShardReady; + // client.ShardLatencyUpdated += ShardLatencyUpdated; } - public ShardInfo GetShardInfo(DiscordSocketClient shard) => _shardInfo[shard.ShardId]; + public ShardInfo GetShardInfo(DiscordClient shard) => _shardInfo[shard.ShardId]; - private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordSocketClient shard) + private Task ShardLatencyUpdated(int oldLatency, int newLatency, DiscordClient shard) { _shardInfo[shard.ShardId].ShardLatency = newLatency; return Task.CompletedTask; } - private Task ShardReady(DiscordSocketClient shard) + private Task ShardReady(DiscordClient shard) { return Task.CompletedTask; } - private Task ShardDisconnected(Exception e, DiscordSocketClient shard) + private Task ShardDisconnected(Exception e, DiscordClient shard) { _shardInfo[shard.ShardId].DisconnectionCount++; return Task.CompletedTask; } - private Task ShardConnected(DiscordSocketClient shard) + private Task ShardConnected(DiscordClient shard) { _shardInfo[shard.ShardId].LastConnectionTime = SystemClock.Instance.GetCurrentInstant(); return Task.CompletedTask; diff --git a/PluralKit.Bot/Services/WebhookCacheService.cs b/PluralKit.Bot/Services/WebhookCacheService.cs index f6cbcdec..873f8eba 100644 --- a/PluralKit.Bot/Services/WebhookCacheService.cs +++ b/PluralKit.Bot/Services/WebhookCacheService.cs @@ -3,8 +3,9 @@ using System.Collections.Concurrent; using System.Linq; using System.Net.Http; using System.Threading.Tasks; -using Discord; -using Discord.WebSocket; + +using DSharpPlus; +using DSharpPlus.Entities; using Serilog; @@ -15,54 +16,55 @@ namespace PluralKit.Bot public static readonly string WebhookName = "PluralKit Proxy Webhook"; private DiscordShardedClient _client; - private ConcurrentDictionary>> _webhooks; + private ConcurrentDictionary>> _webhooks; private ILogger _logger; - public WebhookCacheService(IDiscordClient client, ILogger logger) + public WebhookCacheService(DiscordShardedClient client, ILogger logger) { - _client = client as DiscordShardedClient; + _client = client; _logger = logger.ForContext(); - _webhooks = new ConcurrentDictionary>>(); + _webhooks = new ConcurrentDictionary>>(); } - public async Task GetWebhook(ulong channelId) + public async Task GetWebhook(DiscordClient client, ulong channelId) { - var channel = _client.GetChannel(channelId) as ITextChannel; + var channel = await client.GetChannelAsync(channelId); if (channel == null) return null; + if (channel.Type == ChannelType.Text) return null; return await GetWebhook(channel); } - public async Task GetWebhook(ITextChannel channel) + public async Task GetWebhook(DiscordChannel channel) { // We cache the webhook through a Lazy>, this way we make sure to only create one webhook per channel // If the webhook is requested twice before it's actually been found, the Lazy wrapper will stop the // webhook from being created twice. var lazyWebhookValue = - _webhooks.GetOrAdd(channel.Id, new Lazy>(() => GetOrCreateWebhook(channel))); + _webhooks.GetOrAdd(channel.Id, new Lazy>(() => GetOrCreateWebhook(channel))); // It's possible to "move" a webhook to a different channel after creation // Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one. var webhook = await lazyWebhookValue.Value; - if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(webhook); + if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(channel, webhook); return webhook; } - public async Task InvalidateAndRefreshWebhook(IWebhook webhook) + public async Task InvalidateAndRefreshWebhook(DiscordChannel channel, DiscordWebhook webhook) { _logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId); _webhooks.TryRemove(webhook.ChannelId, out _); - return await GetWebhook(webhook.Channel); + return await GetWebhook(channel); } - private async Task GetOrCreateWebhook(ITextChannel channel) + private async Task GetOrCreateWebhook(DiscordChannel channel) { _logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id); return await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel); } - private async Task FindExistingWebhook(ITextChannel channel) + private async Task FindExistingWebhook(DiscordChannel channel) { _logger.Debug("Finding webhook for channel {Channel}", channel.Id); try @@ -78,13 +80,13 @@ namespace PluralKit.Bot } } - private Task DoCreateWebhook(ITextChannel channel) + private Task DoCreateWebhook(DiscordChannel channel) { _logger.Information("Creating new webhook for channel {Channel}", channel.Id); return channel.CreateWebhookAsync(WebhookName); } - private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.GetShardFor(arg.Guild).CurrentUser.Id && arg.Name == WebhookName; + private bool IsWebhookMine(DiscordWebhook arg) => arg.User.Id == _client.CurrentUser.Id && arg.Name == WebhookName; public int CacheSize => _webhooks.Count; } diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index 104d379b..adc9d67d 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -8,7 +8,8 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using App.Metrics; -using Discord; +using DSharpPlus.Entities; +using DSharpPlus.Exceptions; using Humanizer; @@ -44,13 +45,13 @@ namespace PluralKit.Bot _logger = logger.ForContext(); } - public async Task ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection attachments) + public async Task ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList attachments) { _logger.Verbose("Invoking webhook in channel {Channel}", channel.Id); // Get a webhook, execute it var webhook = await _webhookCache.GetWebhook(channel); - var id = await ExecuteWebhookInner(webhook, name, avatarUrl, content, attachments); + var id = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments); // Log the relevant metrics _metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied); @@ -60,112 +61,93 @@ namespace PluralKit.Bot return id; } - private async Task ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content, - IReadOnlyCollection attachments, bool hasRetried = false) + private async Task ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, + IReadOnlyList attachments, bool hasRetried = false) { - using var mfd = new MultipartFormDataContent - { - {new StringContent(content.Truncate(2000)), "content"}, - {new StringContent(FixClyde(name).Truncate(80)), "username"} - }; - if (avatarUrl != null) mfd.Add(new StringContent(avatarUrl), "avatar_url"); - + var dwb = new DiscordWebhookBuilder(); + dwb.WithUsername(FixClyde(name).Truncate(80)); + dwb.WithContent(content.Truncate(2000)); + if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl); + var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024); if (attachmentChunks.Count > 0) { - _logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.Size).Sum() / 1024 / 1024, attachmentChunks.Count); - await AddAttachmentsToMultipart(mfd, attachmentChunks.First()); + _logger.Information("Invoking webhook with {AttachmentCount} attachments totalling {AttachmentSize} MiB in {AttachmentChunks} chunks", attachments.Count, attachments.Select(a => a.FileSize).Sum() / 1024 / 1024, attachmentChunks.Count); + await AddAttachmentsToBuilder(dwb, attachmentChunks[0]); } - mfd.Headers.Add("X-RateLimit-Precision", "millisecond"); // Need this for better rate limit support - - // Adding this check as close to the actual send call as possible to prevent potential race conditions (unlikely, but y'know) - if (!_rateLimit.TryExecuteWebhook(webhook)) - throw new WebhookRateLimited(); - var timerCtx = _metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime); - using var response = await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}?wait=true", mfd); - timerCtx.Dispose(); - - _rateLimit.UpdateRateLimitInfo(webhook, response); - if (response.StatusCode == HttpStatusCode.TooManyRequests) - // Rate limits should be respected, we bail early (already updated the limit info so we hopefully won't hit this again) - throw new WebhookRateLimited(); - - var responseString = await response.Content.ReadAsStringAsync(); - - JObject responseJson; + DiscordMessage response; try { - responseJson = JsonConvert.DeserializeObject(responseString); + response = await webhook.ExecuteAsync(dwb); } - catch (JsonReaderException) + catch (NotFoundException e) { - // Sometimes we get invalid JSON from the server, just ignore all of it - throw new WebhookExecutionErrorOnDiscordsEnd(); - } - - if (responseJson.ContainsKey("code")) - { - var errorCode = responseJson["code"].Value(); - if (errorCode == 10015 && !hasRetried) + if (e.JsonMessage.Contains("10015") && !hasRetried) { // Error 10015 = "Unknown Webhook" - this likely means the webhook was deleted // but is still in our cache. Invalidate, refresh, try again _logger.Warning("Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId); - return await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachments, hasRetried: true); - } - - if (errorCode == 40005) - throw Errors.AttachmentTooLarge; // should be caught by the check above but just makin' sure - - // TODO: look into what this actually throws, and if this is the correct handling - if ((int) response.StatusCode >= 500) - // If it's a 5xx error code, this is on Discord's end, so we throw an execution exception - throw new WebhookExecutionErrorOnDiscordsEnd(); - - // Otherwise, this is going to throw on 4xx, and bubble up to our Sentry handler - response.EnsureSuccessStatusCode(); - } - - // If we have any leftover attachment chunks, send those - if (attachmentChunks.Count > 1) - { - // Deliberately not adding a content, just the remaining files - foreach (var chunk in attachmentChunks.Skip(1)) - { - using var mfd2 = new MultipartFormDataContent(); - mfd2.Add(new StringContent(FixClyde(name).Truncate(80)), "username"); - if (avatarUrl != null) mfd2.Add(new StringContent(avatarUrl), "avatar_url"); - await AddAttachmentsToMultipart(mfd2, chunk); - // Don't bother with ?wait, we're just kinda firehosing this stuff - // also don't error check, the real message itself is already sent - await _client.PostAsync($"{DiscordConfig.APIUrl}webhooks/{webhook.Id}/{webhook.Token}", mfd2); + var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook); + return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, hasRetried: true); } + + throw; + } + + timerCtx.Dispose(); + + // We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off + var _ = TrySendRemainingAttachments(webhook, name, avatarUrl, attachmentChunks); + + return response.Id; + } + + private async Task TrySendRemainingAttachments(DiscordWebhook webhook, string name, string avatarUrl, IReadOnlyList> attachmentChunks) + { + if (attachmentChunks.Count <= 1) return; + + for (var i = 1; i < attachmentChunks.Count; i++) + { + var dwb = new DiscordWebhookBuilder(); + if (avatarUrl != null) dwb.WithAvatarUrl(avatarUrl); + dwb.WithUsername(name); + await AddAttachmentsToBuilder(dwb, attachmentChunks[i]); + await webhook.ExecuteAsync(dwb); + } + } + + private async Task AddAttachmentsToBuilder(DiscordWebhookBuilder dwb, IReadOnlyCollection attachments) + { + async Task<(DiscordAttachment, Stream)> GetStream(DiscordAttachment attachment) + { + var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead); + return (attachment, await attachmentResponse.Content.ReadAsStreamAsync()); } - // At this point we're sure we have a 2xx status code, so just assume success - // TODO: can we do this without a round-trip to a string? - return responseJson["id"].Value(); + foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream))) + dwb.AddFile(attachment.FileName, attachmentStream); } - private IReadOnlyCollection> ChunkAttachmentsOrThrow( - IReadOnlyCollection attachments, int sizeThreshold) + + private IReadOnlyList> ChunkAttachmentsOrThrow( + IReadOnlyList attachments, int sizeThreshold) { // Splits a list of attachments into "chunks" of at most 8MB each // If any individual attachment is larger than 8MB, will throw an error - var chunks = new List>(); - var list = new List(); + var chunks = new List>(); + var list = new List(); foreach (var attachment in attachments) { - if (attachment.Size >= sizeThreshold) throw Errors.AttachmentTooLarge; + if (attachment.FileSize >= sizeThreshold) throw Errors.AttachmentTooLarge; - if (list.Sum(a => a.Size) + attachment.Size >= sizeThreshold) + if (list.Sum(a => a.FileSize) + attachment.FileSize >= sizeThreshold) { chunks.Add(list); - list = new List(); + list = new List(); } list.Add(attachment); @@ -175,20 +157,6 @@ namespace PluralKit.Bot return chunks; } - private async Task AddAttachmentsToMultipart(MultipartFormDataContent content, - IReadOnlyCollection attachments) - { - async Task<(IAttachment, Stream)> GetStream(IAttachment attachment) - { - var attachmentResponse = await _client.GetAsync(attachment.Url, HttpCompletionOption.ResponseHeadersRead); - return (attachment, await attachmentResponse.Content.ReadAsStreamAsync()); - } - - var attachmentId = 0; - foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream))) - content.Add(new StreamContent(attachmentStream), $"file{attachmentId++}", attachment.Filename); - } - private string FixClyde(string name) { // Check if the name contains "Clyde" - if not, do nothing diff --git a/PluralKit.Bot/Services/WebhookRateLimitService.cs b/PluralKit.Bot/Services/WebhookRateLimitService.cs index 7cb03ee1..9468bcaf 100644 --- a/PluralKit.Bot/Services/WebhookRateLimitService.cs +++ b/PluralKit.Bot/Services/WebhookRateLimitService.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; -using Discord; +using DSharpPlus.Entities; using NodaTime; @@ -26,7 +26,7 @@ namespace PluralKit.Bot public int CacheSize => _info.Count; - public bool TryExecuteWebhook(IWebhook webhook) + public bool TryExecuteWebhook(DiscordWebhook webhook) { // If we have nothing saved, just allow it (we'll save something once the response returns) if (!_info.TryGetValue(webhook.Id, out var info)) return true; @@ -57,7 +57,7 @@ namespace PluralKit.Bot return true; } - public void UpdateRateLimitInfo(IWebhook webhook, HttpResponseMessage response) + public void UpdateRateLimitInfo(DiscordWebhook webhook, HttpResponseMessage response) { var info = _info.GetOrAdd(webhook.Id, _ => new WebhookRateLimitInfo()); diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index ba411603..9f4631f7 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -1,58 +1,63 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading.Tasks; -using Discord; -using Discord.Net; -using Discord.WebSocket; + +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using DSharpPlus.Exceptions; using PluralKit.Core; namespace PluralKit.Bot { public static class ContextUtils { - public static async Task PromptYesNo(this Context ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) { + public static async Task PromptYesNo(this Context ctx, DiscordMessage message, DiscordUser user = null, TimeSpan? timeout = null) { // "Fork" the task adding the reactions off so we don't have to wait for them to be finished to start listening for presses -#pragma warning disable 4014 - message.AddReactionsAsync(new IEmote[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)}); -#pragma warning restore 4014 - var reaction = await ctx.AwaitReaction(message, user ?? ctx.Author, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); - return reaction.Emote.Name == Emojis.Success; + var _ = message.CreateReactionsBulk(new[] {Emojis.Success, Emojis.Error}); + var reaction = await ctx.AwaitReaction(message, user ?? ctx.Author, r => r.Emoji.Name == Emojis.Success || r.Emoji.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1)); + return reaction.Emoji.Name == Emojis.Success; } - public static async Task AwaitReaction(this Context ctx, IUserMessage message, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource(); - Task Inner(Cacheable _message, ISocketMessageChannel _channel, SocketReaction reaction) { - if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages - if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined - if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate - tcs.SetResult(reaction); + public static async Task AwaitReaction(this Context ctx, DiscordMessage message, DiscordUser user = null, Func predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource(); + Task Inner(MessageReactionAddEventArgs args) { + if (message.Id != args.Message.Id) return Task.CompletedTask; // Ignore reactions for different messages + if (user != null && user.Id != args.User.Id) return Task.CompletedTask; // Ignore messages from other users if a user was defined + if (predicate != null && !predicate.Invoke(args)) return Task.CompletedTask; // Check predicate + tcs.SetResult(args); return Task.CompletedTask; } - ((BaseSocketClient) ctx.Shard).ReactionAdded += Inner; + ctx.Shard.MessageReactionAdded += Inner; try { - return await (tcs.Task.TimeoutAfter(timeout)); + return await tcs.Task.TimeoutAfter(timeout); } finally { - ((BaseSocketClient) ctx.Shard).ReactionAdded -= Inner; + ctx.Shard.MessageReactionAdded -= Inner; } } - public static async Task AwaitMessage(this Context ctx, IMessageChannel channel, IUser user = null, Func predicate = null, TimeSpan? timeout = null) { - var tcs = new TaskCompletionSource(); - Task Inner(SocketMessage msg) { + public static async Task AwaitMessage(this Context ctx, DiscordChannel channel, DiscordUser user = null, Func predicate = null, TimeSpan? timeout = null) { + var tcs = new TaskCompletionSource(); + Task Inner(MessageCreateEventArgs args) + { + var msg = args.Message; if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate - - ((BaseSocketClient) ctx.Shard).MessageReceived -= Inner; - tcs.SetResult(msg as IUserMessage); - + tcs.SetResult(msg); return Task.CompletedTask; } - ((BaseSocketClient) ctx.Shard).MessageReceived += Inner; - return await (tcs.Task.TimeoutAfter(timeout)); + ctx.Shard.MessageCreated += Inner; + try + { + return await (tcs.Task.TimeoutAfter(timeout)); + } + finally + { + ctx.Shard.MessageCreated -= Inner; + } } public static async Task ConfirmWithReply(this Context ctx, string expectedReply) @@ -61,20 +66,20 @@ namespace PluralKit.Bot { return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase); } - public static async Task Paginate(this Context ctx, IAsyncEnumerable items, int totalCount, int itemsPerPage, string title, Func, Task> renderer) { + public static async Task Paginate(this Context ctx, IAsyncEnumerable items, int totalCount, int itemsPerPage, string title, Func, Task> renderer) { // TODO: make this generic enough we can use it in Choose below var buffer = new List(); await using var enumerator = items.GetAsyncEnumerator(); var pageCount = (totalCount / itemsPerPage) + 1; - async Task MakeEmbedForPage(int page) + async Task MakeEmbedForPage(int page) { var bufferedItemsNeeded = (page + 1) * itemsPerPage; while (buffer.Count < bufferedItemsNeeded && await enumerator.MoveNextAsync()) buffer.Add(enumerator.Current); - var eb = new EmbedBuilder(); + var eb = new DiscordEmbedBuilder(); eb.Title = pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title; await renderer(eb, buffer.Skip(page*itemsPerPage).Take(itemsPerPage)); return eb.Build(); @@ -84,8 +89,9 @@ namespace PluralKit.Bot { { var msg = await ctx.Reply(embed: await MakeEmbedForPage(0)); if (pageCount == 1) return; // If we only have one page, don't bother with the reaction/pagination logic, lol - IEmote[] botEmojis = { new Emoji("\u23EA"), new Emoji("\u2B05"), new Emoji("\u27A1"), new Emoji("\u23E9"), new Emoji(Emojis.Error) }; - await msg.AddReactionsAsync(botEmojis); + string[] botEmojis = { "\u23EA", "\u2B05", "\u27A1", "\u23E9", Emojis.Error }; + + var _ = msg.CreateReactionsBulk(botEmojis); // Again, "fork" try { var currentPage = 0; @@ -93,31 +99,30 @@ namespace PluralKit.Bot { var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: TimeSpan.FromMinutes(5)); // Increment/decrement page counter based on which reaction was clicked - if (reaction.Emote.Name == "\u23EA") currentPage = 0; // << - if (reaction.Emote.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // < - if (reaction.Emote.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // > - if (reaction.Emote.Name == "\u23E9") currentPage = pageCount - 1; // >> - if (reaction.Emote.Name == Emojis.Error) break; // X + if (reaction.Emoji.Name == "\u23EA") currentPage = 0; // << + if (reaction.Emoji.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // < + if (reaction.Emoji.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // > + if (reaction.Emoji.Name == "\u23E9") currentPage = pageCount - 1; // >> + if (reaction.Emoji.Name == Emojis.Error) break; // X // C#'s % operator is dumb and wrong, so we fix negative numbers if (currentPage < 0) currentPage += pageCount; // If we can, remove the user's reaction (so they can press again quickly) - if (ctx.BotHasPermission(ChannelPermission.ManageMessages) && reaction.User.IsSpecified) await msg.RemoveReactionAsync(reaction.Emote, reaction.User.Value); + if (ctx.BotHasPermission(Permissions.ManageMessages)) await msg.DeleteReactionAsync(reaction.Emoji, reaction.User); // Edit the embed with the new page var embed = await MakeEmbedForPage(currentPage); - await msg.ModifyAsync((mp) => mp.Embed = embed); + await msg.ModifyAsync(embed: embed); } } catch (TimeoutException) { // "escape hatch", clean up as if we hit X } - if (ctx.BotHasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync(); - else await msg.RemoveReactionsAsync(ctx.Shard.CurrentUser, botEmojis); + if (ctx.BotHasPermission(Permissions.ManageMessages)) await msg.DeleteAllReactionsAsync(); } // If we get a "NotFound" error, the message has been deleted and thus not our problem - catch (HttpException e) when (e.HttpCode == HttpStatusCode.NotFound) { } + catch (NotFoundException) { } } public static async Task Choose(this Context ctx, string description, IList items, Func display = null) @@ -152,36 +157,35 @@ namespace PluralKit.Bot { // Add back/forward reactions and the actual indicator emojis async Task AddEmojis() { - await msg.AddReactionAsync(new Emoji("\u2B05")); - await msg.AddReactionAsync(new Emoji("\u27A1")); - for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i])); + await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u2B05")); + await msg.CreateReactionAsync(DiscordEmoji.FromUnicode("\u27A1")); + for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i])); } var _ = AddEmojis(); // Not concerned about awaiting - - + while (true) { // Wait for a reaction var reaction = await ctx.AwaitReaction(msg, ctx.Author); // If it's a movement reaction, inc/dec the page index - if (reaction.Emote.Name == "\u2B05") currPage -= 1; // < - if (reaction.Emote.Name == "\u27A1") currPage += 1; // > + if (reaction.Emoji.Name == "\u2B05") currPage -= 1; // < + if (reaction.Emoji.Name == "\u27A1") currPage += 1; // > if (currPage < 0) currPage += pageCount; if (currPage >= pageCount) currPage -= pageCount; // If it's an indicator emoji, return the relevant item - if (indicators.Contains(reaction.Emote.Name)) + if (indicators.Contains(reaction.Emoji.Name)) { - var idx = Array.IndexOf(indicators, reaction.Emote.Name) + pageSize * currPage; + var idx = Array.IndexOf(indicators, reaction.Emoji.Name) + pageSize * currPage; // only if it's in bounds, though // eg. 8 items, we're on page 2, and I hit D (3 + 1*7 = index 10 on an 8-long list) = boom if (idx < items.Count) return items[idx]; } - var __ = msg.RemoveReactionAsync(reaction.Emote, ctx.Author); // don't care about awaiting - await msg.ModifyAsync(mp => mp.Content = $"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"); + var __ = msg.DeleteReactionAsync(reaction.Emoji, ctx.Author); // don't care about awaiting + await msg.ModifyAsync($"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}"); } } else @@ -191,26 +195,21 @@ namespace PluralKit.Bot { // Add the relevant reactions (we don't care too much about awaiting) async Task AddEmojis() { - for (int i = 0; i < items.Count; i++) await msg.AddReactionAsync(new Emoji(indicators[i])); + for (int i = 0; i < items.Count; i++) await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(indicators[i])); } var _ = AddEmojis(); // Then wait for a reaction and return whichever one we found - var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emote.Name)); - return items[Array.IndexOf(indicators, reaction.Emote.Name)]; + var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emoji.Name)); + return items[Array.IndexOf(indicators, reaction.Emoji.Name)]; } } - public static ChannelPermissions BotPermissions(this Context ctx) { - if (ctx.Channel is SocketGuildChannel gc) { - var gu = gc.Guild.CurrentUser; - return gu.GetPermissions(gc); - } - return ChannelPermissions.DM; - } + public static Permissions BotPermissions(this Context ctx) => ctx.Channel.BotPermissions(); - public static bool BotHasPermission(this Context ctx, ChannelPermission permission) => BotPermissions(ctx).Has(permission); + public static bool BotHasPermission(this Context ctx, Permissions permission) => + ctx.Channel.BotHasPermission(permission); public static async Task BusyIndicator(this Context ctx, Func f, string emoji = "\u23f3" /* hourglass */) { @@ -226,17 +225,17 @@ namespace PluralKit.Bot { var task = f(); // If we don't have permission to add reactions, don't bother, and just await the task normally. - if (!ctx.BotHasPermission(ChannelPermission.AddReactions)) return await task; - if (!ctx.BotHasPermission(ChannelPermission.ReadMessageHistory)) return await task; + var neededPermissions = Permissions.AddReactions | Permissions.ReadMessageHistory; + if ((ctx.BotPermissions() & neededPermissions) != neededPermissions) return await task; try { - await Task.WhenAll(ctx.Message.AddReactionAsync(new Emoji(emoji)), task); + await Task.WhenAll(ctx.Message.CreateReactionAsync(DiscordEmoji.FromUnicode(emoji)), task); return await task; } finally { - var _ = ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Shard.CurrentUser); + var _ = ctx.Message.DeleteReactionAsync(DiscordEmoji.FromUnicode(emoji), ctx.Shard.CurrentUser); } } } diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index 1f596789..e3ed92d1 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -1,31 +1,76 @@ -using Discord; -using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +using DSharpPlus; +using DSharpPlus.Entities; + +using NodaTime; namespace PluralKit.Bot { public static class DiscordUtils { - public static string NameAndMention(this IUser user) { + public static string NameAndMention(this DiscordUser user) { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } - - public static ChannelPermissions PermissionsIn(this IChannel channel) + + public static async Task PermissionsIn(this DiscordChannel channel, DiscordUser user) { - switch (channel) + if (channel.Guild != null) { - case IDMChannel _: - return ChannelPermissions.DM; - case IGroupChannel _: - return ChannelPermissions.Group; - case SocketGuildChannel gc: - var currentUser = gc.Guild.CurrentUser; - return currentUser.GetPermissions(gc); - default: - return ChannelPermissions.None; + var member = await channel.Guild.GetMemberAsync(user.Id); + return member.PermissionsIn(channel); } + + if (channel.Type == ChannelType.Private) + return (Permissions) 0b00000_1000110_1011100110000_000000; + + return Permissions.None; } - public static bool HasPermission(this IChannel channel, ChannelPermission permission) => - PermissionsIn(channel).Has(permission); + public static Permissions PermissionsInSync(this DiscordChannel channel, DiscordUser user) + { + if (user is DiscordMember dm && channel.Guild != null) + return dm.PermissionsIn(channel); + + if (channel.Type == ChannelType.Private) + return (Permissions) 0b00000_1000110_1011100110000_000000; + + return Permissions.None; + } + + public static Permissions BotPermissions(this DiscordChannel channel) + { + if (channel.Guild != null) + { + var member = channel.Guild.CurrentMember; + return channel.PermissionsFor(member); + } + + if (channel.Type == ChannelType.Private) + return (Permissions) 0b00000_1000110_1011100110000_000000; + + return Permissions.None; + } + + public static bool BotHasPermission(this DiscordChannel channel, Permissions permissionSet) => + (BotPermissions(channel) & permissionSet) == permissionSet; + + public static Instant SnowflakeToInstant(ulong snowflake) => + Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake << 22); + + public static ulong InstantToSnowflake(Instant time) => + (ulong) (time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds >> 22; + + public static ulong InstantToSnowflake(DateTimeOffset time) => + (ulong) (time - new DateTimeOffset(2015, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalMilliseconds >> 22; + + public static async Task CreateReactionsBulk(this DiscordMessage msg, string[] reactions) + { + foreach (var reaction in reactions) + { + await msg.CreateReactionAsync(DiscordEmoji.FromUnicode(reaction)); + } + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs index ed4d63dc..d3165f3e 100644 --- a/PluralKit.Bot/Utils/MiscUtils.cs +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -3,8 +3,6 @@ using System.Linq; using System.Net.Sockets; using System.Threading.Tasks; -using Discord.Net; - using Npgsql; using PluralKit.Core; @@ -20,7 +18,8 @@ namespace PluralKit.Bot // otherwise we'd blow out our error reporting budget as soon as Discord takes a dump, or something. // Discord server errors are *not our problem* - if (e is HttpException he && ((int) he.HttpCode) >= 500) return false; + // TODO + // if (e is DSharpPlus.Exceptions he && ((int) he.HttpCode) >= 500) return false; // Webhook server errors are also *not our problem* // (this includes rate limit errors, WebhookRateLimited is a subclass) diff --git a/PluralKit.Bot/Utils/StringUtils.cs b/PluralKit.Bot/Utils/StringUtils.cs index f0ede963..1320daac 100644 --- a/PluralKit.Bot/Utils/StringUtils.cs +++ b/PluralKit.Bot/Utils/StringUtils.cs @@ -2,16 +2,16 @@ using System.Globalization; using System.Text.RegularExpressions; -using Discord; +using DSharpPlus.Entities; namespace PluralKit.Bot { public static class StringUtils { - public static Color? ToDiscordColor(this string color) + public static DiscordColor? ToDiscordColor(this string color) { - if (uint.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) - return new Color(colorInt); + if (int.TryParse(color, NumberStyles.HexNumber, null, out var colorInt)) + return new DiscordColor(colorInt); throw new ArgumentException($"Invalid color string '{color}'."); } @@ -23,7 +23,7 @@ namespace PluralKit.Bot if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@')) return false; int num = content.IndexOf('>'); - if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out mentionId)) + if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !TryParseMention(content.Substring(0, num + 1), out mentionId)) return false; argPos = num + 2; return true; @@ -32,7 +32,18 @@ namespace PluralKit.Bot public static bool TryParseMention(this string potentialMention, out ulong id) { if (ulong.TryParse(potentialMention, out id)) return true; - if (MentionUtils.TryParseUser(potentialMention, out id)) return true; + + // Roughly ported from Discord.MentionUtils.TryParseUser + if (potentialMention.Length >= 3 && potentialMention[0] == '<' && potentialMention[1] == '@' && potentialMention[potentialMention.Length - 1] == '>') + { + if (potentialMention.Length >= 4 && potentialMention[2] == '!') + potentialMention = potentialMention.Substring(3, potentialMention.Length - 4); //<@!123> + else + potentialMention = potentialMention.Substring(2, potentialMention.Length - 3); //<@123> + + if (ulong.TryParse(potentialMention, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + return true; + } return false; } From c99784b9dc8c152224c034debe19523e0bcbdf15 Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 15:50:28 -0400 Subject: [PATCH 03/21] Fix Build Errors --- PluralKit.Bot/CommandSystem/Context.cs | 13 ++- PluralKit.Bot/Commands/Autoproxy.cs | 8 +- PluralKit.Bot/Commands/CommandTree.cs | 5 +- PluralKit.Bot/Commands/Help.cs | 6 +- PluralKit.Bot/Commands/ImportExport.cs | 14 +-- PluralKit.Bot/Commands/MemberAvatar.cs | 34 +++---- PluralKit.Bot/Commands/MemberEdit.cs | 14 +-- PluralKit.Bot/Commands/Misc.cs | 84 ++++++++--------- PluralKit.Bot/Commands/ServerConfig.cs | 32 ++++--- PluralKit.Bot/Commands/Switch.cs | 4 +- PluralKit.Bot/Commands/System.cs | 4 +- PluralKit.Bot/Commands/SystemEdit.cs | 17 ++-- PluralKit.Bot/Commands/SystemFront.cs | 16 +++- PluralKit.Bot/Commands/SystemList.cs | 10 +- PluralKit.Bot/Commands/Token.cs | 19 ++-- PluralKit.Bot/Extensions.cs | 23 +++++ PluralKit.Bot/Modules.cs | 10 +- PluralKit.Bot/PluralKit.Bot.csproj | 3 +- PluralKit.Bot/Utils/MentionUtils.cs | 121 +++++++++++++++++++++++++ 19 files changed, 301 insertions(+), 136 deletions(-) create mode 100644 PluralKit.Bot/Extensions.cs create mode 100644 PluralKit.Bot/Utils/MentionUtils.cs diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 1a710c84..17e18637 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -6,12 +6,11 @@ using App.Metrics; using Autofac; -using Discord; -using Discord.WebSocket; using DSharpPlus; using DSharpPlus.Entities; +using PluralKit.Bot.Utils; using PluralKit.Core; namespace PluralKit.Bot @@ -20,6 +19,7 @@ namespace PluralKit.Bot { private ILifetimeScope _provider; + private readonly DiscordRestClient _rest; private readonly DiscordShardedClient _client; private readonly DiscordClient _shard; private readonly DiscordMessage _message; @@ -34,6 +34,7 @@ namespace PluralKit.Bot public Context(ILifetimeScope provider, DiscordClient shard, DiscordMessage message, int commandParseOffset, PKSystem senderSystem) { + _rest = provider.Resolve(); _client = provider.Resolve(); _message = message; _shard = shard; @@ -50,6 +51,9 @@ namespace PluralKit.Bot public DiscordGuild Guild => _message.Channel.Guild; public DiscordClient Shard => _shard; public DiscordShardedClient Client => _client; + + public DiscordRestClient Rest => _rest; + public PKSystem System => _senderSystem; public string PopArgument() => _parameters.Pop(); @@ -280,10 +284,11 @@ namespace PluralKit.Bot public DiscordChannel MatchChannel() { if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null; - if (!(_client.GetChannelAsync(channel) is ITextChannel textChannel)) return null; + var discordChannel = _rest.GetChannelAsync(channel).GetAwaiter().GetResult(); + if (discordChannel.Type != ChannelType.Text) return null; PopArgument(); - return textChannel; + return null;// return textChannel; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index a84d9a7c..714478f4 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using PluralKit.Core; @@ -96,12 +96,12 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.Name}** in this server."); } - private async Task CreateAutoproxyStatusEmbed(Context ctx) + private async Task CreateAutoproxyStatusEmbed(Context ctx) { var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id); var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member\n**pk;autoproxy front** - Autoproxies as current (first) fronter\n**pk;autoproxy ** - Autoproxies as a specific member"; - var eb = new EmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); + var eb = new DiscordEmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})"); switch (settings.AutoproxyMode) { case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}"); diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index 3ec50e65..900811aa 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -1,7 +1,7 @@ using System.Linq; using System.Threading.Tasks; -using Discord.WebSocket; +using DSharpPlus; using PluralKit.Core; @@ -81,6 +81,7 @@ namespace PluralKit.Bot public CommandTree(DiscordShardedClient client) { + _client = client; } @@ -345,7 +346,7 @@ namespace PluralKit.Bot { // Try to resolve the user ID to find the associated account, // so we can print their username. - var user = await _client.Rest.GetUserAsync(id); + var user = await ctx.Rest.GetUserAsync(id); // Print descriptive errors based on whether we found the user or not. if (user == null) diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index 07ea6b8a..de4a6d12 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using PluralKit.Core; @@ -10,7 +10,7 @@ namespace PluralKit.Bot { public async Task HelpRoot(Context ctx) { - await ctx.Reply(embed: new EmbedBuilder() + await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle("PluralKit") .WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.") .AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.") @@ -20,7 +20,7 @@ namespace PluralKit.Bot .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") - .WithColor(Color.Blue) + .WithColor(DiscordColor.Blue) .Build()); } } diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index 2a9a2d43..946d6b23 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -5,10 +5,9 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; -using Discord; -using Discord.Net; - using Newtonsoft.Json; +using DSharpPlus.Exceptions; +using DSharpPlus.Entities; using PluralKit.Core; @@ -134,13 +133,14 @@ namespace PluralKit.Bot try { - await ctx.Author.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!"); - + var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); + await dm.SendFileAsync("system.json", stream, $"{Emojis.Success} Here you go!"); + // If the original message wasn't posted in DMs, send a public reminder - if (!(ctx.Channel is IDMChannel)) + if (!(ctx.Channel is DiscordDmChannel)) await ctx.Reply($"{Emojis.Success} Check your DMs!"); } - catch (HttpException) + catch (UnauthorizedException) { // If user has DMs closed, tell 'em to open them await ctx.Reply( diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index ac84d964..0e0b8916 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -1,7 +1,8 @@ -using System.Linq; +using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus; +using DSharpPlus.Entities; using PluralKit.Core; @@ -39,7 +40,7 @@ namespace PluralKit.Bot { if ((target.AvatarUrl?.Trim() ?? "").Length > 0) { - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle($"{target.Name.SanitizeMentions()}'s avatar") .WithImageUrl(target.AvatarUrl); if (target.System == ctx.System?.Id) @@ -55,18 +56,17 @@ namespace PluralKit.Bot return; } - + var user = await ctx.MatchUser(); if (ctx.System == null) throw Errors.NoSystemError; if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; - - else if (await ctx.MatchUser() is IUser user) + else if (user != null) { - if (user.AvatarId == null) throw Errors.UserHasNoAvatar; + if (user.AvatarUrl == user.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; //TODO: is this necessary? target.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SaveMember(target); - var embed = new EmbedBuilder().WithImageUrl(target.AvatarUrl).Build(); + var embed = new DiscordEmbedBuilder().WithImageUrl(target.AvatarUrl).Build(); await ctx.Reply( $"{Emojis.Success} Member avatar changed to {user.Username}'s avatar! {Emojis.Warn} Please note that if {user.Username} changes their avatar, the member's avatar will need to be re-set.", embed: embed); } @@ -76,10 +76,10 @@ namespace PluralKit.Bot target.AvatarUrl = url; await _data.SaveMember(target); - var embed = new EmbedBuilder().WithImageUrl(url).Build(); + var embed = new DiscordEmbedBuilder().WithImageUrl(url).Build(); await ctx.Reply($"{Emojis.Success} Member avatar changed.", embed: embed); } - else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment) + else if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment attachment) { await AvatarUtils.VerifyAvatarOrThrow(attachment.Url); target.AvatarUrl = attachment.Url; @@ -113,7 +113,7 @@ namespace PluralKit.Bot { if ((guildData.AvatarUrl?.Trim() ?? "").Length > 0) { - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle($"{target.Name.SanitizeMentions()}'s server avatar (for {ctx.Guild.Name})") .WithImageUrl(guildData.AvatarUrl); if (target.System == ctx.System?.Id) @@ -125,17 +125,17 @@ namespace PluralKit.Bot return; } - + var user = await ctx.MatchUser(); if (ctx.System == null) throw Errors.NoSystemError; if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; - if (await ctx.MatchUser() is IUser user) + if (user != null) { - if (user.AvatarId == null) throw Errors.UserHasNoAvatar; + if (user.AvatarUrl == user.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; guildData.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SetMemberGuildSettings(target, ctx.Guild.Id, guildData); - var embed = new EmbedBuilder().WithImageUrl(guildData.AvatarUrl).Build(); + var embed = new DiscordEmbedBuilder().WithImageUrl(guildData.AvatarUrl).Build(); await ctx.Reply( $"{Emojis.Success} Member server avatar changed to {user.Username}'s avatar! This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**). {Emojis.Warn} Please note that if {user.Username} changes their avatar, the member's server avatar will need to be re-set.", embed: embed); } @@ -145,10 +145,10 @@ namespace PluralKit.Bot guildData.AvatarUrl = url; await _data.SetMemberGuildSettings(target, ctx.Guild.Id, guildData); - var embed = new EmbedBuilder().WithImageUrl(url).Build(); + var embed = new DiscordEmbedBuilder().WithImageUrl(url).Build(); await ctx.Reply($"{Emojis.Success} Member server avatar changed. This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", embed: embed); } - else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment) + else if (ctx.Message.Attachments.FirstOrDefault() is DiscordAttachment attachment) { await AvatarUtils.VerifyAvatarOrThrow(attachment.Url); guildData.AvatarUrl = attachment.Url; diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index a6fff5e7..e471a2f9 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Text.RegularExpressions; using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using NodaTime; @@ -86,7 +86,7 @@ namespace PluralKit.Bot else if (ctx.MatchFlag("r", "raw")) await ctx.Reply($"```\n{target.Description.SanitizeMentions()}\n```"); else - await ctx.Reply(embed: new EmbedBuilder() + await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle("Member description") .WithDescription(target.Description) .AddField("\u200B", $"To print the description with formatting, type `pk;member {target.Hid} description -raw`." @@ -163,7 +163,7 @@ namespace PluralKit.Bot else await ctx.Reply("This member does not have a color set."); else - await ctx.Reply(embed: new EmbedBuilder() + await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle("Member color") .WithColor(target.Color.ToDiscordColor().Value) .WithThumbnailUrl($"https://fakeimg.pl/256x256/{target.Color}/?text=%20") @@ -180,7 +180,7 @@ namespace PluralKit.Bot target.Color = color.ToLower(); await _data.SaveMember(target); - await ctx.Reply(embed: new EmbedBuilder() + await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle($"{Emojis.Success} Member color changed.") .WithColor(target.Color.ToDiscordColor().Value) .WithThumbnailUrl($"https://fakeimg.pl/256x256/{target.Color}/?text=%20") @@ -220,13 +220,13 @@ namespace PluralKit.Bot } } - private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) + private async Task CreateMemberNameInfoEmbed(Context ctx, PKMember target) { MemberGuildSettings memberGuildConfig = null; if (ctx.Guild != null) memberGuildConfig = await _data.GetMemberGuildSettings(target, ctx.Guild.Id); - var eb = new EmbedBuilder().WithTitle($"Member names") + var eb = new DiscordEmbedBuilder().WithTitle($"Member names") .WithFooter($"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name."); if (target.DisplayName == null && memberGuildConfig?.DisplayName == null) diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 6df8a8ae..98dcf3de 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -3,16 +3,18 @@ using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Net.WebSockets; using App.Metrics; -using Discord; +using DSharpPlus; using Humanizer; using NodaTime; using PluralKit.Core; +using DSharpPlus.Entities; namespace PluralKit.Bot { public class Misc @@ -36,18 +38,16 @@ namespace PluralKit.Bot { public async Task Invite(Context ctx) { - var clientId = _botConfig.ClientId ?? (await ctx.Client.GetApplicationInfoAsync()).Id; - var permissions = new GuildPermissions( - addReactions: true, - attachFiles: true, - embedLinks: true, - manageMessages: true, - manageWebhooks: true, - readMessageHistory: true, - sendMessages: true - ); - - var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}"; + var clientId = _botConfig.ClientId ?? ctx.Client.CurrentApplication.Id; + var permissions = new Permissions() + .Grant(Permissions.AddReactions) + .Grant(Permissions.AttachFiles) + .Grant(Permissions.EmbedLinks) + .Grant(Permissions.ManageMessages) + .Grant(Permissions.ManageWebhooks) + .Grant(Permissions.ReadMessageHistory) + .Grant(Permissions.SendMessages); + var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={(long)permissions}"; await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); } @@ -65,8 +65,8 @@ namespace PluralKit.Bot { var totalMessages = _metrics.Snapshot.GetForContext("Application").Gauges.First(m => m.MultidimensionalName == CoreMetrics.MessageCount.Name).Value; var shardId = ctx.Shard.ShardId; - var shardTotal = ctx.Client.Shards.Count; - var shardUpTotal = ctx.Client.Shards.Select(s => s.ConnectionState == ConnectionState.Connected).Count(); + var shardTotal = ctx.Client.ShardClients.Count; + var shardUpTotal = ctx.Client.ShardClients.Where(x => x.Value.IsConnected()).Count(); var shardInfo = _shards.GetShardInfo(ctx.Shard); var process = Process.GetCurrentProcess(); @@ -74,7 +74,7 @@ namespace PluralKit.Bot { var shardUptime = SystemClock.Instance.GetCurrentInstant() - shardInfo.LastConnectionTime; - var embed = new EmbedBuilder() + var embed = new DiscordEmbedBuilder() .AddField("Messages processed", $"{messagesReceived.OneMinuteRate * 60:F1}/m ({messagesReceived.FifteenMinuteRate * 60:F1}/m over 15m)", true) .AddField("Messages proxied", $"{messagesProxied.OneMinuteRate * 60:F1}/m ({messagesProxied.FifteenMinuteRate * 60:F1}/m over 15m)", true) .AddField("Commands executed", $"{commandsRun.OneMinuteRate * 60:F1}/m ({commandsRun.FifteenMinuteRate * 60:F1}/m over 15m)", true) @@ -84,17 +84,12 @@ namespace PluralKit.Bot { .AddField("Memory usage", $"{memoryUsage / 1024 / 1024} MiB", true) .AddField("Latency", $"API: {(msg.Timestamp - ctx.Message.Timestamp).TotalMilliseconds:F0} ms, shard: {shardInfo.ShardLatency} ms", true) .AddField("Total numbers", $"{totalSystems:N0} systems, {totalMembers:N0} members, {totalSwitches:N0} switches, {totalMessages:N0} messages"); - - await msg.ModifyAsync(f => - { - f.Content = ""; - f.Embed = embed.Build(); - }); + await msg.ModifyAsync("", embed.Build()); } - + public async Task PermCheckGuild(Context ctx) { - IGuild guild; + DiscordGuild guild; if (ctx.Guild != null && !ctx.HasNext()) { @@ -107,51 +102,52 @@ namespace PluralKit.Bot { throw new PKSyntaxError($"Could not parse `{guildIdStr.SanitizeMentions()}` as an ID."); // TODO: will this call break for sharding if you try to request a guild on a different bot instance? - guild = ctx.Client.GetGuild(guildId); + guild = await ctx.Rest.GetGuildAsync(guildId); if (guild == null) throw Errors.GuildNotFound(guildId); } - + var requiredPermissions = new [] { - ChannelPermission.ViewChannel, - ChannelPermission.SendMessages, - ChannelPermission.AddReactions, - ChannelPermission.AttachFiles, - ChannelPermission.EmbedLinks, - ChannelPermission.ManageMessages, - ChannelPermission.ManageWebhooks + Permissions.AccessChannels, + Permissions.SendMessages, + Permissions.AddReactions, + Permissions.AttachFiles, + Permissions.EmbedLinks, + Permissions.ManageMessages, + Permissions.ManageWebhooks }; // Loop through every channel and group them by sets of permissions missing - var permissionsMissing = new Dictionary>(); - foreach (var channel in await guild.GetTextChannelsAsync()) + var permissionsMissing = new Dictionary>(); + var guildTextChannels = (await guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text); + foreach (var channel in guildTextChannels) { // TODO: do we need to hide channels here to prevent info-leaking? - var perms = channel.PermissionsIn(); + var perms = await channel.PermissionsIn(ctx.Client.CurrentUser); // We use a bitfield so we can set individual permission bits in the loop ulong missingPermissionField = 0; foreach (var requiredPermission in requiredPermissions) - if (!perms.Has(requiredPermission)) + if (!perms.HasPermission(requiredPermission)) missingPermissionField |= (ulong) requiredPermission; // If we're not missing any permissions, don't bother adding it to the dict // This means we can check if the dict is empty to see if all channels are proxyable if (missingPermissionField != 0) { - permissionsMissing.TryAdd(missingPermissionField, new List()); + permissionsMissing.TryAdd(missingPermissionField, new List()); permissionsMissing[missingPermissionField].Add(channel); } } // Generate the output embed - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle($"Permission check for **{guild.Name.SanitizeMentions()}**"); if (permissionsMissing.Count == 0) { - eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(Color.Green); + eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordColor.Green); } else { @@ -159,15 +155,13 @@ namespace PluralKit.Bot { { // Each missing permission field can have multiple missing channels // so we extract them all and generate a comma-separated list - var missingPermissionNames = string.Join(", ", new ChannelPermissions(missingPermissionField) - .ToList() - .Select(perm => perm.Humanize().Transform(To.TitleCase))); + var missingPermissionNames = ((Permissions)missingPermissionField).ToPermissionString(); var channelsList = string.Join("\n", channels .OrderBy(c => c.Position) .Select(c => $"#{c.Name}")); eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)); - eb.WithColor(Color.Red); + eb.WithColor(DiscordColor.Red); } } @@ -189,7 +183,7 @@ namespace PluralKit.Bot { var message = await _data.GetMessage(messageId); if (message == null) throw Errors.MessageNotFound(messageId); - await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message)); + await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message)); //TODO: test this } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index 8f758067..ad7658ff 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus; +using DSharpPlus.Entities; using PluralKit.Core; @@ -20,12 +21,13 @@ namespace PluralKit.Bot public async Task SetLogChannel(Context ctx) { - ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - ITextChannel channel = null; + DiscordChannel channel = null; if (ctx.HasNext()) channel = ctx.MatchChannel() ?? throw new PKSyntaxError("You must pass a #channel to set."); - if (channel != null && channel.GuildId != ctx.Guild.Id) throw new PKError("That channel is not in this server!"); + if (channel != null && channel.GuildId != ctx.Guild.Id) throw new PKError("That channel is not in this server!"); + if (channel.Type != ChannelType.Text) throw new PKError("The logging channel must be a text channel."); //TODO: test this var cfg = await _data.GetOrCreateGuildConfig(ctx.Guild.Id); cfg.LogChannel = channel?.Id; @@ -39,15 +41,16 @@ namespace PluralKit.Bot public async Task SetLogEnabled(Context ctx, bool enable) { - ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - var affectedChannels = new List(); + var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).OfType().ToList(); + affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); else while (ctx.HasNext()) { - if (!(ctx.MatchChannel() is ITextChannel channel)) + var channel = ctx.MatchChannel(); //TODO: test this + if (channel.Type != ChannelType.Text) throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server."); affectedChannels.Add(channel); @@ -65,15 +68,16 @@ namespace PluralKit.Bot public async Task SetBlacklisted(Context ctx, bool onBlacklist) { - ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); - var affectedChannels = new List(); + var affectedChannels = new List(); if (ctx.Match("all")) - affectedChannels = (await ctx.Guild.GetChannelsAsync()).OfType().ToList(); + affectedChannels = (await ctx.Guild.GetChannelsAsync()).Where(x => x.Type == ChannelType.Text).ToList(); else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); else while (ctx.HasNext()) { - if (!(ctx.MatchChannel() is ITextChannel channel)) + var channel = ctx.MatchChannel(); //TODO: test this + if (channel.Type != ChannelType.Text) throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server."); affectedChannels.Add(channel); @@ -89,7 +93,7 @@ namespace PluralKit.Bot public async Task SetLogCleanup(Context ctx) { - ctx.CheckGuildContext().CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server"); + ctx.CheckGuildContext().CheckAuthorPermission(Permissions.ManageGuild, "Manage Server"); var guildCfg = await _data.GetOrCreateGuildConfig(ctx.Guild.Id); var botList = string.Join(", ", _cleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); @@ -108,7 +112,7 @@ namespace PluralKit.Bot } else { - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle("Log cleanup settings") .AddField("Supported bots", botList); diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs index be5a6675..e650db19 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using NodaTime; using NodaTime.TimeZones; @@ -140,7 +140,7 @@ namespace PluralKit.Bot var lastSwitchMemberStr = string.Join(", ", await lastSwitchMembers.Select(m => m.Name).ToListAsync()); var lastSwitchDeltaStr = DateTimeFormats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp); - IUserMessage msg; + DiscordMessage msg; if (lastTwoSwitches.Count == 1) { msg = await ctx.Reply( diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index 3094c3b6..2fde9b66 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -18,9 +18,9 @@ namespace PluralKit.Bot public async Task Query(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError; - await ctx.Reply(embed: await _embeds.CreateSystemEmbed(system, ctx.LookupContextFor(system))); + await ctx.Reply(embed: await _embeds.CreateSystemEmbed(ctx.Shard, system, ctx.LookupContextFor(system))); } - + public async Task New(Context ctx) { ctx.CheckNoSystem(); diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index ef0970b6..454614a4 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus; +using DSharpPlus.Entities; using NodaTime; using NodaTime.Text; @@ -70,7 +71,7 @@ namespace PluralKit.Bot else if (ctx.MatchFlag("r", "raw")) await ctx.Reply($"```\n{ctx.System.Description.SanitizeMentions()}\n```"); else - await ctx.Reply(embed: new EmbedBuilder() + await ctx.Reply(embed: new DiscordEmbedBuilder() .WithTitle("System description") .WithDescription(ctx.System.Description) .WithFooter("To print the description with formatting, type `pk;s description -raw`. To clear it, type `pk;s description -clear`. To change it, type `pk;s description `.") @@ -128,7 +129,7 @@ namespace PluralKit.Bot { if ((ctx.System.AvatarUrl?.Trim() ?? "").Length > 0) { - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle($"System avatar") .WithImageUrl(ctx.System.AvatarUrl) .WithDescription($"To clear, use `pk;system avatar clear`."); @@ -143,11 +144,11 @@ namespace PluralKit.Bot var member = await ctx.MatchUser(); if (member != null) { - if (member.AvatarId == null) throw Errors.UserHasNoAvatar; + if (member.AvatarUrl == member.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; ctx.System.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SaveSystem(ctx.System); - var embed = new EmbedBuilder().WithImageUrl(ctx.System.AvatarUrl).Build(); + var embed = new DiscordEmbedBuilder().WithImageUrl(ctx.System.AvatarUrl).Build(); await ctx.Reply( $"{Emojis.Success} System avatar changed to {member.Username}'s avatar! {Emojis.Warn} Please note that if {member.Username} changes their avatar, the system's avatar will need to be re-set.", embed: embed); } @@ -160,7 +161,7 @@ namespace PluralKit.Bot ctx.System.AvatarUrl = url; await _data.SaveSystem(ctx.System); - var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null; + var embed = url != null ? new DiscordEmbedBuilder().WithImageUrl(url).Build() : null; await ctx.Reply($"{Emojis.Success} System avatar changed.", embed: embed); } } @@ -249,7 +250,7 @@ namespace PluralKit.Bot _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) }; - var eb = new EmbedBuilder() + var eb = new DiscordEmbedBuilder() .WithTitle("Current privacy settings for your system") .AddField("Description", PrivacyLevelString(ctx.System.DescriptionPrivacy)) .AddField("Member list", PrivacyLevelString(ctx.System.MemberListPrivacy)) diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 726987d9..1abe995b 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,7 +1,8 @@ -using System.Linq; +using ArgumentException = System.ArgumentException; +using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using NodaTime; @@ -88,9 +89,14 @@ namespace PluralKit.Bot stringToAdd = $"**{membersStr}** ({DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(system.Zone))}, {DateTimeFormats.DurationFormat.Format(switchSince)} ago)\n"; } - - if (outputStr.Length + stringToAdd.Length > EmbedBuilder.MaxDescriptionLength) break; - outputStr += stringToAdd; + try // Unfortunately the only way to test DiscordEmbedBuilder.Description max length is this + { + builder.Description += stringToAdd; + } + catch (ArgumentException) + { + break; + }// TODO: Make sure this works } builder.Description = outputStr; diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 39ea4074..34e50f2d 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Discord; +using DSharpPlus.Entities; using Humanizer; @@ -21,7 +21,7 @@ namespace PluralKit.Bot } private async Task RenderMemberList(Context ctx, PKSystem system, bool canShowPrivate, int membersPerPage, string embedTitle, Func filter, - Func, Task> + Func, Task> renderer) { var authCtx = ctx.LookupContextFor(system); @@ -54,7 +54,7 @@ namespace PluralKit.Bot }); } - private Task ShortRenderer(EmbedBuilder eb, IEnumerable members) + private Task ShortRenderer(DiscordEmbedBuilder eb, IEnumerable members) { eb.Description = string.Join("\n", members.Select((m) => { @@ -73,7 +73,7 @@ namespace PluralKit.Bot return Task.CompletedTask; } - private Task LongRenderer(EmbedBuilder eb, IEnumerable members) + private Task LongRenderer(DiscordEmbedBuilder eb, IEnumerable members) { foreach (var m in members) { diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Token.cs index dbdfa057..d502b96e 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Token.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; -using Discord; +using DSharpPlus; +using DSharpPlus.Entities; using PluralKit.Core; @@ -22,14 +23,15 @@ namespace PluralKit.Bot var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System); // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is IDMChannel)) + if (!(ctx.Channel is DiscordDmChannel)) { await ctx.Reply($"{Emojis.Success} Check your DMs!"); } // DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile) - await ctx.Author.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"); - await ctx.Author.SendMessageAsync(token); + var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); + await dm.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:"); + await dm.SendMessageAsync(token); } private async Task MakeAndSetNewToken(PKSystem system) @@ -55,14 +57,15 @@ namespace PluralKit.Bot var token = await MakeAndSetNewToken(ctx.System); // If we're not already in a DM, reply with a reminder to check - if (!(ctx.Channel is IDMChannel)) + if (!(ctx.Channel is DiscordDmChannel)) { await ctx.Reply($"{Emojis.Success} Check your DMs!"); } - + // DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile) - await ctx.Author.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"); - await ctx.Author.SendMessageAsync(token); + var dm = await ctx.Rest.CreateDmAsync(ctx.Author.Id); + await dm.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"); + await dm.SendMessageAsync(token); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Extensions.cs b/PluralKit.Bot/Extensions.cs new file mode 100644 index 00000000..b5434e5c --- /dev/null +++ b/PluralKit.Bot/Extensions.cs @@ -0,0 +1,23 @@ +using DSharpPlus; + +using System.Net.WebSockets; + +namespace PluralKit.Bot +{ + static class Extensions + { + //Unfortunately D#+ doesn't expose the connection state of the client, so we have to test for it instead + public static bool IsConnected(this DiscordClient client) + { + try + { + client.GetConnectionsAsync().GetAwaiter().GetResult(); + } + catch(WebSocketException) + { + return false; + } + return true; + } + } +} diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 2581a7d0..051c2d78 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -15,14 +15,20 @@ namespace PluralKit.Bot { protected override void Load(ContainerBuilder builder) { - // Client + // Clients builder.Register(c => new DiscordShardedClient(new DiscordConfiguration { Token = c.Resolve().Token, TokenType = TokenType.Bot, MessageCacheSize = 0, })).AsSelf().SingleInstance(); - + builder.Register(c => new DiscordRestClient(new DiscordConfiguration + { + Token = c.Resolve().Token, + TokenType = TokenType.Bot, + MessageCacheSize = 0, + })).AsSelf().SingleInstance(); + // Commands builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index f56c7d05..cc6b2b1c 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -10,7 +10,8 @@ - + + diff --git a/PluralKit.Bot/Utils/MentionUtils.cs b/PluralKit.Bot/Utils/MentionUtils.cs new file mode 100644 index 00000000..cd6e9034 --- /dev/null +++ b/PluralKit.Bot/Utils/MentionUtils.cs @@ -0,0 +1,121 @@ +using System; +using System.Globalization; +using System.Text; + +namespace PluralKit.Bot.Utils +{ + /// + /// Provides a series of helper methods for parsing mentions. + /// + public static class MentionUtils + { + private const char SanitizeChar = '\x200b'; + + //If the system can't be positive a user doesn't have a nickname, assume useNickname = true (source: Jake) + internal static string MentionUser(string id, bool useNickname = true) => useNickname ? $"<@!{id}>" : $"<@{id}>"; + /// + /// Returns a mention string based on the user ID. + /// + /// + /// A user mention string (e.g. <@80351110224678912>). + /// + public static string MentionUser(ulong id) => MentionUser(id.ToString(), true); + internal static string MentionChannel(string id) => $"<#{id}>"; + /// + /// Returns a mention string based on the channel ID. + /// + /// + /// A channel mention string (e.g. <#103735883630395392>). + /// + public static string MentionChannel(ulong id) => MentionChannel(id.ToString()); + internal static string MentionRole(string id) => $"<@&{id}>"; + /// + /// Returns a mention string based on the role ID. + /// + /// + /// A role mention string (e.g. <@&165511591545143296>). + /// + public static string MentionRole(ulong id) => MentionRole(id.ToString()); + + /// + /// Parses a provided user mention string. + /// + /// Invalid mention format. + public static ulong ParseUser(string text) + { + if (TryParseUser(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided user mention string. + /// + public static bool TryParseUser(string text, out ulong userId) + { + if (text.Length >= 3 && text[0] == '<' && text[1] == '@' && text[text.Length - 1] == '>') + { + if (text.Length >= 4 && text[2] == '!') + text = text.Substring(3, text.Length - 4); //<@!123> + else + text = text.Substring(2, text.Length - 3); //<@123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out userId)) + return true; + } + userId = 0; + return false; + } + + /// + /// Parses a provided channel mention string. + /// + /// Invalid mention format. + public static ulong ParseChannel(string text) + { + if (TryParseChannel(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided channel mention string. + /// + public static bool TryParseChannel(string text, out ulong channelId) + { + if (text.Length >= 3 && text[0] == '<' && text[1] == '#' && text[text.Length - 1] == '>') + { + text = text.Substring(2, text.Length - 3); //<#123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out channelId)) + return true; + } + channelId = 0; + return false; + } + + /// + /// Parses a provided role mention string. + /// + /// Invalid mention format. + public static ulong ParseRole(string text) + { + if (TryParseRole(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided role mention string. + /// + public static bool TryParseRole(string text, out ulong roleId) + { + if (text.Length >= 4 && text[0] == '<' && text[1] == '@' && text[2] == '&' && text[text.Length - 1] == '>') + { + text = text.Substring(3, text.Length - 4); //<@&123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out roleId)) + return true; + } + roleId = 0; + return false; + } + } +} \ No newline at end of file From 949dae6561b2b3053083c846ce2fd67bf247263f Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:20:34 -0400 Subject: [PATCH 04/21] Add Extension Methods --- PluralKit.Bot/Bot.cs | 2 +- PluralKit.Bot/Commands/MemberAvatar.cs | 4 ++-- PluralKit.Bot/Commands/SystemEdit.cs | 2 +- PluralKit.Bot/Extensions.cs | 6 ++++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 6764b5e6..7d7d7cad 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -283,7 +283,7 @@ namespace PluralKit.Bot // Fetch information about the guild early, as we need it for the logger cleanup GuildConfig cachedGuild = default; - if (msg.Channel.Type == ChannelType.Text) await _cache.GetGuildDataCached(msg.Channel.GuildId); + if (msg.Channel.Type == ChannelType.Text) cachedGuild = await _cache.GetGuildDataCached(msg.Channel.GuildId); // Pass guild bot/WH messages onto the logger cleanup service, but otherwise ignore if (msg.Author.IsBot && msg.Channel.Type == ChannelType.Text) diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 0e0b8916..a02dabdd 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -61,7 +61,7 @@ namespace PluralKit.Bot if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; else if (user != null) { - if (user.AvatarUrl == user.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; //TODO: is this necessary? + if (!user.HasAvatar()) throw Errors.UserHasNoAvatar; //TODO: is this necessary? target.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SaveMember(target); @@ -131,7 +131,7 @@ namespace PluralKit.Bot if (user != null) { - if (user.AvatarUrl == user.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; + if (!user.HasAvatar()) throw Errors.UserHasNoAvatar; guildData.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SetMemberGuildSettings(target, ctx.Guild.Id, guildData); diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 454614a4..ed000be2 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -144,7 +144,7 @@ namespace PluralKit.Bot var member = await ctx.MatchUser(); if (member != null) { - if (member.AvatarUrl == member.DefaultAvatarUrl) throw Errors.UserHasNoAvatar; + if (!member.HasAvatar()) throw Errors.UserHasNoAvatar; ctx.System.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256); await _data.SaveSystem(ctx.System); diff --git a/PluralKit.Bot/Extensions.cs b/PluralKit.Bot/Extensions.cs index b5434e5c..ad9da5a6 100644 --- a/PluralKit.Bot/Extensions.cs +++ b/PluralKit.Bot/Extensions.cs @@ -1,4 +1,5 @@ using DSharpPlus; +using DSharpPlus.Entities; using System.Net.WebSockets; @@ -19,5 +20,10 @@ namespace PluralKit.Bot } return true; } + + public static bool HasAvatar(this DiscordUser user) + { + return user.AvatarUrl != user.DefaultAvatarUrl; + } } } From ebaded4bbdf9d08a6908753a7e9ae85675b935a3 Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:21:02 -0400 Subject: [PATCH 05/21] Fix Proxied Messages Ignored --- PluralKit.Bot/Services/ProxyService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index b4a51fd6..0c5b1414 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -86,7 +86,7 @@ namespace PluralKit.Bot public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool doAutoProxy) { // Bail early if this isn't in a guild channel - if (message.Channel.Guild != null) return; + if (message.Channel.Guild == null) return; // Find a member with proxy tags matching the message var match = GetProxyTagMatch(message.Content, account.System, account.Members); From 4e988867fe4d460eb7af1ed50d81c5a09bbbecfa Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:21:46 -0400 Subject: [PATCH 06/21] Refactor Unnecessary Enum Checks --- PluralKit.Bot/Commands/ServerConfig.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index ad7658ff..3407b8b3 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -27,7 +27,6 @@ namespace PluralKit.Bot if (ctx.HasNext()) channel = ctx.MatchChannel() ?? throw new PKSyntaxError("You must pass a #channel to set."); if (channel != null && channel.GuildId != ctx.Guild.Id) throw new PKError("That channel is not in this server!"); - if (channel.Type != ChannelType.Text) throw new PKError("The logging channel must be a text channel."); //TODO: test this var cfg = await _data.GetOrCreateGuildConfig(ctx.Guild.Id); cfg.LogChannel = channel?.Id; @@ -49,9 +48,7 @@ namespace PluralKit.Bot else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); else while (ctx.HasNext()) { - var channel = ctx.MatchChannel(); //TODO: test this - if (channel.Type != ChannelType.Text) - throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); + var channel = ctx.MatchChannel() ?? throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server."); affectedChannels.Add(channel); } @@ -76,9 +73,7 @@ namespace PluralKit.Bot else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); else while (ctx.HasNext()) { - var channel = ctx.MatchChannel(); //TODO: test this - if (channel.Type != ChannelType.Text) - throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); + var channel = ctx.MatchChannel() ?? throw new PKSyntaxError($"Channel \"{ctx.PopArgument().SanitizeMentions()}\" not found."); if (channel.GuildId != ctx.Guild.Id) throw new PKError($"Channel {ctx.Guild.Id} is not in this server."); affectedChannels.Add(channel); } From bdb6019cb950c9a80d77f8655db4118462138a51 Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:22:33 -0400 Subject: [PATCH 07/21] Fix MatchChannel --- PluralKit.Bot/CommandSystem/Context.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/CommandSystem/Context.cs b/PluralKit.Bot/CommandSystem/Context.cs index 17e18637..c4f7bae2 100644 --- a/PluralKit.Bot/CommandSystem/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context.cs @@ -288,7 +288,7 @@ namespace PluralKit.Bot if (discordChannel.Type != ChannelType.Text) return null; PopArgument(); - return null;// return textChannel; + return discordChannel; } } } \ No newline at end of file From c41f2c7c9b6535836c421e7dbb6579365fa3b7f8 Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:22:46 -0400 Subject: [PATCH 08/21] Fix Proxied Message Logging --- PluralKit.Bot/Utils/DiscordUtils.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index e3ed92d1..d64c3f46 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using DSharpPlus; @@ -57,7 +57,7 @@ namespace PluralKit.Bot (BotPermissions(channel) & permissionSet) == permissionSet; public static Instant SnowflakeToInstant(ulong snowflake) => - Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake << 22); + Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22); public static ulong InstantToSnowflake(Instant time) => (ulong) (time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds >> 22; From 2fd07c9ec9ba97031a463b6de072584de017a27c Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:47:35 -0400 Subject: [PATCH 09/21] Fix Periodic Methods --- PluralKit.Bot/Bot.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 7d7d7cad..3b2dac21 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Data; using System.Linq; +using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using App.Metrics; @@ -162,7 +163,11 @@ namespace PluralKit.Bot { // Change bot status var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); - await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); + try // DiscordClient may throw an exception if an update is attempted while reconnecting (e.g just after OP 7 received) + { + await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); + } + catch (WebSocketException) { } // Run webhook rate limit GC every 10 minutes if (_periodicUpdateCount++ % 10 == 0) @@ -176,9 +181,9 @@ namespace PluralKit.Bot await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); } - /*private Task ShardReady(DiscordSocketClient shardClient) + private Task ShardReady(DiscordClient shardClient) { - _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Channels.Count), shardClient.Guilds.Count); + _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Value.Channels.Count), shardClient.Guilds.Count); if (shardClient.ShardId == 0) { @@ -190,7 +195,7 @@ namespace PluralKit.Bot } return Task.CompletedTask; - }*/ + } private Task HandleEvent(Func handler) { From 25ce2539bc91cea4e986f9530af8023a8c3b7aa7 Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:48:10 -0400 Subject: [PATCH 10/21] Remove Extraneous Comment --- PluralKit.Bot/Commands/Misc.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index 98dcf3de..b91448ce 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -183,7 +183,7 @@ namespace PluralKit.Bot { var message = await _data.GetMessage(messageId); if (message == null) throw Errors.MessageNotFound(messageId); - await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message)); //TODO: test this + await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(ctx.Shard, message)); } } } \ No newline at end of file From adb4262237964555408e028adf8f42bb004f34bb Mon Sep 17 00:00:00 2001 From: Fennel Date: Fri, 24 Apr 2020 17:50:34 -0400 Subject: [PATCH 11/21] Remove Extraneous Using Alias --- PluralKit.Bot/Commands/SystemFront.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 1abe995b..1ceac707 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -1,4 +1,4 @@ -using ArgumentException = System.ArgumentException; +using System; using System.Linq; using System.Threading.Tasks; From 66dd85050467fff4c48e2782e23819037b3f1dd1 Mon Sep 17 00:00:00 2001 From: Fennel Date: Sat, 25 Apr 2020 12:53:50 -0400 Subject: [PATCH 12/21] Fix SocketErrored and Ready events --- PluralKit.Bot/Bot.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 3b2dac21..f4e20706 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -119,8 +119,9 @@ namespace PluralKit.Bot public Task Init() { - // _client.ShardDisconnected += ShardDisconnected; - // _client.ShardReady += ShardReady; + // DiscordShardedClient SocketErrored/Ready events also fire whenever an individual shard's respective events fire + _client.SocketErrored += ShardDisconnected; + _client.Ready += ShardReady; _client.DebugLogger.LogMessageReceived += FrameworkLog; _client.MessageCreated += args => HandleEvent(eh => eh.HandleMessage(args)); @@ -134,11 +135,11 @@ namespace PluralKit.Bot return Task.CompletedTask; } - /*private Task ShardDisconnected(Exception ex, DiscordSocketClient shard) + private Task ShardDisconnected(SocketErrorEventArgs e) { - _logger.Warning(ex, $"Shard #{shard.ShardId} disconnected"); + _logger.Warning(e.Exception, $"Shard #{e.Client.ShardId} disconnected"); return Task.CompletedTask; - }*/ + } private void FrameworkLog(object sender, DebugLogMessageEventArgs args) { @@ -163,7 +164,7 @@ namespace PluralKit.Bot { // Change bot status var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); - try // DiscordClient may throw an exception if an update is attempted while reconnecting (e.g just after OP 7 received) + try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) { await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); } @@ -181,11 +182,11 @@ namespace PluralKit.Bot await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); } - private Task ShardReady(DiscordClient shardClient) + private Task ShardReady(ReadyEventArgs e) { - _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", shardClient.ShardId, shardClient.Guilds.Sum(g => g.Value.Channels.Count), shardClient.Guilds.Count); + _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", e.Client.ShardId, e.Client.Guilds.Sum(g => g.Value.Channels.Count), e.Client.Guilds.Count); - if (shardClient.ShardId == 0) + if (e.Client.ShardId == 0) { _updateTimer = new Timer((_) => { HandleEvent(_ => UpdatePeriodic()); From 35e30f481bb27ab86889f97f47ccc4137d3a63bf Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 00:04:53 +0200 Subject: [PATCH 13/21] Fix error in stats collection task --- PluralKit.Bot/Services/PeriodicStatCollector.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index 10a8b225..75d68165 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -72,7 +72,9 @@ namespace PluralKit.Bot foreach (var user in guild.Members.Values) { usersKnown.Add(user.Id); - if (user.Presence.Status == UserStatus.Online) usersOnline.Add(user.Id); + + // Presence updates are disabled, for now we just assume every user is online, I guess + usersOnline.Add(user.Id); } _metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count); From 9b6f79a508b352d6c11863f1016b7910ca52702e Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 00:05:26 +0200 Subject: [PATCH 14/21] Factor DiscordConfiguration out into DI --- PluralKit.Bot/Modules.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 051c2d78..bd90468e 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -16,18 +16,15 @@ namespace PluralKit.Bot protected override void Load(ContainerBuilder builder) { // Clients - builder.Register(c => new DiscordShardedClient(new DiscordConfiguration - { - Token = c.Resolve().Token, - TokenType = TokenType.Bot, - MessageCacheSize = 0, - })).AsSelf().SingleInstance(); - builder.Register(c => new DiscordRestClient(new DiscordConfiguration + builder.Register(c => new DiscordConfiguration { Token = c.Resolve().Token, TokenType = TokenType.Bot, MessageCacheSize = 0, - })).AsSelf().SingleInstance(); + LargeThreshold = 50 + }).AsSelf(); + builder.Register(c => new DiscordShardedClient(c.Resolve())).AsSelf().SingleInstance(); + builder.Register(c => new DiscordRestClient(c.Resolve())).AsSelf().SingleInstance(); // Commands builder.RegisterType().AsSelf(); @@ -69,8 +66,7 @@ namespace PluralKit.Bot // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); - - + // Utils builder.Register(c => new HttpClient { From 86b6a90b1c2a28c67cee3dd1f377d7f7f7d9245e Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 00:06:40 +0200 Subject: [PATCH 15/21] Add proper credit to MentionUtils --- PluralKit.Bot/Utils/MentionUtils.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PluralKit.Bot/Utils/MentionUtils.cs b/PluralKit.Bot/Utils/MentionUtils.cs index cd6e9034..73444ce0 100644 --- a/PluralKit.Bot/Utils/MentionUtils.cs +++ b/PluralKit.Bot/Utils/MentionUtils.cs @@ -1,9 +1,11 @@ using System; using System.Globalization; -using System.Text; namespace PluralKit.Bot.Utils { + // PK note: class is wholesale copied from Discord.NET (MIT-licensed) + // https://github.com/discord-net/Discord.Net/blob/ff0fea98a65d907fbce07856f1a9ef4aebb9108b/src/Discord.Net.Core/Utils/MentionUtils.cs + /// /// Provides a series of helper methods for parsing mentions. /// From 9fa36b78ffdf9ecf62b5c6c34bfe157ec6dfaab1 Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 00:25:01 +0200 Subject: [PATCH 16/21] Fix the garish default D#+ colors --- PluralKit.Bot/Commands/Help.cs | 2 +- PluralKit.Bot/Commands/Misc.cs | 4 ++-- PluralKit.Bot/Services/EmbedService.cs | 12 ++++++------ PluralKit.Bot/Utils/DiscordUtils.cs | 5 +++++ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index de4a6d12..b52f6d36 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -20,7 +20,7 @@ namespace PluralKit.Bot .AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.") .AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78") .WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/") - .WithColor(DiscordColor.Blue) + .WithColor(DiscordUtils.Blue) .Build()); } } diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index b91448ce..095e0298 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -147,7 +147,7 @@ namespace PluralKit.Bot { if (permissionsMissing.Count == 0) { - eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordColor.Green); + eb.WithDescription($"No errors found, all channels proxyable :)").WithColor(DiscordUtils.Green); } else { @@ -161,7 +161,7 @@ namespace PluralKit.Bot { .OrderBy(c => c.Position) .Select(c => $"#{c.Name}")); eb.AddField($"Missing *{missingPermissionNames}*", channelsList.Truncate(1000)); - eb.WithColor(DiscordColor.Red); + eb.WithColor(DiscordUtils.Red); } } diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index 7be34979..f3f35cd8 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -31,7 +31,7 @@ namespace PluralKit.Bot { var memberCount = await _data.GetSystemMemberCount(system, false); var eb = new DiscordEmbedBuilder() - .WithColor(DiscordColor.Blue) + .WithColor(DiscordUtils.Gray) .WithTitle(system.Name ?? null) .WithThumbnailUrl(system.AvatarUrl ?? null) .WithFooter($"System ID: {system.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}"); @@ -81,14 +81,14 @@ namespace PluralKit.Bot { DiscordColor color; try { - color = member.Color?.ToDiscordColor() ?? DiscordColor.Gray; + color = member.Color?.ToDiscordColor() ?? DiscordUtils.Gray; } catch (ArgumentException) { // Bad API use can cause an invalid color string // TODO: fix that in the API // for now we just default to a blank color, yolo - color = DiscordColor.Gray; + color = DiscordUtils.Gray; } var messageCount = await _data.GetMemberMessageCount(member); @@ -102,7 +102,7 @@ namespace PluralKit.Bot { var eb = new DiscordEmbedBuilder() // TODO: add URL of website when that's up .WithAuthor(name, avatar) - .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordColor.Gray) + .WithColor(member.MemberPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) .WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {DateTimeFormats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}"); var description = ""; @@ -133,7 +133,7 @@ namespace PluralKit.Bot { var members = await _data.GetSwitchMembers(sw).ToListAsync(); var timeSinceSwitch = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp; return new DiscordEmbedBuilder() - .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordColor.Blue) + .WithColor(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray) .AddField($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", members.Count > 0 ? string.Join(", ", members.Select(m => m.Name)) : "*(no fronter)*") .AddField("Since", $"{DateTimeFormats.ZonedDateTimeFormat.Format(sw.Timestamp.InZone(zone))} ({DateTimeFormats.DurationFormat.Format(timeSinceSwitch)} ago)") .Build(); @@ -185,7 +185,7 @@ namespace PluralKit.Bot { { var actualPeriod = breakdown.RangeEnd - breakdown.RangeStart; var eb = new DiscordEmbedBuilder() - .WithColor(DiscordColor.Blue) + .WithColor(DiscordUtils.Gray) .WithFooter($"Since {DateTimeFormats.ZonedDateTimeFormat.Format(breakdown.RangeStart.InZone(tz))} ({DateTimeFormats.DurationFormat.Format(actualPeriod)} ago)"); var maxEntriesToDisplay = 24; // max 25 fields allowed in embed - reserve 1 for "others" diff --git a/PluralKit.Bot/Utils/DiscordUtils.cs b/PluralKit.Bot/Utils/DiscordUtils.cs index d64c3f46..0598d7d1 100644 --- a/PluralKit.Bot/Utils/DiscordUtils.cs +++ b/PluralKit.Bot/Utils/DiscordUtils.cs @@ -10,6 +10,11 @@ namespace PluralKit.Bot { public static class DiscordUtils { + public static DiscordColor Blue = new DiscordColor(0x1f99d8); + public static DiscordColor Green = new DiscordColor(0x00cc78); + public static DiscordColor Red = new DiscordColor(0xef4b3d); + public static DiscordColor Gray = new DiscordColor(0x979c9f); + public static string NameAndMention(this DiscordUser user) { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } From 483a9d6ed940a9ac62d1d4fdcaf30c03bbad5389 Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 00:25:31 +0200 Subject: [PATCH 17/21] Save log file as both text and JSON --- PluralKit.Core/Modules.cs | 47 +++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/PluralKit.Core/Modules.cs b/PluralKit.Core/Modules.cs index 597f5b71..8589eb0f 100644 --- a/PluralKit.Core/Modules.cs +++ b/PluralKit.Core/Modules.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using App.Metrics; @@ -95,20 +96,58 @@ namespace PluralKit.Core private ILogger InitLogger(CoreConfig config) { + var outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.ffffff}] {Level:u3} {Message:lj}{NewLine}{Exception}"; + return new LoggerConfiguration() .ConfigureForNodaTime(DateTimeZoneProviders.Tzdb) .MinimumLevel.Debug() .WriteTo.Async(a => + { + // Both the same output, except one is raw compact JSON and one is plain text. + // Output simultaneously. May remove the JSON formatter later, keeping it just in cast. + // Flush interval is 250ms (down from 10s) to make "tail -f" easier. May be too low? + a.File( + (config.LogDir ?? "logs") + $"/pluralkit.{_component}.json", + outputTemplate: outputTemplate, + rollingInterval: RollingInterval.Day, + flushToDiskInterval: TimeSpan.FromMilliseconds(250), + restrictedToMinimumLevel: LogEventLevel.Information, + formatProvider: new UTCTimestampFormatProvider(), + buffered: true); + a.File( new RenderedCompactJsonFormatter(), - (config.LogDir ?? "logs") + $"/pluralkit.{_component}.log", + (config.LogDir ?? "logs") + $"/pluralkit.{_component}.json", rollingInterval: RollingInterval.Day, - flushToDiskInterval: TimeSpan.FromSeconds(10), + flushToDiskInterval: TimeSpan.FromMilliseconds(250), restrictedToMinimumLevel: LogEventLevel.Information, - buffered: true)) + buffered: true); + }) + // TODO: render as UTC in the console, too? or just in log files .WriteTo.Async(a => - a.Console(theme: AnsiConsoleTheme.Code, outputTemplate:"[{Timestamp:HH:mm:ss}] {Level:u3} {Message:lj}{NewLine}{Exception}")) + a.Console(theme: AnsiConsoleTheme.Code, outputTemplate: outputTemplate, formatProvider: new UTCTimestampFormatProvider())) .CreateLogger(); } } + + // Serilog why is this necessary for such a simple thing >.> + public class UTCTimestampFormatProvider: IFormatProvider + { + public object GetFormat(Type formatType) => new UTCTimestampFormatter(); + } + + public class UTCTimestampFormatter: ICustomFormatter + { + public string Format(string format, object arg, IFormatProvider formatProvider) + { + // Convert offset to UTC and then print + // FormatProvider defaults to locale-specific stuff so we force-default to invariant culture + // If we pass the given formatProvider it'll conveniently ignore it, for some reason >.> + if (arg is DateTimeOffset dto) + return dto.ToUniversalTime().ToString(format, CultureInfo.InvariantCulture); + if (arg is IFormattable f) + return f.ToString(format, CultureInfo.InvariantCulture); + return arg.ToString(); + } + } } \ No newline at end of file From 12aef1f61d7a43d7902eb33681a3c82db4c98199 Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 01:14:49 +0200 Subject: [PATCH 18/21] Refactor periodic event loop --- PluralKit.Bot/Bot.cs | 59 +++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index f4e20706..4e49b8ac 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -15,6 +15,8 @@ using DSharpPlus.EventArgs; using Microsoft.Extensions.Configuration; +using NodaTime; + using PluralKit.Core; using Sentry; @@ -106,7 +108,8 @@ namespace PluralKit.Bot private ILogger _logger; private WebhookRateLimitService _webhookRateLimit; private int _periodicUpdateCount; - + private Task _periodicWorker; + public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit) { _services = services; @@ -131,6 +134,9 @@ namespace PluralKit.Bot _client.MessageUpdated += args => HandleEvent(eh => eh.HandleMessageEdited(args)); _services.Resolve().Init(_client); + + // Will not be awaited, just runs in the background + _periodicWorker = UpdatePeriodic(); return Task.CompletedTask; } @@ -158,43 +164,40 @@ namespace PluralKit.Bot _logger.Write(level, args.Exception, "D#+ {Source}: {Message}", args.Application, args.Message); } - - // Method called every 60 seconds + private async Task UpdatePeriodic() { - // Change bot status - var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); - try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) + while (true) { - await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); - } - catch (WebSocketException) { } + // Run at every whole minute (:00), mostly because I feel like it + var timeNow = SystemClock.Instance.GetCurrentInstant(); + var timeTillNextWholeMinute = 60000 - (timeNow.ToUnixTimeMilliseconds() % 60000); + await Task.Delay((int) timeTillNextWholeMinute); + + // Change bot status + var totalGuilds = _client.ShardClients.Values.Sum(c => c.Guilds.Count); + try // DiscordClient may throw an exception if the socket is closed (e.g just after OP 7 received) + { + await _client.UpdateStatusAsync(new DiscordActivity($"pk;help | in {totalGuilds} servers")); + } + catch (WebSocketException) { } - // Run webhook rate limit GC every 10 minutes - if (_periodicUpdateCount++ % 10 == 0) - { - var _ = Task.Run(() => _webhookRateLimit.GarbageCollect()); - } + // Run webhook rate limit GC every 10 minutes + if (_periodicUpdateCount++ % 10 == 0) + { + var _ = Task.Run(() => _webhookRateLimit.GarbageCollect()); + } - await _collector.CollectStats(); - - _logger.Information("Submitted metrics to backend"); - await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); + await _collector.CollectStats(); + + _logger.Information("Submitted metrics to backend"); + await Task.WhenAll(((IMetricsRoot) _metrics).ReportRunner.RunAllAsync()); + } } private Task ShardReady(ReadyEventArgs e) { _logger.Information("Shard {Shard} connected to {ChannelCount} channels in {GuildCount} guilds", e.Client.ShardId, e.Client.Guilds.Sum(g => g.Value.Channels.Count), e.Client.Guilds.Count); - - if (e.Client.ShardId == 0) - { - _updateTimer = new Timer((_) => { - HandleEvent(_ => UpdatePeriodic()); - }, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); - - _logger.Information("PluralKit started as {Username}#{Discriminator} ({Id})", _client.CurrentUser.Username, _client.CurrentUser.Discriminator, _client.CurrentUser.Id); - } - return Task.CompletedTask; } From 697a24c4fa9e4f8099852308053647a0ccc0a46f Mon Sep 17 00:00:00 2001 From: Ske Date: Wed, 29 Apr 2020 02:35:46 +0200 Subject: [PATCH 19/21] Remove unused assignment --- PluralKit.Bot/Commands/SystemFront.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index 1ceac707..606e6ed6 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -63,7 +63,6 @@ namespace PluralKit.Bot embedTitle, async (builder, switches) => { - var outputStr = ""; foreach (var entry in switches) { var lastSw = entry.LastTime; @@ -98,8 +97,6 @@ namespace PluralKit.Bot break; }// TODO: Make sure this works } - - builder.Description = outputStr; } ); } From 042327d4aa5d59568c7d49c9f255755aef4e44f2 Mon Sep 17 00:00:00 2001 From: Ske Date: Thu, 30 Apr 2020 23:55:12 +0200 Subject: [PATCH 20/21] Add online user counting back (ish) --- PluralKit.Bot/Services/PeriodicStatCollector.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index 75d68165..ef2171b9 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -72,9 +72,8 @@ namespace PluralKit.Bot foreach (var user in guild.Members.Values) { usersKnown.Add(user.Id); - - // Presence updates are disabled, for now we just assume every user is online, I guess - usersOnline.Add(user.Id); + if (user.Presence?.Status == UserStatus.Online) + usersOnline.Add(user.Id); } _metrics.Measure.Gauge.SetValue(BotMetrics.MembersTotal, usersKnown.Count); From 546cb7f97a2992c736be433bb4f6c8a7d99363ea Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 1 May 2020 00:00:33 +0200 Subject: [PATCH 21/21] Remove webhook rate limit cache The move to DSharpPlus makes it unnecessary, as D#+ can actually do webhook invocations on its own. --- PluralKit.Bot/Bot.cs | 12 +- PluralKit.Bot/Modules.cs | 1 - .../Services/PeriodicStatCollector.cs | 5 +- .../Services/WebhookExecutorService.cs | 4 +- .../Services/WebhookRateLimitService.cs | 128 ------------------ 5 files changed, 3 insertions(+), 147 deletions(-) delete mode 100644 PluralKit.Bot/Services/WebhookRateLimitService.cs diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index 4e49b8ac..bde58fe5 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -102,21 +102,17 @@ namespace PluralKit.Bot { private ILifetimeScope _services; private DiscordShardedClient _client; - private Timer _updateTimer; private IMetrics _metrics; private PeriodicStatCollector _collector; private ILogger _logger; - private WebhookRateLimitService _webhookRateLimit; - private int _periodicUpdateCount; private Task _periodicWorker; - public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger, WebhookRateLimitService webhookRateLimit) + public Bot(ILifetimeScope services, DiscordShardedClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger) { _services = services; _client = client; _metrics = metrics; _collector = collector; - _webhookRateLimit = webhookRateLimit; _logger = logger.ForContext(); } @@ -182,12 +178,6 @@ namespace PluralKit.Bot } catch (WebSocketException) { } - // Run webhook rate limit GC every 10 minutes - if (_periodicUpdateCount++ % 10 == 0) - { - var _ = Task.Run(() => _webhookRateLimit.GarbageCollect()); - } - await _collector.CollectStats(); _logger.Information("Submitted metrics to backend"); diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index bd90468e..16da31b9 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -55,7 +55,6 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); - builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); diff --git a/PluralKit.Bot/Services/PeriodicStatCollector.cs b/PluralKit.Bot/Services/PeriodicStatCollector.cs index ef2171b9..73114be3 100644 --- a/PluralKit.Bot/Services/PeriodicStatCollector.cs +++ b/PluralKit.Bot/Services/PeriodicStatCollector.cs @@ -24,13 +24,12 @@ namespace PluralKit.Bot private IDataStore _data; private WebhookCacheService _webhookCache; - private WebhookRateLimitService _webhookRateLimitCache; private DbConnectionCountHolder _countHolder; private ILogger _logger; - public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu, WebhookRateLimitService webhookRateLimitCache) + public PeriodicStatCollector(DiscordShardedClient client, IMetrics metrics, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder, IDataStore data, CpuStatService cpu) { _client = client; _metrics = metrics; @@ -38,7 +37,6 @@ namespace PluralKit.Bot _countHolder = countHolder; _data = data; _cpu = cpu; - _webhookRateLimitCache = webhookRateLimitCache; _logger = logger.ForContext(); } @@ -99,7 +97,6 @@ namespace PluralKit.Bot // Other shiz _metrics.Measure.Gauge.SetValue(BotMetrics.WebhookCacheSize, _webhookCache.CacheSize); - _metrics.Measure.Gauge.SetValue(BotMetrics.WebhookRateLimitCacheSize, _webhookRateLimitCache.CacheSize); stopwatch.Stop(); _logger.Information("Updated metrics in {Time}", stopwatch.ElapsedDuration()); diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index adc9d67d..4d2c3a15 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -31,17 +31,15 @@ namespace PluralKit.Bot public class WebhookExecutorService { private WebhookCacheService _webhookCache; - private WebhookRateLimitService _rateLimit; private ILogger _logger; private IMetrics _metrics; private HttpClient _client; - public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client, WebhookRateLimitService rateLimit) + public WebhookExecutorService(IMetrics metrics, WebhookCacheService webhookCache, ILogger logger, HttpClient client) { _metrics = metrics; _webhookCache = webhookCache; _client = client; - _rateLimit = rateLimit; _logger = logger.ForContext(); } diff --git a/PluralKit.Bot/Services/WebhookRateLimitService.cs b/PluralKit.Bot/Services/WebhookRateLimitService.cs deleted file mode 100644 index 9468bcaf..00000000 --- a/PluralKit.Bot/Services/WebhookRateLimitService.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Http; - -using DSharpPlus.Entities; - -using NodaTime; - -using Serilog; - -namespace PluralKit.Bot -{ - // Simplified rate limit handler for webhooks only, disregards most bucket functionality since scope is limited and just denies requests if too fast. - public class WebhookRateLimitService - { - private ILogger _logger; - private ConcurrentDictionary _info = new ConcurrentDictionary(); - - public WebhookRateLimitService(ILogger logger) - { - _logger = logger.ForContext(); - } - - public int CacheSize => _info.Count; - - public bool TryExecuteWebhook(DiscordWebhook webhook) - { - // If we have nothing saved, just allow it (we'll save something once the response returns) - if (!_info.TryGetValue(webhook.Id, out var info)) return true; - - // If we're past the reset time, allow the request and update the bucket limit - if (SystemClock.Instance.GetCurrentInstant() > info.resetTime) - { - if (!info.hasResetTimeExpired) - info.remaining = info.maxLimit; - info.hasResetTimeExpired = true; - - // We can hit this multiple times if many requests are in flight before a real one gets "back", so we still - // decrement the remaining request count, this basically "blacklists" the channel given continuous spam until *one* of the requests come back with new rate limit headers - info.remaining--; - - return true; - } - - // If we don't have any more requests left, deny the request - if (info.remaining == 0) - { - _logger.Debug("Rate limit bucket for {Webhook} out of requests, denying request", webhook.Id); - return false; - } - - // Otherwise, decrement the request count and allow the request - info.remaining--; - return true; - } - - public void UpdateRateLimitInfo(DiscordWebhook webhook, HttpResponseMessage response) - { - var info = _info.GetOrAdd(webhook.Id, _ => new WebhookRateLimitInfo()); - - if (int.TryParse(GetHeader(response, "X-RateLimit-Limit"), out var limit)) - info.maxLimit = limit; - - // Max "safe" is way above UNIX timestamp values, and we get fractional seconds, hence the double - // but need culture/format specifiers to get around Some Locales (cough, my local PC) having different settings for decimal point... - // We also use Reset-After to avoid issues with clock desync between us and Discord's server, this way it's all relative (plus latency errors work in our favor) - if (double.TryParse(GetHeader(response, "X-RateLimit-Reset-After"), NumberStyles.Float, CultureInfo.InvariantCulture, out var resetTimestampDelta)) - { - var resetTime = SystemClock.Instance.GetCurrentInstant() + Duration.FromSeconds(resetTimestampDelta); - if (resetTime > info.resetTime) - { - // Set to the *latest* reset value we have (for safety), since we rely on relative times this can jitter a bit - info.resetTime = resetTime; - info.hasResetTimeExpired = false; - } - } - - if (int.TryParse(GetHeader(response, "X-RateLimit-Remaining"), out var remainingRequests)) - // Overwrite a negative "we don't know" value with whatever we just got - // Otherwise, *lower* remaining requests takes precedence - if (info.remaining < 0 || remainingRequests < info.remaining) - info.remaining = remainingRequests; - - _logger.Debug("Updated rate limit information for {Webhook}, bucket has {RequestsRemaining} requests remaining, reset in {ResetTime}", webhook.Id, info.remaining, info.resetTime - SystemClock.Instance.GetCurrentInstant()); - - if (response.StatusCode == HttpStatusCode.TooManyRequests) - { - // 429, we're *definitely* out of requests - info.remaining = 0; - _logger.Warning("Got 429 Too Many Requests when invoking webhook {Webhook}, next bucket reset in {ResetTime}", webhook.Id, info.resetTime - SystemClock.Instance.GetCurrentInstant()); - } - } - - public void GarbageCollect() - { - _logger.Information("Garbage-collecting webhook rate limit buckets..."); - - var collected = 0; - foreach (var channel in _info.Keys) - { - if (!_info.TryGetValue(channel, out var info)) continue; - - // Remove all keys that expired more than an hour ago (and of course, haven't been reset) - if (info.resetTime < SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1)) - if (_info.TryRemove(channel, out _)) collected++; - } - - _logger.Information("Garbage-collected {ChannelCount} channels from the webhook rate limit buckets.", collected); - } - - private string GetHeader(HttpResponseMessage response, string key) - { - var firstPair = response.Headers.FirstOrDefault(pair => pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)); - return firstPair.Value?.FirstOrDefault(); // If key is missing, default value is null - } - - private class WebhookRateLimitInfo - { - public Instant resetTime; - public bool hasResetTimeExpired; - public int remaining = -1; - public int maxLimit = 0; - } - } -} \ No newline at end of file