diff --git a/PluralKit.Bot/Bot.cs b/PluralKit.Bot/Bot.cs index bd3810f2..a84799d0 100644 --- a/PluralKit.Bot/Bot.cs +++ b/PluralKit.Bot/Bot.cs @@ -64,21 +64,23 @@ namespace PluralKit.Bot } public ServiceProvider BuildServiceProvider() => new ServiceCollection() - .AddSingleton(_config.GetSection("PluralKit").Get() ?? new CoreConfig()) - .AddSingleton(_config.GetSection("PluralKit").GetSection("Bot").Get() ?? new BotConfig()) + .AddTransient(_ => _config.GetSection("PluralKit").Get() ?? new CoreConfig()) + .AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get() ?? new BotConfig()) + + .AddScoped(svc => new NpgsqlConnection(svc.GetRequiredService().Database)) .AddSingleton() - .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddTransient() + .AddTransient() + .AddTransient() .BuildServiceProvider(); } class Bot @@ -154,6 +156,8 @@ namespace PluralKit.Bot private async Task MessageReceived(SocketMessage _arg) { + var serviceScope = _services.CreateScope(); + // Ignore system messages (member joined, message pinned, etc) var arg = _arg as SocketUserMessage; if (arg == null) return; @@ -169,7 +173,7 @@ namespace PluralKit.Bot // and start command execution // Note system may be null if user has no system, hence `OrDefault` var system = await _connection.QueryFirstOrDefaultAsync("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id }); - await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services); + await _commands.ExecuteAsync(new PKCommandContext(_client, arg, _connection, system), argPos, serviceScope.ServiceProvider); } else { diff --git a/PluralKit.Bot/Commands/MemberCommands.cs b/PluralKit.Bot/Commands/MemberCommands.cs index 200b6e95..968c5fc2 100644 --- a/PluralKit.Bot/Commands/MemberCommands.cs +++ b/PluralKit.Bot/Commands/MemberCommands.cs @@ -1,8 +1,12 @@ using System; +using System.Linq; +using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Discord; using Discord.Commands; using NodaTime; +using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot.Commands { @@ -179,6 +183,22 @@ namespace PluralKit.Bot.Commands await Context.Channel.SendMessageAsync($"{Emojis.Success} Member deleted."); } + [Command("avatar")] + [Alias("profile", "picture", "icon", "image", "pic", "pfp")] + [Remarks("member avatar ")] + [MustPassOwnMember] + public async Task MemberAvatar([Remainder] string avatarUrl = null) + { + string url = avatarUrl ?? Context.Message.Attachments.FirstOrDefault()?.ProxyUrl; + if (url != null) await Context.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url)); + + ContextEntity.AvatarUrl = url; + await Members.Save(ContextEntity); + + var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null; + await Context.Channel.SendMessageAsync($"{Emojis.Success} Member avatar {(url == null ? "cleared" : "changed")}.", embed: embed); + } + [Command] [Alias("view", "show", "info")] [Remarks("member")] @@ -187,6 +207,7 @@ namespace PluralKit.Bot.Commands var system = await Systems.GetById(member.System); await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member)); } + public override async Task ReadContextParameterAsync(string value) { var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services); diff --git a/PluralKit.Bot/ContextUtils.cs b/PluralKit.Bot/ContextUtils.cs index 1c34a886..39f3ed44 100644 --- a/PluralKit.Bot/ContextUtils.cs +++ b/PluralKit.Bot/ContextUtils.cs @@ -103,5 +103,29 @@ namespace PluralKit.Bot { } public static async Task HasPermission(this ICommandContext ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission); + + public static async Task BusyIndicator(this ICommandContext ctx, Func f, string emoji = "\u23f3" /* hourglass */) + { + await ctx.BusyIndicator(async () => + { + await f(); + return null; + }, emoji); + } + + public static async Task BusyIndicator(this ICommandContext ctx, Func> f, string emoji = "\u23f3" /* hourglass */) + { + var task = f(); + + try + { + await Task.WhenAll(ctx.Message.AddReactionAsync(new Emoji(emoji)), task); + return await task; + } + finally + { + ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Client.CurrentUser); + } + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 52ec1539..599db57d 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -1,3 +1,6 @@ +using System.Net; +using Humanizer; + namespace PluralKit.Bot { public static class Errors { // TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead? @@ -20,5 +23,10 @@ namespace PluralKit.Bot { public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once."); public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}"); + public static PKError AvatarServerError(HttpStatusCode statusCode) => new PKError($"Server responded with status code {(int) statusCode}, are you sure your link is working?"); + public static PKError AvatarFileSizeLimit(long size) => new PKError($"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image."); + public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif)."); + public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image."); + public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Limits.cs b/PluralKit.Bot/Limits.cs index 9971bcfc..3f7b3ec5 100644 --- a/PluralKit.Bot/Limits.cs +++ b/PluralKit.Bot/Limits.cs @@ -5,5 +5,8 @@ namespace PluralKit.Bot { public static readonly int MaxDescriptionLength = 1000; public static readonly int MaxMemberNameLength = 50; public static readonly int MaxPronounsLength = 100; + + public static readonly long AvatarFileSizeLimit = 1024 * 1024; + public static readonly int AvatarDimensionLimit = 1000; } } \ No newline at end of file diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 4c7c476f..7525a821 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -13,6 +13,8 @@ + + diff --git a/PluralKit.Bot/Services/ProxyService.cs b/PluralKit.Bot/Services/ProxyService.cs index e43b8f5d..b58773f3 100644 --- a/PluralKit.Bot/Services/ProxyService.cs +++ b/PluralKit.Bot/Services/ProxyService.cs @@ -31,18 +31,17 @@ namespace PluralKit.Bot private IDiscordClient _client; private IDbConnection _connection; private LogChannelService _logger; + private WebhookCacheService _webhookCache; private MessageStore _messageStorage; - private ConcurrentDictionary>> _webhooks; - public ProxyService(IDiscordClient client, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) + public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, IDbConnection connection, LogChannelService logger, MessageStore messageStorage) { this._client = client; + this._webhookCache = webhookCache; this._connection = connection; this._logger = logger; this._messageStorage = messageStorage; - - _webhooks = new ConcurrentDictionary>>(); } private ProxyMatch GetProxyTagMatch(string message, IEnumerable potentials) { @@ -70,7 +69,7 @@ namespace PluralKit.Bot if (match == null) return; // Fetch a webhook for this channel, and send the proxied message - var webhook = await GetWebhookByChannelCaching(message.Channel as ITextChannel); + var webhook = await _webhookCache.GetWebhook(message.Channel as ITextChannel); var hookMessage = await ExecuteWebhook(webhook, match.InnerText, match.ProxyName, match.Member.AvatarUrl, message.Attachments.FirstOrDefault()); // Store the message in the database, and log it in the log channel (if applicable) @@ -96,28 +95,6 @@ namespace PluralKit.Bot return await webhook.Channel.GetMessageAsync(messageId); } - private async Task GetWebhookByChannelCaching(ITextChannel channel) { - // We cache the webhook through a Lazy>, this way we make sure to only create one webhook per channel - // TODO: make sure this is sharding-safe. Intuition says yes, since one channel is guaranteed to only be handled by one shard, but best to make sure - var webhookFactory = _webhooks.GetOrAdd(channel.Id, new Lazy>(() => FindWebhookByChannel(channel))); - return await webhookFactory.Value; - } - - private async Task FindWebhookByChannel(ITextChannel channel) { - IWebhook webhook; - - webhook = (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); - if (webhook != null) return webhook; - - webhook = await channel.CreateWebhookAsync("PluralKit Proxy Webhook"); - return webhook; - } - - private bool IsWebhookMine(IWebhook arg) - { - return arg.Creator.Id == this._client.CurrentUser.Id && arg.Name == "PluralKit Proxy Webhook"; - } - public async Task HandleReactionAddedAsync(Cacheable message, ISocketMessageChannel channel, SocketReaction reaction) { // Make sure it's the right emoji (red X) diff --git a/PluralKit.Bot/Services/WebhookCacheService.cs b/PluralKit.Bot/Services/WebhookCacheService.cs new file mode 100644 index 00000000..e45597d0 --- /dev/null +++ b/PluralKit.Bot/Services/WebhookCacheService.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; + +namespace PluralKit.Bot +{ + public class WebhookCacheService + { + public static readonly string WebhookName = "PluralKit Proxy Webhook"; + + private IDiscordClient _client; + private ConcurrentDictionary>> _webhooks; + + public WebhookCacheService(IDiscordClient client) + { + this._client = client; + _webhooks = new ConcurrentDictionary>>(); + } + + public async Task GetWebhook(ulong channelId) + { + var channel = await _client.GetChannelAsync(channelId) as ITextChannel; + if (channel == null) return null; + return await GetWebhook(channel); + } + + public async Task GetWebhook(ITextChannel channel) + { + // We cache the webhook through a Lazy>, this way we make sure to only create one webhook per channel + // If the webhook is requested twice before it's actually been found, the Lazy wrapper will stop the + // webhook from being created twice. + var lazyWebhookValue = + _webhooks.GetOrAdd(channel.Id, new Lazy>(() => GetOrCreateWebhook(channel))); + return await lazyWebhookValue.Value; + } + + private async Task GetOrCreateWebhook(ITextChannel channel) => + await FindExistingWebhook(channel) ?? await GetOrCreateWebhook(channel); + + private async Task FindExistingWebhook(ITextChannel channel) => (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine); + + private async Task DoCreateWebhook(ITextChannel channel) => await channel.CreateWebhookAsync(WebhookName); + private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.CurrentUser.Id && arg.Name == WebhookName; + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Utils.cs b/PluralKit.Bot/Utils.cs index a9b9dfa1..365b2ac6 100644 --- a/PluralKit.Bot/Utils.cs +++ b/PluralKit.Bot/Utils.cs @@ -1,7 +1,7 @@ using System; -using System.Collections.Generic; using System.Data; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using Dapper; using Discord; @@ -9,7 +9,7 @@ using Discord.Commands; using Discord.Commands.Builders; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; -using NodaTime; +using Image = SixLabors.ImageSharp.Image; namespace PluralKit.Bot { @@ -17,6 +17,48 @@ namespace PluralKit.Bot public static string NameAndMention(this IUser user) { return $"{user.Username}#{user.Discriminator} ({user.Mention})"; } + + public static async Task VerifyAvatarOrThrow(string url) + { + // List of MIME types we consider acceptable + var acceptableMimeTypes = new[] + { + "image/jpeg", + "image/gif", + "image/png" + // TODO: add image/webp once ImageSharp supports this + }; + + using (var client = new HttpClient()) + { + Uri uri; + try + { + uri = new Uri(url); + if (!uri.IsAbsoluteUri) throw Errors.InvalidUrl(url); + } + catch (UriFormatException) + { + throw Errors.InvalidUrl(url); + } + + var response = await client.GetAsync(uri); + if (!response.IsSuccessStatusCode) // Check status code + throw Errors.AvatarServerError(response.StatusCode); + if (response.Content.Headers.ContentLength == null) // Check presence of content length + throw Errors.AvatarNotAnImage(null); + if (response.Content.Headers.ContentLength > Limits.AvatarFileSizeLimit) // Check content length + throw Errors.AvatarFileSizeLimit(response.Content.Headers.ContentLength.Value); + if (!acceptableMimeTypes.Contains(response.Content.Headers.ContentType.MediaType)) // Check MIME type + throw Errors.AvatarNotAnImage(response.Content.Headers.ContentType.MediaType); + + // Parse the image header in a worker + var stream = await response.Content.ReadAsStreamAsync(); + var image = await Task.Run(() => Image.Identify(stream)); + if (image.Width > Limits.AvatarDimensionLimit || image.Height > Limits.AvatarDimensionLimit) // Check image size + throw Errors.AvatarDimensionsTooLarge(image.Width, image.Height); + } + } } class UlongEncodeAsLongHandler : SqlMapper.TypeHandler