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