Port some things, still does not compile
This commit is contained in:
		@@ -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<CoreConfig>();
 | 
			
		||||
                var botConfig = services.Resolve<BotConfig>();
 | 
			
		||||
                var schema = services.Resolve<SchemaService>();
 | 
			
		||||
 | 
			
		||||
                using var _ = Sentry.SentrySdk.Init(coreConfig.SentryUrl);
 | 
			
		||||
@@ -71,10 +73,9 @@ namespace PluralKit.Bot
 | 
			
		||||
 | 
			
		||||
                logger.Information("Connecting to Discord");
 | 
			
		||||
                var client = services.Resolve<DiscordShardedClient>();
 | 
			
		||||
                await client.LoginAsync(TokenType.Bot, botConfig.Token);
 | 
			
		||||
 | 
			
		||||
                logger.Information("Initializing bot");
 | 
			
		||||
                await client.StartAsync();
 | 
			
		||||
                
 | 
			
		||||
                logger.Information("Initializing bot");
 | 
			
		||||
                await services.Resolve<Bot>().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<ShardInfoService>().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<PKEventHandler, Task> 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<string, string>
 | 
			
		||||
            {
 | 
			
		||||
                {"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 (<https://discord.gg/PczBt78>), 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<IUserMessage, ulong> message, ISocketMessageChannel channel,
 | 
			
		||||
            SocketReaction reaction)
 | 
			
		||||
        public Task HandleReactionAdded(MessageReactionAddEventArgs args)
 | 
			
		||||
        {
 | 
			
		||||
            _sentryScope.AddBreadcrumb("", "event.reaction", data: new Dictionary<string, string>()
 | 
			
		||||
            {
 | 
			
		||||
                {"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<IMessage, ulong> message, ISocketMessageChannel channel)
 | 
			
		||||
        public Task HandleMessageDeleted(MessageDeleteEventArgs args)
 | 
			
		||||
        {
 | 
			
		||||
            _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
 | 
			
		||||
            {
 | 
			
		||||
                {"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<Cacheable<IMessage, ulong>> messages,
 | 
			
		||||
                                             IMessageChannel channel)
 | 
			
		||||
        public Task HandleMessagesBulkDelete(MessageBulkDeleteEventArgs args)
 | 
			
		||||
        {
 | 
			
		||||
            _sentryScope.AddBreadcrumb("", "event.messageDelete", data: new Dictionary<string, string>()
 | 
			
		||||
            {
 | 
			
		||||
                {"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<IMessage, ulong> oldMessage, SocketMessage newMessage, ISocketMessageChannel channel)
 | 
			
		||||
        public async Task HandleMessageEdited(MessageUpdateEventArgs args)
 | 
			
		||||
        {
 | 
			
		||||
            _sentryScope.AddBreadcrumb(newMessage.Content, "event.messageEdit", data: new Dictionary<string, string>()
 | 
			
		||||
            _sentryScope.AddBreadcrumb(args.Message.Content ?? "<unknown>", "event.messageEdit", data: new Dictionary<string, string>()
 | 
			
		||||
            {
 | 
			
		||||
                {"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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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<DiscordShardedClient>();
 | 
			
		||||
            _message = message;
 | 
			
		||||
            _shard = shard;
 | 
			
		||||
            _data = provider.Resolve<IDataStore>();
 | 
			
		||||
            _senderSystem = senderSystem;
 | 
			
		||||
            _metrics = provider.Resolve<IMetrics>();
 | 
			
		||||
@@ -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<IUserMessage> Reply(string text = null, Embed embed = null)
 | 
			
		||||
        public Task<DiscordMessage> 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<IUser> MatchUser()
 | 
			
		||||
        public async Task<DiscordUser> 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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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<BaseDiscordClient>().As<BaseSocketClient>().As<IDiscordClient>().SingleInstance();
 | 
			
		||||
            builder.Register(c => new DiscordShardedClient(new DiscordConfiguration
 | 
			
		||||
                {
 | 
			
		||||
                    Token = c.Resolve<BotConfig>().Token,
 | 
			
		||||
                    TokenType = TokenType.Bot,
 | 
			
		||||
                    MessageCacheSize = 0,
 | 
			
		||||
                })).AsSelf().SingleInstance();
 | 
			
		||||
            
 | 
			
		||||
            // Commands
 | 
			
		||||
            builder.RegisterType<CommandTree>().AsSelf();
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,6 @@
 | 
			
		||||
    </PropertyGroup>
 | 
			
		||||
 | 
			
		||||
    <ItemGroup>
 | 
			
		||||
      <ProjectReference Include="..\Discord.Net\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" />
 | 
			
		||||
      <ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
 | 
			
		||||
    </ItemGroup>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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<Embed> CreateSystemEmbed(PKSystem system, LookupContext ctx) {
 | 
			
		||||
        public async Task<DiscordEmbed> 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<Embed> CreateMemberEmbed(PKSystem system, PKMember member, IGuild guild, LookupContext ctx)
 | 
			
		||||
        public async Task<DiscordEmbed> 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<Embed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone)
 | 
			
		||||
        public async Task<DiscordEmbed> 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<Embed> CreateMessageInfoEmbed(FullMessage msg)
 | 
			
		||||
        public async Task<DiscordEmbed> 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<IRole> roles = null;
 | 
			
		||||
            ICollection<DiscordRole> 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<Embed> CreateFrontPercentEmbed(FrontBreakdown breakdown, DateTimeZone tz)
 | 
			
		||||
        public Task<DiscordEmbed> 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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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<ulong, ulong> _cache = new ConcurrentDictionary<ulong, ulong>();
 | 
			
		||||
 
 | 
			
		||||
@@ -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<LogChannelService>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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<LoggerBot> 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<SocketMessage, ulong?> ExtractFunc;
 | 
			
		||||
            public Func<SocketMessage, FuzzyExtractResult?> FuzzyExtractFunc;
 | 
			
		||||
            public Func<DiscordMessage, ulong?> ExtractFunc;
 | 
			
		||||
            public Func<DiscordMessage, FuzzyExtractResult?> FuzzyExtractFunc;
 | 
			
		||||
            public string WebhookName;
 | 
			
		||||
 | 
			
		||||
            public LoggerBot(string name, ulong id, Func<SocketMessage, ulong?> extractFunc = null, Func<SocketMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
 | 
			
		||||
            public LoggerBot(string name, ulong id, Func<DiscordMessage, ulong?> extractFunc = null, Func<DiscordMessage, FuzzyExtractResult?> fuzzyExtractFunc = null, string webhookName = null)
 | 
			
		||||
            {
 | 
			
		||||
                Name = name;
 | 
			
		||||
                Id = id;
 | 
			
		||||
 
 | 
			
		||||
@@ -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<ulong>();
 | 
			
		||||
            var usersOnline = new HashSet<ulong>();
 | 
			
		||||
            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);
 | 
			
		||||
 
 | 
			
		||||
@@ -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<ProxyMatch> GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, IMessage message, IGuildChannel channel)
 | 
			
		||||
        private async Task<ProxyMatch> 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<string> 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<bool> EnsureBotPermissions(ITextChannel channel)
 | 
			
		||||
        private async Task<bool> 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<IUserMessage, ulong> 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<IUserMessage, ulong> 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<IUserMessage, ulong> 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<IUserMessage, ulong> 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<IMessage, ulong> 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<Cacheable<IMessage, ulong>> 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());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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<ulong, Lazy<Task<IWebhook>>> _webhooks;
 | 
			
		||||
        private ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>> _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<WebhookCacheService>();
 | 
			
		||||
            _webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>();
 | 
			
		||||
            _webhooks = new ConcurrentDictionary<ulong, Lazy<Task<DiscordWebhook>>>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public async Task<IWebhook> GetWebhook(ulong channelId)
 | 
			
		||||
        public async Task<DiscordWebhook> 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<IWebhook> GetWebhook(ITextChannel channel)
 | 
			
		||||
        public async Task<DiscordWebhook> GetWebhook(DiscordChannel channel)
 | 
			
		||||
        {
 | 
			
		||||
            // We cache the webhook through a Lazy<Task<T>>, 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<T> wrapper will stop the
 | 
			
		||||
            // webhook from being created twice.
 | 
			
		||||
            var lazyWebhookValue =    
 | 
			
		||||
                _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => GetOrCreateWebhook(channel)));
 | 
			
		||||
                _webhooks.GetOrAdd(channel.Id, new Lazy<Task<DiscordWebhook>>(() => 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<IWebhook> InvalidateAndRefreshWebhook(IWebhook webhook)
 | 
			
		||||
        public async Task<DiscordWebhook> 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<IWebhook> GetOrCreateWebhook(ITextChannel channel)
 | 
			
		||||
        private async Task<DiscordWebhook> 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<IWebhook> FindExistingWebhook(ITextChannel channel)
 | 
			
		||||
        private async Task<DiscordWebhook> FindExistingWebhook(DiscordChannel channel)
 | 
			
		||||
        {
 | 
			
		||||
            _logger.Debug("Finding webhook for channel {Channel}", channel.Id);
 | 
			
		||||
            try
 | 
			
		||||
@@ -78,13 +80,13 @@ namespace PluralKit.Bot
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private Task<IWebhook> DoCreateWebhook(ITextChannel channel)
 | 
			
		||||
        private Task<DiscordWebhook> 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;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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<WebhookExecutorService>();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        public async Task<ulong> ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IReadOnlyCollection<IAttachment> attachments)
 | 
			
		||||
        public async Task<ulong> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> 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<ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content,
 | 
			
		||||
            IReadOnlyCollection<IAttachment> attachments, bool hasRetried = false)
 | 
			
		||||
        private async Task<ulong> ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
 | 
			
		||||
            IReadOnlyList<DiscordAttachment> 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<JObject>(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<int>();
 | 
			
		||||
                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<IReadOnlyCollection<DiscordAttachment>> 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<DiscordAttachment> 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<ulong>();
 | 
			
		||||
            foreach (var (attachment, attachmentStream) in await Task.WhenAll(attachments.Select(GetStream)))
 | 
			
		||||
                dwb.AddFile(attachment.FileName, attachmentStream);
 | 
			
		||||
        }
 | 
			
		||||
        private IReadOnlyCollection<IReadOnlyCollection<IAttachment>> ChunkAttachmentsOrThrow(
 | 
			
		||||
            IReadOnlyCollection<IAttachment> attachments, int sizeThreshold)
 | 
			
		||||
 | 
			
		||||
        private IReadOnlyList<IReadOnlyCollection<DiscordAttachment>> ChunkAttachmentsOrThrow(
 | 
			
		||||
            IReadOnlyList<DiscordAttachment> 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<List<IAttachment>>();
 | 
			
		||||
            var list = new List<IAttachment>();
 | 
			
		||||
            var chunks = new List<List<DiscordAttachment>>();
 | 
			
		||||
            var list = new List<DiscordAttachment>();
 | 
			
		||||
            
 | 
			
		||||
            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<IAttachment>();
 | 
			
		||||
                    list = new List<DiscordAttachment>();
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                list.Add(attachment);
 | 
			
		||||
@@ -175,20 +157,6 @@ namespace PluralKit.Bot
 | 
			
		||||
            return chunks;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        private async Task AddAttachmentsToMultipart(MultipartFormDataContent content,
 | 
			
		||||
                                               IReadOnlyCollection<IAttachment> 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
 | 
			
		||||
 
 | 
			
		||||
@@ -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());
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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<bool> PromptYesNo(this Context ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) {
 | 
			
		||||
        public static async Task<bool> 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<SocketReaction> AwaitReaction(this Context ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) {
 | 
			
		||||
            var tcs = new TaskCompletionSource<SocketReaction>();
 | 
			
		||||
            Task Inner(Cacheable<IUserMessage, ulong> _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<MessageReactionAddEventArgs> AwaitReaction(this Context ctx, DiscordMessage message, DiscordUser user = null, Func<MessageReactionAddEventArgs, bool> predicate = null, TimeSpan? timeout = null) {
 | 
			
		||||
            var tcs = new TaskCompletionSource<MessageReactionAddEventArgs>();
 | 
			
		||||
            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<IUserMessage> AwaitMessage(this Context ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) {
 | 
			
		||||
            var tcs = new TaskCompletionSource<IUserMessage>();
 | 
			
		||||
            Task Inner(SocketMessage msg) {
 | 
			
		||||
        public static async Task<DiscordMessage> AwaitMessage(this Context ctx, DiscordChannel channel, DiscordUser user = null, Func<DiscordMessage, bool> predicate = null, TimeSpan? timeout = null) {
 | 
			
		||||
            var tcs = new TaskCompletionSource<DiscordMessage>();
 | 
			
		||||
            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<bool> 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<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount, int itemsPerPage, string title, Func<EmbedBuilder, IEnumerable<T>, Task> renderer) {
 | 
			
		||||
        public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount, int itemsPerPage, string title, Func<DiscordEmbedBuilder, IEnumerable<T>, Task> renderer) {
 | 
			
		||||
            // TODO: make this generic enough we can use it in Choose<T> below
 | 
			
		||||
 | 
			
		||||
            var buffer = new List<T>();
 | 
			
		||||
            await using var enumerator = items.GetAsyncEnumerator();
 | 
			
		||||
            
 | 
			
		||||
            var pageCount = (totalCount / itemsPerPage) + 1;
 | 
			
		||||
            async Task<Embed> MakeEmbedForPage(int page)
 | 
			
		||||
            async Task<DiscordEmbed> 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<T> Choose<T>(this Context ctx, string description, IList<T> items, Func<T, string> 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<Task> 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);
 | 
			
		||||
            }
 | 
			
		||||
        } 
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -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<Permissions> 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));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user