From 23cf06df4ca5b41006c7a80992e1fb151ab3480e Mon Sep 17 00:00:00 2001 From: Ske Date: Fri, 17 Apr 2020 23:10:01 +0200 Subject: [PATCH] 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; }