diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index a9d99fa3..96d34c42 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -118,7 +118,7 @@ namespace PluralKit.Bot try { - await _proxy.HandleMessageAsync(evt.Client, cachedGuild, cachedAccount, msg, doAutoProxy: true); + await _proxy.HandleMessageAsync(evt.Client, cachedGuild, cachedAccount, msg, allowAutoproxy: true); } catch (PKError e) { diff --git a/PluralKit.Bot/Handlers/MessageDeleted.cs b/PluralKit.Bot/Handlers/MessageDeleted.cs index f98e143c..6a8bbc38 100644 --- a/PluralKit.Bot/Handlers/MessageDeleted.cs +++ b/PluralKit.Bot/Handlers/MessageDeleted.cs @@ -1,31 +1,39 @@ -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using DSharpPlus.EventArgs; -using Sentry; +using PluralKit.Core; + +using Serilog; namespace PluralKit.Bot { // Double duty :) public class MessageDeleted: IEventHandler, IEventHandler { - private readonly ProxyService _proxy; + private readonly IDataStore _data; + private readonly ILogger _logger; - public MessageDeleted(ProxyService proxy) + public MessageDeleted(IDataStore data, ILogger logger) { - _proxy = proxy; + _data = data; + _logger = logger.ForContext(); } - public Task Handle(MessageDeleteEventArgs evt) + public async Task Handle(MessageDeleteEventArgs evt) { - return _proxy.HandleMessageDeletedAsync(evt); + // Delete deleted webhook messages from the data store + // (if we don't know whether it's a webhook, delete it just to be safe) + if (!evt.Message.WebhookMessage) return; + await _data.DeleteMessage(evt.Message.Id); } - public Task Handle(MessageBulkDeleteEventArgs evt) + public async Task Handle(MessageBulkDeleteEventArgs evt) { - return _proxy.HandleMessageBulkDeleteAsync(evt); + // Same as above, but bulk + _logger.Information("Bulk deleting {Count} messages in channel {Channel}", evt.Messages.Count, evt.Channel.Id); + await _data.DeleteMessagesBulk(evt.Messages.Select(m => m.Id).ToList()); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/MessageEdited.cs b/PluralKit.Bot/Handlers/MessageEdited.cs index 551ecd05..476ab47d 100644 --- a/PluralKit.Bot/Handlers/MessageEdited.cs +++ b/PluralKit.Bot/Handlers/MessageEdited.cs @@ -45,7 +45,7 @@ namespace PluralKit.Bot var guild = await _proxyCache.GetGuildDataCached(evt.Channel.GuildId); // Just run the normal message handling stuff, with a flag to disable autoproxying - await _proxy.HandleMessageAsync(evt.Client, guild, account, evt.Message, doAutoProxy: false); + await _proxy.HandleMessageAsync(evt.Client, guild, account, evt.Message, allowAutoproxy: false); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Handlers/ReactionAdded.cs b/PluralKit.Bot/Handlers/ReactionAdded.cs index a87e6771..7752e82b 100644 --- a/PluralKit.Bot/Handlers/ReactionAdded.cs +++ b/PluralKit.Bot/Handlers/ReactionAdded.cs @@ -1,24 +1,126 @@ -using System.Collections.Generic; using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.Entities; using DSharpPlus.EventArgs; +using DSharpPlus.Exceptions; -using Sentry; +using PluralKit.Core; namespace PluralKit.Bot { public class ReactionAdded: IEventHandler { - private readonly ProxyService _proxy; + private IDataStore _data; + private EmbedService _embeds; - public ReactionAdded(ProxyService proxy) + public ReactionAdded(IDataStore data, EmbedService embeds) { - _proxy = proxy; + _data = data; + _embeds = embeds; } - public Task Handle(MessageReactionAddEventArgs evt) + public async Task Handle(MessageReactionAddEventArgs evt) + { + await TryHandleProxyMessageReactions(evt); + } + + private async ValueTask TryHandleProxyMessageReactions(MessageReactionAddEventArgs evt) { - return _proxy.HandleReactionAddedAsync(evt); + // Only proxies in guild text channels + if (evt.Channel.Type != ChannelType.Text) return; + + FullMessage msg; + switch (evt.Emoji.Name) + { + // Message deletion + case "\u274C": // Red X + if ((msg = await _data.GetMessage(evt.Message.Id)) != null) + await HandleDeleteReaction(evt, msg); + break; + + case "\u2753": // Red question mark + case "\u2754": // White question mark + if ((msg = await _data.GetMessage(evt.Message.Id)) != null) + await HandleQueryReaction(evt, msg); + break; + + case "\U0001F514": // Bell + case "\U0001F6CE": // Bellhop bell + case "\U0001F3D3": // Ping pong paddle (lol) + case "\u23F0": // Alarm clock + case "\u2757": // Exclamation mark + if ((msg = await _data.GetMessage(evt.Message.Id)) != null) + await HandlePingReaction(evt, msg); + break; + } + } + + private async ValueTask HandleDeleteReaction(MessageReactionAddEventArgs evt, FullMessage msg) + { + if (evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; + + // Can only delete your own message + if (msg.Message.Sender != evt.User.Id) return; + + try + { + await evt.Message.DeleteAsync(); + } + catch (NotFoundException) + { + // Message was deleted by something/someone else before we got to it + } + + await _data.DeleteMessage(evt.Message.Id); + } + + private async ValueTask HandleQueryReaction(MessageReactionAddEventArgs evt, FullMessage msg) + { + // Try to DM the user info about the message + var member = await evt.Guild.GetMemberAsync(evt.User.Id); + try + { + await member.SendMessageAsync(embed: await _embeds.CreateMemberEmbed(msg.System, msg.Member, evt.Guild, LookupContext.ByNonOwner)); + await member.SendMessageAsync(embed: await _embeds.CreateMessageInfoEmbed(evt.Client, msg)); + } + catch (UnauthorizedException) { } // No permissions to DM, can't check for this :( + + // And finally remove the original reaction (if we can) + if (evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) + await evt.Message.DeleteReactionAsync(evt.Emoji, evt.User); + } + + private async ValueTask HandlePingReaction(MessageReactionAddEventArgs evt, FullMessage msg) + { + if (!evt.Channel.BotHasAllPermissions(Permissions.SendMessages)) return; + + // Check if the "pinger" has permission to send messages in this channel + // (if not, PK shouldn't send messages on their behalf) + var guildUser = await evt.Guild.GetMemberAsync(evt.User.Id); + var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages; + if ((guildUser.PermissionsIn(evt.Channel) & requiredPerms) != requiredPerms) return; + + if (msg.System.PingsEnabled) + { + // If the system has pings enabled, go ahead + var embed = new DiscordEmbedBuilder().WithDescription($"[Jump to pinged message]({evt.Message.JumpLink})"); + await evt.Channel.SendMessageAsync($"Psst, **{msg.Member.DisplayName ?? msg.Member.Name}** (<@{msg.Message.Sender}>), you have been pinged by <@{evt.User.Id}>.", embed: embed.Build()); + } + else + { + // If not, tell them in DMs (if we can) + try + { + await guildUser.SendMessageAsync($"{Emojis.Error} {msg.Member.DisplayName ?? msg.Member.Name}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); + await guildUser.SendMessageAsync($"`<@{msg.Message.Sender}>`"); + } + catch (UnauthorizedException) { } + } + + // Finally, remove the original reaction (if we can) + if (evt.Channel.BotHasAllPermissions(Permissions.ManageMessages)) + await evt.Message.DeleteReactionAsync(evt.Emoji, evt.User); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index da4f5ea3..ff8ec351 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -82,6 +82,10 @@ namespace PluralKit.Bot .As>() .As>() .SingleInstance(); + + // Proxy stuff + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); // Utils builder.Register(c => new HttpClient diff --git a/PluralKit.Bot/Proxy/Autoproxier.cs b/PluralKit.Bot/Proxy/Autoproxier.cs new file mode 100644 index 00000000..312ea717 --- /dev/null +++ b/PluralKit.Bot/Proxy/Autoproxier.cs @@ -0,0 +1,93 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; + +using NodaTime; + +using PluralKit.Core; + + +namespace PluralKit.Bot +{ + public class Autoproxier + { + public static readonly string EscapeString = @"\"; + public static readonly Duration AutoproxyExpiryTime = Duration.FromHours(6); + + private IClock _clock; + private IDataStore _data; + + public Autoproxier(IDataStore data, IClock clock) + { + _data = data; + _clock = clock; + } + + public async ValueTask TryAutoproxy(AutoproxyContext ctx) + { + if (IsEscaped(ctx.Content)) + return null; + + var member = await FindAutoproxyMember(ctx); + if (member == null) return null; + + return new ProxyMatch + { + Content = ctx.Content, + Member = member, + ProxyTags = ProxyTagsFor(member) + }; + } + + private async ValueTask FindAutoproxyMember(AutoproxyContext ctx) + { + switch (ctx.Mode) + { + case AutoproxyMode.Off: + return null; + + case AutoproxyMode.Front: + return await _data.GetFirstFronter(ctx.Account.System); + + case AutoproxyMode.Latch: + // Latch mode: find last proxied message, use *that* member + var msg = await _data.GetLastMessageInGuild(ctx.SenderId, ctx.GuildId); + if (msg == null) return null; // No message found + + // 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 = DiscordUtils.SnowflakeToInstant(msg.Message.Mid); + if (_clock.GetCurrentInstant() - timestamp > AutoproxyExpiryTime) return null; + + return msg.Member; + + case AutoproxyMode.Member: + // We already have the member list cached, so: + // O(n) lookup since n is small (max 1500 de jure) and we're more constrained by memory (for a dictionary) here + return ctx.Account.Members.FirstOrDefault(m => m.Id == ctx.AutoproxyMember); + + default: + throw new ArgumentOutOfRangeException($"Unknown autoproxy mode {ctx.Mode}"); + } + } + + private ProxyTag? ProxyTagsFor(PKMember member) + { + if (member.ProxyTags.Count == 0) return null; + return member.ProxyTags.First(); + } + + private bool IsEscaped(string message) => message.TrimStart().StartsWith(EscapeString); + + public struct AutoproxyContext + { + public CachedAccount Account; + public string Content; + public AutoproxyMode Mode; + public int? AutoproxyMember; + public ulong SenderId; + public ulong GuildId; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Proxy/ProxyMatch.cs b/PluralKit.Bot/Proxy/ProxyMatch.cs new file mode 100644 index 00000000..4a316731 --- /dev/null +++ b/PluralKit.Bot/Proxy/ProxyMatch.cs @@ -0,0 +1,25 @@ +#nullable enable +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public struct ProxyMatch + { + public PKMember Member; + public string? Content; + public ProxyTag? ProxyTags; + + public string? ProxyContent + { + get + { + // Add the proxy tags into the proxied message if that option is enabled + // Also check if the member has any proxy tags - some cases autoproxy can return a member with no tags + if (Member.KeepProxy && Content != null && ProxyTags != null) + return $"{ProxyTags.Value.Prefix}{Content}{ProxyTags.Value.Suffix}"; + + return Content; + } + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs new file mode 100644 index 00000000..2f7f7e76 --- /dev/null +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -0,0 +1,145 @@ +using System; +using System.Threading.Tasks; + +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.Exceptions; + +using PluralKit.Core; + +using Serilog; + +namespace PluralKit.Bot +{ + public class ProxyService { + public static readonly TimeSpan MessageDeletionDelay = TimeSpan.FromMilliseconds(1000); + + private LogChannelService _logChannel; + private IDataStore _data; + private ILogger _logger; + private WebhookExecutorService _webhookExecutor; + private ProxyTagParser _parser; + private Autoproxier _autoproxier; + + public ProxyService(LogChannelService logChannel, IDataStore data, ILogger logger, WebhookExecutorService webhookExecutor, ProxyTagParser parser, Autoproxier autoproxier) + { + _logChannel = logChannel; + _data = data; + _webhookExecutor = webhookExecutor; + _parser = parser; + _autoproxier = autoproxier; + _logger = logger.ForContext(); + } + + public async Task TryGetMatch(DiscordMessage message, SystemGuildSettings systemGuildSettings, CachedAccount account, bool allowAutoproxy) + { + // First, try parsing by tags + if (_parser.TryParse(message.Content, account.Members, out var tagMatch)) + { + // If the content is blank (and we don't have any attachments), someone just sent a message that happens + // to be equal to someone else's tags. This doesn't count! Proceed to autoproxy in that case. + var isEdgeCase = tagMatch.Content.Trim().Length == 0 && message.Attachments.Count == 0; + if (!isEdgeCase) return tagMatch; + } + + // Then, if AP is enabled, try finding an autoproxy match + if (allowAutoproxy) + return await _autoproxier.TryAutoproxy(new Autoproxier.AutoproxyContext + { + Account = account, + AutoproxyMember = systemGuildSettings.AutoproxyMember, + Content = message.Content, + GuildId = message.Channel.GuildId, + Mode = systemGuildSettings.AutoproxyMode, + SenderId = message.Author.Id + }); + + // Didn't find anything :( + return null; + } + + public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool allowAutoproxy) + { + // Early checks + if (message.Channel.Guild == null) return; + if (guild.Blacklist.Contains(message.ChannelId)) return; + var systemSettingsForGuild = account.SettingsForGuild(message.Channel.GuildId); + if (!systemSettingsForGuild.ProxyEnabled) return; + if (!await EnsureBotPermissions(message.Channel)) return; + + // Find a proxy match (either with tags or autoproxy), bail if we couldn't find any + if (!(await TryGetMatch(message, systemSettingsForGuild, account, allowAutoproxy) is { } match)) + return; + + // Can't proxy a message with no content and no attachment + if (match.Content.Trim().Length == 0 && message.Attachments.Count == 0) + return; + + var memberSettingsForGuild = account.SettingsForMemberGuild(match.Member.Id, message.Channel.GuildId); + + // Find and check proxied name + var proxyName = match.Member.ProxyName(account.System.Tag, memberSettingsForGuild.DisplayName); + if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName); + if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); + + // Find proxy avatar (server avatar -> member avatar -> system avatar) + var proxyAvatar = memberSettingsForGuild.AvatarUrl ?? match.Member.AvatarUrl ?? account.System.AvatarUrl; + + // Execute the webhook! + var hookMessage = await _webhookExecutor.ExecuteWebhook(message.Channel, proxyName, proxyAvatar, + await SanitizeEveryoneMaybe(message, match.ProxyContent), + message.Attachments + ); + + // Store the message in the database, and log it in the log channel (if applicable) + await _data.AddMessage(message.Author.Id, hookMessage, message.Channel.GuildId, message.Channel.Id, message.Id, match.Member); + await _logChannel.LogMessage(client, account.System, match.Member, hookMessage, message.Id, message.Channel, message.Author, match.Content, guild); + + // Wait a second or so before deleting the original message + await Task.Delay(MessageDeletionDelay); + + try + { + await message.DeleteAsync(); + } + 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 static async Task SanitizeEveryoneMaybe(DiscordMessage message, + string messageContents) + { + var permissions = await message.Channel.PermissionsIn(message.Author); + return (permissions & Permissions.MentionEveryone) == 0 ? messageContents.SanitizeEveryone() : messageContents; + } + + private async Task EnsureBotPermissions(DiscordChannel channel) + { + var permissions = channel.BotPermissions(); + + // If we can't send messages at all, just bail immediately. + // 2020-04-22: Manage Messages does *not* override a lack of Send Messages. + if ((permissions & Permissions.SendMessages) == 0) return false; + + if ((permissions & Permissions.ManageWebhooks) == 0) + { + // todo: PKError-ify these + await channel.SendMessageAsync( + $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this."); + return false; + } + + 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."); + return false; + } + + return true; + } + } +} diff --git a/PluralKit.Bot/Proxy/ProxyTagParser.cs b/PluralKit.Bot/Proxy/ProxyTagParser.cs new file mode 100644 index 00000000..47a3a82a --- /dev/null +++ b/PluralKit.Bot/Proxy/ProxyTagParser.cs @@ -0,0 +1,94 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class ProxyTagParser + { + public bool TryParse(string input, IEnumerable members, out ProxyMatch result) + { + result = default; + + // If the message starts with a @mention, and then proceeds to have proxy tags, + // extract the mention and place it inside the inner message + // eg. @Ske [text] => [@Ske text] + var leadingMention = ExtractLeadingMention(ref input); + + // "Flatten" list of members to a list of tag-member pairs + // Then order them by "tag specificity" + // (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first) + var tags = members + .SelectMany(member => member.ProxyTags.Select(tag => (tag, member))) + .OrderByDescending(p => p.tag.ProxyString.Length); + + // Iterate now-ordered list of tags and try matching each one + foreach (var (tag, member) in tags) + { + result.ProxyTags = tag; + result.Member = member; + + // Skip blank tags (shouldn't ever happen in practice) + if (tag.Prefix == null && tag.Suffix == null) continue; + + // Can we match with these tags? + if (TryMatchTags(input, tag, out result.Content)) + { + // (see https://github.com/xSke/PluralKit/pull/181) + if (result.Content == "\U0000fe0f") return false; + + // If we extracted a leading mention before, add that back now + if (leadingMention != null) result.Content = $"{leadingMention} {result.Content}"; + + // We're done! + return true; + } + + // (if not, keep going) + } + + // We couldn't match anything :( + return false; + } + + private bool TryMatchTags(string input, ProxyTag tag, out string content) + { + // Normalize null tags to empty strings + var prefix = tag.Prefix ?? ""; + var suffix = tag.Suffix ?? ""; + + // Check if our input starts/ends with the tags + var isMatch = input.Length >= prefix.Length + suffix.Length + && input.StartsWith(prefix) && input.EndsWith(suffix); + + // Special case: image-only proxies + proxy tags with spaces + // Trim everything, then see if we have a "contentless tag pair" (normally disallowed, but OK if we have an attachment) + if (!isMatch && input.Trim() == prefix.TrimEnd() + suffix.TrimStart()) + { + content = ""; + return true; + } + + if (isMatch) + { + content = input.Substring(prefix.Length, input.Length - prefix.Length - suffix.Length); + return true; + } + + content = ""; + return false; + } + + private string? ExtractLeadingMention(ref string input) + { + var mentionPos = 0; + if (!StringUtils.HasMentionPrefix(input, ref mentionPos, out _)) return null; + + var leadingMention = input.Substring(0, mentionPos); + input = input.Substring(mentionPos); + return leadingMention; + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs deleted file mode 100644 index 25c449b5..00000000 --- a/PluralKit.Bot/Services/ProxyService.cs +++ /dev/null @@ -1,375 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using DSharpPlus; -using DSharpPlus.Entities; -using DSharpPlus.EventArgs; -using DSharpPlus.Exceptions; - -using NodaTime; - -using PluralKit.Core; - -using Serilog; - -namespace PluralKit.Bot -{ - class ProxyMatch { - public PKMember Member; - public PKSystem System; - public ProxyTag? ProxyTags; - public string InnerText; - } - - public class ProxyService { - private DiscordShardedClient _client; - private LogChannelService _logChannel; - private IDataStore _data; - private EmbedService _embeds; - private ILogger _logger; - private WebhookExecutorService _webhookExecutor; - - public ProxyService(DiscordShardedClient client, LogChannelService logChannel, IDataStore data, EmbedService embeds, ILogger logger, WebhookExecutorService webhookExecutor) - { - _client = client; - _logChannel = logChannel; - _data = data; - _embeds = embeds; - _webhookExecutor = webhookExecutor; - _logger = logger.ForContext(); - } - - private ProxyMatch GetProxyTagMatch(string message, PKSystem system, IEnumerable potentialMembers) - { - // If the message starts with a @mention, and then proceeds to have proxy tags, - // extract the mention and place it inside the inner message - // eg. @Ske [text] => [@Ske text] - int matchStartPosition = 0; - string leadingMention = null; - if (StringUtils.HasMentionPrefix(message, ref matchStartPosition, out _)) - { - leadingMention = message.Substring(0, matchStartPosition); - message = message.Substring(matchStartPosition); - } - - // Flatten and sort by specificity (ProxyString length desc = prefix+suffix length desc = inner message asc = more specific proxy first!) - var ordered = potentialMembers.SelectMany(m => m.ProxyTags.Select(tag => (tag, m))).OrderByDescending(p => p.Item1.ProxyString.Length); - foreach (var (tag, match) in ordered) - { - if (tag.Prefix == null && tag.Suffix == null) continue; - - var prefix = tag.Prefix ?? ""; - var suffix = tag.Suffix ?? ""; - - var isMatch = message.Length >= prefix.Length + suffix.Length - && message.StartsWith(prefix) && message.EndsWith(suffix); - - // Special case for image-only proxies and proxy tags with spaces - if (!isMatch && message.Trim() == prefix.TrimEnd() + suffix.TrimStart()) - { - isMatch = true; - message = prefix + suffix; // To get around substring errors - } - - if (isMatch) { - var inner = message.Substring(prefix.Length, message.Length - prefix.Length - suffix.Length); - if (leadingMention != null) inner = $"{leadingMention} {inner}"; - if (inner == "\U0000fe0f") return null; - return new ProxyMatch { Member = match, System = system, InnerText = inner, ProxyTags = tag}; - } - } - - return null; - } - - public async Task HandleMessageAsync(DiscordClient client, GuildConfig guild, CachedAccount account, DiscordMessage message, bool doAutoProxy) - { - // Bail early if this isn't in a guild channel - if (message.Channel.Guild == null) return; - - // 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(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 - // is equal to someone else's tags, and messages like these should be autoproxied if possible - - // All of this should only be done if this call allows autoproxy. - // 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, 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(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(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, message.Channel.GuildId); - - // Get variables in order and all - var proxyName = match.Member.ProxyName(match.System.Tag, memberSettingsForGuild.DisplayName); - var avatarUrl = memberSettingsForGuild.AvatarUrl ?? match.Member.AvatarUrl ?? match.System.AvatarUrl; - - // If the name's too long (or short), bail - if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName); - if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); - - // Add the proxy tags into the proxied message if that option is enabled - // Also check if the member has any proxy tags - some cases autoproxy can return a member with no tags - var messageContents = (match.Member.KeepProxy && match.ProxyTags.HasValue) - ? $"{match.ProxyTags.Value.Prefix}{match.InnerText}{match.ProxyTags.Value.Suffix}" - : match.InnerText; - - // Sanitize @everyone, but only if the original user wouldn't have permission to - messageContents = await SanitizeEveryoneMaybe(message, messageContents); - - // Execute the webhook itself - 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, 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); - - try - { - await message.DeleteAsync(); - } - catch (NotFoundException) - { - // If it's already deleted, we just log and swallow the exception - _logger.Warning("Attempted to delete already deleted proxy trigger message {Message}", message.Id); - } - } - - private async Task GetAutoproxyMatch(CachedAccount account, SystemGuildSettings guildSettings, 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; - - PKMember member = null; - // Figure out which member to proxy as - switch (guildSettings.AutoproxyMode) - { - case AutoproxyMode.Off: - // Autoproxy off, bail - return null; - case AutoproxyMode.Front: - // Front mode: just use the current first fronter - member = await _data.GetFirstFronter(account.System); - break; - case AutoproxyMode.Latch: - // Latch mode: find last proxied message, use *that* member - var msg = await _data.GetLastMessageInGuild(message.Author.Id, channel.GuildId); - if (msg == null) return null; // No message found - - // 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 = DiscordUtils.SnowflakeToInstant(msg.Message.Mid); - var timeSince = SystemClock.Instance.GetCurrentInstant() - timestamp; - if (timeSince > Duration.FromHours(6)) return null; - - member = msg.Member; - break; - case AutoproxyMode.Member: - // Member mode: just use that member - // O(n) lookup since n is small (max 1000 de jure) and we're more constrained by memory (for a dictionary) here - member = account.Members.FirstOrDefault(m => m.Id == guildSettings.AutoproxyMember); - break; - } - - // If we haven't found the member (eg. front mode w/ no fronter), bail again - if (member == null) return null; - return new ProxyMatch - { - System = account.System, - Member = member, - // Autoproxying members with no proxy tags is possible, return the correct result - ProxyTags = member.ProxyTags.Count > 0 ? member.ProxyTags.First() : (ProxyTag?) null, - InnerText = message.Content - }; - } - - private static async Task SanitizeEveryoneMaybe(DiscordMessage message, - string messageContents) - { - var permissions = await message.Channel.PermissionsIn(message.Author); - return (permissions & Permissions.MentionEveryone) == 0 ? messageContents.SanitizeEveryone() : messageContents; - } - - private async Task EnsureBotPermissions(DiscordChannel channel) - { - var permissions = channel.BotPermissions(); - - // If we can't send messages at all, just bail immediately. - // 2020-04-22: Manage Messages does *not* override a lack of Send Messages. - if ((permissions & Permissions.SendMessages) == 0) return false; - - if ((permissions & Permissions.ManageWebhooks) == 0) - { - // todo: PKError-ify these - await channel.SendMessageAsync( - $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this."); - return false; - } - - 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."); - return false; - } - - return true; - } - - public Task HandleReactionAddedAsync(MessageReactionAddEventArgs args) - { - // Dispatch on emoji - switch (args.Emoji.Name) - { - case "\u274C": // Red X - return HandleMessageDeletionByReaction(args); - case "\u2753": // Red question mark - case "\u2754": // White question mark - 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(args); - default: - return Task.CompletedTask; - } - } - - private async Task HandleMessagePingByReaction(MessageReactionAddEventArgs args) - { - // Bail in DMs or if we don't have send permission - if (args.Channel.Type != ChannelType.Text) return; - if (!args.Channel.BotHasAllPermissions(Permissions.SendMessages)) return; - - // Find the message in the DB - 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 args.Guild.GetMemberAsync(args.User.Id); - var permissions = guildUser.PermissionsIn(args.Channel); - - // If they don't have Send Messages permission, bail (since PK shouldn't send anything on their behalf) - var requiredPerms = Permissions.AccessChannels | Permissions.SendMessages; - if ((permissions & requiredPerms) != requiredPerms) return; - - if (!msg.System.PingsEnabled) { - // If the target system has disabled pings, tell the pinger and bail - var member = await args.Guild.GetMemberAsync(args.User.Id); - try - { - await member.SendMessageAsync($"{Emojis.Error} {msg.Member.DisplayName ?? msg.Member.Name}'s system has disabled reaction pings. If you want to mention them anyway, you can copy/paste the following message:"); - await member.SendMessageAsync($"`<@{msg.Message.Sender}>`"); - } - catch (UnauthorizedException) { } - } - else - { - 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) - if (args.Channel.BotHasAllPermissions(Permissions.ManageMessages)) - await args.Message.DeleteReactionAsync(args.Emoji, args.User); - } - - private async Task HandleMessageQueryByReaction(MessageReactionAddEventArgs args) - { - // Bail if not in guild - if (args.Guild == null) return; - - // Find the message in the DB - 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 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 (UnauthorizedException) - { - // 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) - await args.Message.DeleteReactionAsync(args.Emoji, args.User); - } - - public async Task HandleMessageDeletionByReaction(MessageReactionAddEventArgs args) - { - // Bail if we don't have permission to delete - if (!args.Channel.BotHasAllPermissions(Permissions.ManageMessages)) return; - - // Find the message in the database - 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 != args.User.Id) return; - - 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(args.Message.Id); - } - - 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 (!args.Message.WebhookMessage) return; - await _data.DeleteMessage(args.Message.Id); - } - - public async Task HandleMessageBulkDeleteAsync(MessageBulkDeleteEventArgs args) - { - _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()); - } - } -} diff --git a/PluralKit.Core/Services/IDataStore.cs b/PluralKit.Core/Services/IDataStore.cs index 28cf7b5c..7a371538 100644 --- a/PluralKit.Core/Services/IDataStore.cs +++ b/PluralKit.Core/Services/IDataStore.cs @@ -281,7 +281,7 @@ namespace PluralKit.Core { /// Deletes messages from the data store in bulk. /// /// The IDs of the webhook messages to delete. - Task DeleteMessagesBulk(IEnumerable postedMessageIds); + Task DeleteMessagesBulk(IReadOnlyCollection postedMessageIds); /// /// Gets the most recent message sent by a given account in a given guild. diff --git a/PluralKit.Core/Services/PostgresDataStore.cs b/PluralKit.Core/Services/PostgresDataStore.cs index e4d9b8ca..76be355e 100644 --- a/PluralKit.Core/Services/PostgresDataStore.cs +++ b/PluralKit.Core/Services/PostgresDataStore.cs @@ -296,7 +296,7 @@ namespace PluralKit.Core { _logger.Information("Deleted message {Message}", id); } - public async Task DeleteMessagesBulk(IEnumerable ids) + public async Task DeleteMessagesBulk(IReadOnlyCollection ids) { using (var conn = await _conn.Obtain()) { diff --git a/PluralKit.Core/Services/ProxyCacheService.cs b/PluralKit.Core/Services/ProxyCacheService.cs index c410efba..bfd80f4a 100644 --- a/PluralKit.Core/Services/ProxyCacheService.cs +++ b/PluralKit.Core/Services/ProxyCacheService.cs @@ -187,6 +187,7 @@ namespace PluralKit.Core public ulong[] Accounts; public SystemGuildSettings SettingsForGuild(ulong guild) => + // O(n) lookup since n is small (max ~100 in prod) and we're more constrained by memory (for a dictionary) here SystemGuild.FirstOrDefault(s => s.Guild == guild) ?? new SystemGuildSettings(); public MemberGuildSettings SettingsForMemberGuild(int memberId, ulong guild) =>