diff --git a/Kattbot.Tests/PetTests.cs b/Kattbot.Tests/PetTests.cs index de04a93..7ac41a7 100644 --- a/Kattbot.Tests/PetTests.cs +++ b/Kattbot.Tests/PetTests.cs @@ -6,6 +6,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using NSubstitute; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; namespace Kattbot.Tests; @@ -14,7 +15,13 @@ namespace Kattbot.Tests; public class PetTests { [TestMethod] - public async Task PetPetTest() + [DataRow("SamplePNGImage_100kbmb.png")] + [DataRow("SamplePNGImage_500kbmb.png")] + [DataRow("SamplePNGImage_1mbmb.png")] + [DataRow("SamplePNGImage_3mbmb.png")] + [DataRow("SamplePNGImage_10mbmb.png")] + [DataRow("SamplePNGImage_30mbmb.png")] + public async Task PetPetTest(string inputImage) { var puppeteerFactory = new PuppeteerFactory(); @@ -22,8 +29,8 @@ public async Task PetPetTest() var makeEmojiClient = new PetPetClient(puppeteerFactory, logger); - string inputFile = Path.Combine(Path.GetTempPath(), "froge.png"); - string ouputFile = Path.Combine(Path.GetTempPath(), "pet_froge.gif"); + string inputFile = Path.Combine(Path.GetTempPath(), "test_images", inputImage); + string ouputFile = Path.Combine(Path.GetTempPath(), "pet-test-output", $"pet_{inputImage.Split(".")[0]}.gif"); byte[] resultBytes = await makeEmojiClient.PetPet(inputFile); @@ -32,17 +39,36 @@ public async Task PetPetTest() await image.SaveAsGifAsync(ouputFile); } - [TestMethod] - public async Task CropToCircle() + [DataTestMethod] + [DataRow("froge.png")] + [DataRow("test_working.png")] + [DataRow("test_not_working.png")] + public async Task CropToCircle(string inputFilename) + { + string inputFile = Path.Combine(Path.GetTempPath(), inputFilename); + string ouputFile = Path.Combine(Path.GetTempPath(), "kattbot", $"cropped_{inputFilename}"); + + var imageService = new ImageService(null!); + + using var image = Image.Load(inputFile); + + var croppedImage = imageService.CropToCircle(image); + + await croppedImage.SaveAsPngAsync(ouputFile); + } + + [DataTestMethod] + [DataRow("froge.png")] + public async Task Twirl(string inputFilename) { - string inputFile = Path.Combine(Path.GetTempPath(), "froge.png"); - string ouputFile = Path.Combine(Path.GetTempPath(), "froge_circle.png"); + string inputFile = Path.Combine(Path.GetTempPath(), inputFilename); + string ouputFile = Path.Combine(Path.GetTempPath(), "kattbot", $"twirled_{inputFilename}"); var imageService = new ImageService(null!); - using var image = Image.Load(inputFile); + using var image = Image.Load(inputFile); - var croppedImage = imageService.CropImageToCircle(image); + var croppedImage = imageService.TwirlImage(image); await croppedImage.SaveAsPngAsync(ouputFile); } diff --git a/Kattbot/CommandHandlers/Images/DallePromptCommand.cs b/Kattbot/CommandHandlers/Images/DallePrompt.cs similarity index 79% rename from Kattbot/CommandHandlers/Images/DallePromptCommand.cs rename to Kattbot/CommandHandlers/Images/DallePrompt.cs index 5a40b82..ac59455 100644 --- a/Kattbot/CommandHandlers/Images/DallePromptCommand.cs +++ b/Kattbot/CommandHandlers/Images/DallePrompt.cs @@ -1,10 +1,10 @@ using System; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using DSharpPlus.CommandsNext; using DSharpPlus.Entities; +using Kattbot.Helpers; using Kattbot.Services.Dalle; using Kattbot.Services.Images; using MediatR; @@ -14,23 +14,23 @@ namespace Kattbot.CommandHandlers.Images; #pragma warning disable SA1402 // File may only contain a single type public class DallePromptCommand : CommandRequest { - public string Prompt { get; set; } - public DallePromptCommand(CommandContext ctx, string prompt) : base(ctx) { Prompt = prompt; } + + public string Prompt { get; set; } } -public class DallePromptCommandHandler : IRequestHandler +public class DallePromptHandler : IRequestHandler { private const int MaxEmbedTitleLength = 256; private readonly DalleHttpClient _dalleHttpClient; private readonly ImageService _imageService; - public DallePromptCommandHandler(DalleHttpClient dalleHttpClient, ImageService imageService) + public DallePromptHandler(DalleHttpClient dalleHttpClient, ImageService imageService) { _dalleHttpClient = dalleHttpClient; _imageService = imageService; @@ -42,7 +42,7 @@ public async Task Handle(DallePromptCommand request, CancellationToken cancellat try { - var response = await _dalleHttpClient.CreateImage(new CreateImageRequest { Prompt = request.Prompt }); + var response = await _dalleHttpClient.CreateImage(new CreateImageRequest { Prompt = request.Prompt, User = request.Ctx.User.Id.ToString() }); if (response.Data == null || !response.Data.Any()) throw new Exception("Empty result"); @@ -52,11 +52,7 @@ public async Task Handle(DallePromptCommand request, CancellationToken cancellat var imageStream = await _imageService.GetImageStream(image); - string safeFileName = new(Encoding.ASCII.GetString(Encoding.ASCII.GetBytes(request.Prompt)) - .Select(c => char.IsLetterOrDigit(c) ? c : '_') - .ToArray()); - - string fileName = $"{safeFileName}.{imageStream.FileExtension}"; + var fileName = request.Prompt.ToSafeFilename(imageStream.FileExtension); var truncatedPrompt = request.Prompt.Length > MaxEmbedTitleLength ? $"{request.Prompt[..(MaxEmbedTitleLength - 3)]}..." diff --git a/Kattbot/CommandHandlers/Images/DallifyImage.cs b/Kattbot/CommandHandlers/Images/DallifyImage.cs new file mode 100644 index 0000000..b77f7d9 --- /dev/null +++ b/Kattbot/CommandHandlers/Images/DallifyImage.cs @@ -0,0 +1,215 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using Kattbot.Helpers; +using Kattbot.Services.Dalle; +using Kattbot.Services.Images; +using MediatR; + +namespace Kattbot.CommandHandlers.Images; + +#pragma warning disable SA1402 // File may only contain a single type +public class DallifyEmoteRequest : CommandRequest +{ + public DallifyEmoteRequest(CommandContext ctx, DiscordEmoji emoji) + : base(ctx) + { + Emoji = emoji; + } + + public DiscordEmoji Emoji { get; set; } +} + +public class DallifyUserRequest : CommandRequest +{ + public DallifyUserRequest(CommandContext ctx, DiscordUser user) + : base(ctx) + { + User = user; + } + + public DiscordUser User { get; set; } +} + +public class DallifyImageRequest : CommandRequest +{ + public DallifyImageRequest(CommandContext ctx) + : base(ctx) + { } +} + +public class DallifyImageHandler : IRequestHandler, + IRequestHandler, + IRequestHandler +{ + public static readonly int Size256 = 256; + public static readonly int Size512 = 512; + public static readonly int Size1024 = 1024; + + private const int MaxImageSizeInMb = 4; + + private static readonly int[] ValidSizes = { Size256, Size512, Size1024 }; + + private readonly DalleHttpClient _dalleHttpClient; + private readonly ImageService _imageService; + private readonly DiscordResolver _discordResolver; + + public DallifyImageHandler(DalleHttpClient dalleHttpClient, ImageService imageService, DiscordResolver discordResolver) + { + _dalleHttpClient = dalleHttpClient; + _imageService = imageService; + _discordResolver = discordResolver; + } + + public async Task Handle(DallifyEmoteRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var userId = ctx.User.Id; + + var emoji = request.Emoji; + + var message = await request.Ctx.RespondAsync("Working on it"); + + try + { + var imageUrl = emoji.GetEmojiImageUrl(); + + var imageStreamResult = await DallifyImage(imageUrl, userId, Size256); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageName = emoji.Id != 0 ? emoji.Id.ToString() : emoji.Name; + + string fileName = $"{imageName}.{fileExtension}"; + + var mb = new DiscordMessageBuilder() + .AddFile(fileName, imageStream) + .WithContent($"There you go {request.Ctx.Member?.Mention ?? "Unknown user"}"); + + await message.DeleteAsync(); + + await request.Ctx.RespondAsync(mb); + } + catch (Exception) + { + await message.DeleteAsync(); + throw; + } + } + + public async Task Handle(DallifyUserRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var user = request.User; + var guild = ctx.Guild; + + var message = await request.Ctx.RespondAsync("Working on it"); + + try + { + var userAsMember = await _discordResolver.ResolveGuildMember(guild, user.Id) ?? throw new Exception("Invalid user"); + + var imageUrl = userAsMember.GuildAvatarUrl + ?? userAsMember.AvatarUrl + ?? throw new Exception("Couldn't load user avatar"); + + var imageStreamResult = await DallifyImage(imageUrl, user.Id, Size512); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageFilename = user.GetNicknameOrUsername().ToSafeFilename(fileExtension); + + DiscordMessageBuilder mb = new DiscordMessageBuilder() + .AddFile(imageFilename, imageStream) + .WithContent($"There you go {request.Ctx.Member?.Mention ?? "Unknown user"}"); + + await message.DeleteAsync(); + + await request.Ctx.RespondAsync(mb); + } + catch (Exception) + { + await message.DeleteAsync(); + throw; + } + } + + public async Task Handle(DallifyImageRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var user = ctx.User; + var message = ctx.Message; + + var imageUrl = await message.GetImageUrlFromMessage(); + + if (imageUrl == null) + { + await ctx.RespondAsync("I didn't find any images."); + return; + } + + var wokingOnItMessage = await request.Ctx.RespondAsync("Working on it"); + + try + { + var imageStreamResult = await DallifyImage(imageUrl, user.Id, Size1024); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageFilename = $"{Guid.NewGuid()}.{fileExtension}"; + + DiscordMessageBuilder mb = new DiscordMessageBuilder() + .AddFile(imageFilename, imageStream) + .WithContent($"There you go {request.Ctx.Member?.Mention ?? "Unknown user"}"); + + await wokingOnItMessage.DeleteAsync(); + + await request.Ctx.RespondAsync(mb); + } + catch (Exception) + { + await wokingOnItMessage.DeleteAsync(); + throw; + } + } + + private async Task DallifyImage(string imageUrl, ulong userId, int maxSize) + { + var image = await _imageService.DownloadImage(imageUrl); + + var imageAsPng = await _imageService.ConvertImageToPng(image, MaxImageSizeInMb); + + var squaredImage = _imageService.CropToSquare(imageAsPng); + + var resultSize = Math.Min(maxSize, Math.Max(ValidSizes.Reverse().FirstOrDefault(s => squaredImage.Height > s), ValidSizes[0])); + + var fileName = $"{Guid.NewGuid()}.png"; + + var inputImageStream = await _imageService.GetImageStream(squaredImage); + + var imageVariationRequest = new CreateImageVariationRequest + { + Image = inputImageStream.MemoryStream.ToArray(), + Size = $"{resultSize}x{resultSize}", + User = userId.ToString(), + }; + + var response = await _dalleHttpClient.CreateImageVariation(imageVariationRequest, fileName); + + if (response.Data == null || !response.Data.Any()) throw new Exception("Empty result"); + + var imageResponseUrl = response.Data.First(); + + var imageResult = await _imageService.DownloadImage(imageResponseUrl.Url); + + var imageStream = await _imageService.GetImageStream(imageResult); + + return imageStream; + } +} diff --git a/Kattbot/CommandHandlers/Images/GetAnimatedImages.cs b/Kattbot/CommandHandlers/Images/GetAnimatedImages.cs deleted file mode 100644 index 663990b..0000000 --- a/Kattbot/CommandHandlers/Images/GetAnimatedImages.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using DSharpPlus.CommandsNext; -using DSharpPlus.Entities; -using Kattbot.Helpers; -using Kattbot.Services.Images; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace Kattbot.CommandHandlers.Images; -#pragma warning disable SA1402 // File may only contain a single type -public class GetAnimatedEmoji : CommandRequest -{ - public GetAnimatedEmoji(CommandContext ctx) - : base(ctx) - { - } - - public DiscordEmoji Emoji { get; set; } = null!; - - public string? Speed { get; internal set; } -} - -public class GetAnimatedUserAvatar : CommandRequest -{ - public GetAnimatedUserAvatar(CommandContext ctx) - : base(ctx) - { - } - - public DiscordUser User { get; set; } = null!; - - public string? Speed { get; internal set; } -} - -public class GetAnimatedImagesHandlers : IRequestHandler, - IRequestHandler -{ - private readonly ImageService _imageService; - private readonly PetPetClient _petPetClient; - private readonly ILogger _logger; - - public GetAnimatedImagesHandlers(ImageService imageService, PetPetClient petPetClient, ILogger logger) - { - _imageService = imageService; - _petPetClient = petPetClient; - _logger = logger; - } - - public async Task Handle(GetAnimatedEmoji request, CancellationToken cancellationToken) - { - CommandContext ctx = request.Ctx; - DiscordEmoji emoji = request.Emoji; - - string url = emoji.GetEmojiImageUrl(); - string imageName = emoji.Id != 0 ? emoji.Id.ToString() : emoji.Name; - - using var image = await _imageService.DownloadImage(url); - - string imagePath = await _imageService.SaveImageToTempPath(image, imageName); - - byte[] animatedEmojiBytes = await _petPetClient.PetPet(imagePath, request.Speed); - - using var outputImage = ImageService.LoadImage(animatedEmojiBytes); - - ImageStreamResult imageStreamResult = await _imageService.GetImageStream(outputImage); - - var responseBuilder = new DiscordMessageBuilder(); - - responseBuilder.AddFile($"{imageName}.{imageStreamResult.FileExtension}", imageStreamResult.MemoryStream); - - await ctx.RespondAsync(responseBuilder); - } - - public async Task Handle(GetAnimatedUserAvatar request, CancellationToken cancellationToken) - { - CommandContext ctx = request.Ctx; - DiscordUser user = request.User; - DiscordGuild guild = ctx.Guild; - - DiscordMember? userAsMember = await ResolveGuildMember(guild, user.Id); - - if (userAsMember == null) - { - throw new Exception("Invalid user"); - } - - string avatarUrl = userAsMember.GuildAvatarUrl ?? userAsMember.AvatarUrl; - - if (string.IsNullOrEmpty(avatarUrl)) - { - throw new Exception("Couldn't load user avatar"); - } - - // TODO find a nicer filename - string imageName = user.Id.ToString(); - - using var inputImage = await _imageService.DownloadImage(avatarUrl); - - var croppedImage = _imageService.CropImageToCircle(inputImage); - - string imagePath = await _imageService.SaveImageToTempPath(croppedImage, imageName); - - byte[] animatedEmojiBytes = await _petPetClient.PetPet(imagePath, request.Speed); - - using var outputImage = ImageService.LoadImage(animatedEmojiBytes); - - ImageStreamResult imageStreamResult = await _imageService.GetImageStream(outputImage); - - var responseBuilder = new DiscordMessageBuilder(); - - responseBuilder.AddFile($"{imageName}.{imageStreamResult.FileExtension}", imageStreamResult.MemoryStream); - - await ctx.RespondAsync(responseBuilder); - } - - private Task ResolveGuildMember(DiscordGuild guild, ulong userId) - { - bool memberExists = guild.Members.TryGetValue(userId, out DiscordMember? member); - - return memberExists ? Task.FromResult(member) : guild.GetMemberAsync(userId); - } -} diff --git a/Kattbot/CommandHandlers/Images/GetBigEmote.cs b/Kattbot/CommandHandlers/Images/GetBigEmote.cs index 86e41be..a449f76 100644 --- a/Kattbot/CommandHandlers/Images/GetBigEmote.cs +++ b/Kattbot/CommandHandlers/Images/GetBigEmote.cs @@ -1,6 +1,4 @@ -using System; -using System.IO; -using System.Net.Http; +using System.IO; using System.Threading; using System.Threading.Tasks; using DSharpPlus.CommandsNext; @@ -13,19 +11,16 @@ namespace Kattbot.CommandHandlers.Images; public class GetBigEmoteRequest : CommandRequest { - public static readonly string EffectDeepFry = "deepfry"; - public static readonly string EffectOilPaint = "oilpaint"; - - public GetBigEmoteRequest(CommandContext ctx) + public GetBigEmoteRequest(CommandContext ctx, DiscordEmoji emoji, uint? scaleFactor = null) : base(ctx) { + Emoji = emoji; + ScaleFactor = scaleFactor; } - public DiscordEmoji Emoji { get; set; } = null!; + public DiscordEmoji Emoji { get; set; } public uint? ScaleFactor { get; set; } - - public string? Effect { get; set; } } #pragma warning disable SA1402 // File may only contain a single type @@ -43,37 +38,24 @@ public async Task Handle(GetBigEmoteRequest request, CancellationToken cancellat CommandContext ctx = request.Ctx; DiscordEmoji emoji = request.Emoji; bool hasScaleFactor = request.ScaleFactor.HasValue; - string? effect = request.Effect; string url = emoji.GetEmojiImageUrl(); - using var image = await _imageService.DownloadImage(url); + var image = await _imageService.DownloadImage(url); - ImageStreamResult imageStreamResult; - - if (effect == GetBigEmoteRequest.EffectDeepFry) - { - uint scaleFactor = request.ScaleFactor.HasValue ? request.ScaleFactor.Value : 2; - imageStreamResult = await _imageService.DeepFryImage(image, scaleFactor); - } - else if (effect == GetBigEmoteRequest.EffectOilPaint) - { - uint scaleFactor = request.ScaleFactor.HasValue ? request.ScaleFactor.Value : 2; - imageStreamResult = await _imageService.OilPaintImage(image, scaleFactor); - } - else + if (hasScaleFactor) { - imageStreamResult = hasScaleFactor - ? await _imageService.ScaleImage(image, request.ScaleFactor!.Value) - : await _imageService.GetImageStream(image); + image = _imageService.ScaleImage(image, request.ScaleFactor!.Value); } + var imageStreamResult = await _imageService.GetImageStream(image); + MemoryStream imageStream = imageStreamResult.MemoryStream; string fileExtension = imageStreamResult.FileExtension; var responseBuilder = new DiscordMessageBuilder(); - string fileName = hasScaleFactor ? "bigger" : "big"; + string fileName = hasScaleFactor ? $"big_x{request.ScaleFactor}" : "big"; responseBuilder.AddFile($"{fileName}.{fileExtension}", imageStream); diff --git a/Kattbot/CommandHandlers/Images/PetImage.cs b/Kattbot/CommandHandlers/Images/PetImage.cs new file mode 100644 index 0000000..bec3904 --- /dev/null +++ b/Kattbot/CommandHandlers/Images/PetImage.cs @@ -0,0 +1,166 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using Kattbot.Helpers; +using Kattbot.Services.Images; +using MediatR; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Kattbot.CommandHandlers.Images; + +#pragma warning disable SA1402 // File may only contain a single type +public class PetEmoteRequest : CommandRequest +{ + public PetEmoteRequest(CommandContext ctx, DiscordEmoji emoji, string? speed) + : base(ctx) + { + Emoji = emoji; + Speed = speed; + } + + public DiscordEmoji Emoji { get; set; } + + public string? Speed { get; set; } +} + +public class PetUserRequest : CommandRequest +{ + public PetUserRequest(CommandContext ctx, DiscordUser user, string? speed) + : base(ctx) + { + User = user; + Speed = speed; + } + + public DiscordUser User { get; set; } + + public string? Speed { get; set; } +} + +public class PetImageRequest : CommandRequest +{ + public PetImageRequest(CommandContext ctx, string? speed) + : base(ctx) + { + Speed = speed; + } + + public string? Speed { get; set; } +} + +public class PetImageHandlers : IRequestHandler, + IRequestHandler, + IRequestHandler +{ + private readonly ImageService _imageService; + private readonly PetPetClient _petPetClient; + private readonly DiscordResolver _discordResolver; + + public PetImageHandlers(ImageService imageService, PetPetClient petPetClient, DiscordResolver discordResolver) + { + _imageService = imageService; + _petPetClient = petPetClient; + _discordResolver = discordResolver; + } + + public async Task Handle(PetEmoteRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var emoji = request.Emoji; + + var imageUrl = emoji.GetEmojiImageUrl(); + + var imageStreamResult = await PetImage(imageUrl, request.Speed); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageName = emoji.Id != 0 ? emoji.Id.ToString() : emoji.Name; + + string fileName = $"{imageName}.{imageStreamResult.FileExtension}"; + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile(fileName, imageStreamResult.MemoryStream); + + await ctx.RespondAsync(responseBuilder); + } + + public async Task Handle(PetUserRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var user = request.User; + var guild = ctx.Guild; + + var userAsMember = await _discordResolver.ResolveGuildMember(guild, user.Id) ?? throw new Exception("Invalid user"); + + var imageUrl = userAsMember.GuildAvatarUrl + ?? userAsMember.AvatarUrl + ?? throw new Exception("Couldn't load user avatar"); + + var imageStreamResult = await PetImage(imageUrl, request.Speed, _imageService.CropToCircle); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageFilename = user.GetNicknameOrUsername().ToSafeFilename(fileExtension); + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile(imageFilename, imageStreamResult.MemoryStream); + + await ctx.RespondAsync(responseBuilder); + } + + public async Task Handle(PetImageRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var message = ctx.Message; + + var imageUrl = await message.GetImageUrlFromMessage(); + + if (imageUrl == null) + { + await ctx.RespondAsync("I didn't find any images."); + return; + } + + var imageStreamResult = await PetImage(imageUrl, request.Speed); + + using var imageStream = imageStreamResult.MemoryStream; + var fileExtension = imageStreamResult.FileExtension; + + var imageFilename = $"{Guid.NewGuid()}.{fileExtension}"; + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile(imageFilename, imageStreamResult.MemoryStream); + + await ctx.RespondAsync(responseBuilder); + } + + private async Task PetImage(string imageUrl, string? speed, ImageTransformDelegate? preTransform = null) + { + var inputImage = await _imageService.DownloadImage(imageUrl); + + if (preTransform != null) + { + inputImage = preTransform(inputImage); + } + + string extension = _imageService.GetImageFileExtension(inputImage); + + string imagePath = await _imageService.SaveImageToTempPath(inputImage, $"{Guid.NewGuid()}.{extension}"); + + byte[] animatedEmojiBytes = await _petPetClient.PetPet(imagePath, speed); + + var outputImage = ImageService.LoadImage(animatedEmojiBytes); + + var ouputImageStream = await _imageService.GetImageStream(outputImage); + + return ouputImageStream; + } +} diff --git a/Kattbot/CommandHandlers/Images/TransformImage.cs b/Kattbot/CommandHandlers/Images/TransformImage.cs new file mode 100644 index 0000000..af659af --- /dev/null +++ b/Kattbot/CommandHandlers/Images/TransformImage.cs @@ -0,0 +1,185 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.Entities; +using Kattbot.Helpers; +using Kattbot.Services.Images; +using MediatR; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Kattbot.CommandHandlers.Images; + +#pragma warning disable SA1402 // File may only contain a single type +public enum TransformImageEffect +{ + DeepFry, + OilPaint, + Twirl, +} + +public class TransformImageEmoteRequest : CommandRequest +{ + public TransformImageEmoteRequest(CommandContext ctx, DiscordEmoji emoji, TransformImageEffect effect) + : base(ctx) + { + Emoji = emoji; + Effect = effect; + } + + public DiscordEmoji Emoji { get; set; } + + public TransformImageEffect Effect { get; set; } +} + +public class TransformImageUserRequest : CommandRequest +{ + public TransformImageUserRequest(CommandContext ctx, DiscordUser user, TransformImageEffect effect) + : base(ctx) + { + User = user; + Effect = effect; + } + + public DiscordUser User { get; set; } + + public TransformImageEffect Effect { get; set; } +} + +public class TransformImageMessageRequest : CommandRequest +{ + public TransformImageMessageRequest(CommandContext ctx, TransformImageEffect effect) + : base(ctx) + { + Effect = effect; + } + + public TransformImageEffect Effect { get; set; } +} + +public class TransformImageHandler : IRequestHandler, + IRequestHandler, + IRequestHandler +{ + private readonly ImageService _imageService; + private readonly DiscordResolver _discordResolver; + + public TransformImageHandler(ImageService imageService, DiscordResolver discordResolver) + { + _imageService = imageService; + _discordResolver = discordResolver; + } + + public async Task Handle(TransformImageEmoteRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var emoji = request.Emoji; + var effect = request.Effect; + + string imageUrl = emoji.GetEmojiImageUrl(); + + var imageStreamResult = await TransformImage(imageUrl, effect); + + using var imageStream = imageStreamResult.MemoryStream; + string fileExtension = imageStreamResult.FileExtension; + + string fileName = $"{Guid.NewGuid()}.png"; + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile($"{fileName}.{fileExtension}", imageStream); + + await ctx.RespondAsync(responseBuilder); + } + + public async Task Handle(TransformImageUserRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var user = request.User; + var guild = ctx.Guild; + var effect = request.Effect; + + var userAsMember = await _discordResolver.ResolveGuildMember(guild, user.Id) + ?? throw new Exception("Invalid user"); + + string imageUrl = userAsMember.GuildAvatarUrl + ?? userAsMember.AvatarUrl + ?? throw new Exception("Couldn't load user avatar"); + + var imageStreamResult = await TransformImage(imageUrl, effect, _imageService.CropToCircle); + + using var imageStream = imageStreamResult.MemoryStream; + string fileExtension = imageStreamResult.FileExtension; + + string imageFilename = user.GetNicknameOrUsername().ToSafeFilename(fileExtension); + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile(imageFilename, imageStream); + + await ctx.RespondAsync(responseBuilder); + } + + public async Task Handle(TransformImageMessageRequest request, CancellationToken cancellationToken) + { + var ctx = request.Ctx; + var message = ctx.Message; + var effect = request.Effect; + + var imageUrl = await message.GetImageUrlFromMessage(); + + if (imageUrl == null) + { + await ctx.RespondAsync("I didn't find any images."); + return; + } + + var imageStreamResult = await TransformImage(imageUrl, effect); + + using var imageStream = imageStreamResult.MemoryStream; + string fileExtension = imageStreamResult.FileExtension; + + string imageFilename = $"{Guid.NewGuid()}.png"; + + var responseBuilder = new DiscordMessageBuilder(); + + responseBuilder.AddFile($"{imageFilename}.{fileExtension}", imageStream); + + await ctx.RespondAsync(responseBuilder); + } + + private async Task TransformImage(string imageUrl, TransformImageEffect effect, ImageTransformDelegate? preTransform = null) + { + var inputImage = await _imageService.DownloadImage(imageUrl); + + if (preTransform != null) + { + inputImage = (Image)preTransform(inputImage); + } + + Image imageResult; + + if (effect == TransformImageEffect.DeepFry) + { + imageResult = _imageService.DeepFryImage(inputImage); + } + else if (effect == TransformImageEffect.OilPaint) + { + imageResult = _imageService.OilPaintImage(inputImage); + } + else if (effect == TransformImageEffect.Twirl) + { + imageResult = _imageService.TwirlImage(inputImage, 90); + } + else + { + throw new InvalidOperationException($"Unknown effect: {effect}"); + } + + var imageStreamResult = await _imageService.GetImageStream(imageResult); + + return imageStreamResult; + } +} diff --git a/Kattbot/CommandModules/CommandModule.cs b/Kattbot/CommandModules/CommandModule.cs deleted file mode 100644 index 8eba47b..0000000 --- a/Kattbot/CommandModules/CommandModule.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text; -using System.Threading.Tasks; -using DSharpPlus.CommandsNext; -using DSharpPlus.CommandsNext.Attributes; -using Kattbot.Attributes; -using Kattbot.CommandModules.ResultFormatters; -using Microsoft.Extensions.Options; - -namespace Kattbot.CommandModules; - -[BaseCommandCheck] -public class CommandModule : BaseCommandModule -{ - private readonly BotOptions _options; - - public CommandModule( - IOptions options) - { - _options = options.Value; - } - - [Command("help")] - [Description("Return some help")] - public Task GetHelp(CommandContext ctx) - { - var sb = new StringBuilder(); - - string commandPrefix = _options.CommandPrefix; - - sb.AppendLine($"`{commandPrefix}stats me [?interval] [?page]`"); - sb.AppendLine($"`{commandPrefix}stats best [?interval] [?page]`"); - sb.AppendLine($"`{commandPrefix}stats [emote] [?interval]`"); - sb.AppendLine($"`{commandPrefix}stats help`"); - - sb.AppendLine(); - sb.AppendLine("Other commands"); - sb.AppendLine($"`{commandPrefix}prep me`"); - sb.AppendLine($"`{commandPrefix}prep [username]`"); - sb.AppendLine($"`{commandPrefix}meow`"); - sb.AppendLine($"`{commandPrefix}big [emote]`"); - sb.AppendLine($"`{commandPrefix}bigger [emote]`"); - sb.AppendLine($"`{commandPrefix}deepfry [emote]`"); - sb.AppendLine($"`{commandPrefix}oilpaint [emote]`"); - - sb.AppendLine($"*(\"?\" denotes an optional parameter)*"); - - sb.AppendLine(); - sb.AppendLine("Kattbot source code: github.com/selfdocumentingcode/Kattbot"); - - string result = FormattedResultHelper.BuildMessage($"Commands", sb.ToString()); - - return ctx.RespondAsync(result); - } -} diff --git a/Kattbot/CommandModules/EmoteModule.cs b/Kattbot/CommandModules/EmoteModule.cs new file mode 100644 index 0000000..61aead3 --- /dev/null +++ b/Kattbot/CommandModules/EmoteModule.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using Kattbot.Attributes; +using Kattbot.CommandHandlers.Images; +using Kattbot.Workers; + +namespace Kattbot.CommandModules; + +[BaseCommandCheck] +public class EmoteModule : BaseCommandModule +{ + private readonly CommandQueueChannel _commandParallelQueue; + + public EmoteModule(CommandQueueChannel commandParallelQueue) + { + _commandParallelQueue = commandParallelQueue; + } + + [Command("big")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task BigEmote(CommandContext ctx, DiscordEmoji emoji) + { + var request = new GetBigEmoteRequest(ctx, emoji); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("bigger")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task BiggerEmote(CommandContext ctx, DiscordEmoji emoji) + { + var request = new GetBigEmoteRequest(ctx, emoji, 2); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("gigantic")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task GiganticEmote(CommandContext ctx, DiscordEmoji emoji) + { + var request = new GetBigEmoteRequest(ctx, emoji, 3); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("humongous")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task HumongousEmote(CommandContext ctx, DiscordEmoji emoji) + { + var request = new GetBigEmoteRequest(ctx, emoji, 4); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } +} diff --git a/Kattbot/CommandModules/HelpModule.cs b/Kattbot/CommandModules/HelpModule.cs new file mode 100644 index 0000000..8764005 --- /dev/null +++ b/Kattbot/CommandModules/HelpModule.cs @@ -0,0 +1,136 @@ +using System.Text; +using System.Threading.Tasks; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using Kattbot.Attributes; +using Kattbot.CommandModules.ResultFormatters; +using Kattbot.Helpers; +using Microsoft.Extensions.Options; + +namespace Kattbot.CommandModules; + +[BaseCommandCheck] +[Group("help")] +public class HelpModule : BaseCommandModule +{ + private readonly BotOptions _options; + + public HelpModule( + IOptions options) + { + _options = options.Value; + } + + [GroupCommand] + [Description("Return some help")] + public Task GetHelp(CommandContext ctx) + { + var sb = new StringBuilder(); + + string commandPrefix = _options.CommandPrefix; + + sb.AppendLine($"`{commandPrefix}stats me [?interval] [?page]`"); + sb.AppendLine($"`{commandPrefix}stats best [?interval] [?page]`"); + sb.AppendLine($"`{commandPrefix}stats [emote] [?interval]`"); + sb.AppendLine($"`{commandPrefix}help stats .. See all stats command`"); + + sb.AppendLine(); + sb.AppendLine("Emote commands"); + sb.AppendLine($"`{commandPrefix}big [emote]`"); + sb.AppendLine($"`{commandPrefix}bigger [emote]`"); + sb.AppendLine($"`{commandPrefix}gigantic [emote]`"); + sb.AppendLine($"`{commandPrefix}humongous [emote]`"); + + sb.AppendLine(); + sb.AppendLine("Image commands"); + sb.AppendLine($"`{commandPrefix}deepfry [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}pet [emote|user|image] [?speed]`"); + sb.AppendLine($"`{commandPrefix}dallify [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}dalle [text]`"); + sb.AppendLine($"`{commandPrefix}help images .. See all image commands`"); + + sb.AppendLine(); + sb.AppendLine("Other commands"); + sb.AppendLine($"`{commandPrefix}meow`"); + + sb.AppendLine(); + sb.AppendLine($"`\"?\" denotes an optional parameter`"); + + sb.AppendLine(); + sb.AppendLine("Kattbot source code: [github.com/selfdocumentingcode/Kattbot](https://github.com/selfdocumentingcode/Kattbot)"); + + var eb = EmbedBuilderHelper.BuildSimpleEmbed("Help", sb.ToString()); + + return ctx.RespondAsync(eb); + } + + [Command("stats")] + [Description("Help about stats")] + public Task GetStatsHelp(CommandContext ctx) + { + var sb = new StringBuilder(); + + string commandPrefix = _options.CommandPrefix; + + sb.AppendLine(); + sb.AppendLine($"Command arguments:"); + sb.AppendLine($"`user .. Discord username with # identifier or @mention`"); + sb.AppendLine($"`emote .. Discord emote (server emotes only)`"); + sb.AppendLine($"`-p, --page .. Displays a different page of the result set`"); + sb.AppendLine($"` (default 1st page)`"); + sb.AppendLine($"`-i, --interval .. Limits result set to given interval`"); + sb.AppendLine($"` (default 2 months)`"); + sb.AppendLine($"` Valid interval units: \"m\", \"w\", \"d\"`"); + sb.AppendLine($"` Optionally use interval value \"lifetime\"`"); + sb.AppendLine(); + sb.AppendLine($"Usage examples:"); + sb.AppendLine($"`{commandPrefix}stats best`"); + sb.AppendLine($"`{commandPrefix}stats worst --page 2`"); + sb.AppendLine($"`{commandPrefix}stats @someUser --interval 3m`"); + sb.AppendLine($"`{commandPrefix}stats me -p 2 -i 2w`"); + sb.AppendLine($"`{commandPrefix}stats :a_server_emote:`"); + + var eb = EmbedBuilderHelper.BuildSimpleEmbed("Check out random emote related stats", sb.ToString()); + + return ctx.RespondAsync(eb); + } + + [Command("images")] + [Description("Help about images")] + public Task GetImagesHelp(CommandContext ctx) + { + var sb = new StringBuilder(); + + string commandPrefix = _options.CommandPrefix; + + sb.AppendLine(); + sb.AppendLine("Commands"); + sb.AppendLine($"`{commandPrefix}deepfry [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}oilpaint [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}twirl [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}pet [emote|user|image] [?speed]`"); + sb.AppendLine($"`{commandPrefix}dallify [emote|user|image]`"); + sb.AppendLine($"`{commandPrefix}dalle [text]`"); + + sb.AppendLine(); + sb.AppendLine($"Command arguments:"); + sb.AppendLine($"`user .. Discord username with # identifier or @mention`"); + sb.AppendLine($"`emote .. Discord emote (server emotes only)`"); + sb.AppendLine($"`image .. Attached or linked image in current message.`"); + sb.AppendLine($"` If message contains no images,`"); + sb.AppendLine($"` reply-to message is checked.`"); + sb.AppendLine($"`speed .. Petting speed`"); + sb.AppendLine($"` Valid speeds: \"slow\", \"normal\",`"); + sb.AppendLine($"` \"fast\", \"lightspeed\"`"); + + sb.AppendLine(); + sb.AppendLine($"Usage examples:"); + sb.AppendLine($"`{commandPrefix}deepfry :a_server_emote:`"); + sb.AppendLine($"`{commandPrefix}pet @someUser fast`"); + sb.AppendLine($"`{commandPrefix}dallify `"); + + var eb = EmbedBuilderHelper.BuildSimpleEmbed("Stuff you can do with images", sb.ToString()); + + return ctx.RespondAsync(eb); + } +} diff --git a/Kattbot/CommandModules/ImageModule.cs b/Kattbot/CommandModules/ImageModule.cs index 10c145e..d382e0e 100644 --- a/Kattbot/CommandModules/ImageModule.cs +++ b/Kattbot/CommandModules/ImageModule.cs @@ -18,91 +18,148 @@ public ImageModule(CommandQueueChannel commandParallelQueue) _commandParallelQueue = commandParallelQueue; } - [Command("big")] + [Command("deepfry")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task BigEmote(CommandContext ctx, DiscordEmoji emoji) + public Task DeepFry(CommandContext ctx, DiscordEmoji emoji) { - var request = new GetBigEmoteRequest(ctx) - { - Emoji = emoji, - }; - + var request = new TransformImageEmoteRequest(ctx, emoji, TransformImageEffect.DeepFry); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } - [Command("bigger")] + [Command("deepfry")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task BiggerEmote(CommandContext ctx, DiscordEmoji emoji) + public Task DeepFry(CommandContext ctx, DiscordUser user) { - var request = new GetBigEmoteRequest(ctx) - { - Emoji = emoji, - ScaleFactor = 2, - }; - + var request = new TransformImageUserRequest(ctx, user, TransformImageEffect.DeepFry); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } [Command("deepfry")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task DeepFryEmote(CommandContext ctx, DiscordEmoji emoji) +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public Task DeepFry(CommandContext ctx, string _ = "") +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var request = new TransformImageMessageRequest(ctx, TransformImageEffect.DeepFry); + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("oilpaint")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task OilPaint(CommandContext ctx, DiscordEmoji emoji) { - var request = new GetBigEmoteRequest(ctx) - { - Emoji = emoji, - ScaleFactor = 2, - Effect = GetBigEmoteRequest.EffectDeepFry, - }; + var request = new TransformImageEmoteRequest(ctx, emoji, TransformImageEffect.OilPaint); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } [Command("oilpaint")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task OilPaintEmote(CommandContext ctx, DiscordEmoji emoji) + public Task OilPaint(CommandContext ctx, DiscordUser user) { - var request = new GetBigEmoteRequest(ctx) - { - Emoji = emoji, - ScaleFactor = 2, - Effect = GetBigEmoteRequest.EffectOilPaint, - }; + var request = new TransformImageUserRequest(ctx, user, TransformImageEffect.OilPaint); + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("oilpaint")] + [Cooldown(5, 10, CooldownBucketType.Global)] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public Task OilPaint(CommandContext ctx, string _ = "") +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var request = new TransformImageMessageRequest(ctx, TransformImageEffect.OilPaint); + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("twirl")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task Twirl(CommandContext ctx, DiscordEmoji emoji) + { + var request = new TransformImageEmoteRequest(ctx, emoji, TransformImageEffect.Twirl); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } + [Command("twirl")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task Twirl(CommandContext ctx, DiscordUser user) + { + var request = new TransformImageUserRequest(ctx, user, TransformImageEffect.Twirl); + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("twirl")] + [Cooldown(5, 10, CooldownBucketType.Global)] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public Task Twirl(CommandContext ctx, string _ = "") +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var request = new TransformImageMessageRequest(ctx, TransformImageEffect.Twirl); + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + [Command("pet")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task PetEmote(CommandContext ctx, DiscordEmoji emoji, string speed = null) + public Task Pet(CommandContext ctx, DiscordEmoji emoji, string? speed = null) { - var request = new GetAnimatedEmoji(ctx) - { - Emoji = emoji, - Speed = speed, - }; + var request = new PetEmoteRequest(ctx, emoji, speed); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } [Command("pet")] [Cooldown(5, 10, CooldownBucketType.Global)] - public Task PetUser(CommandContext ctx, DiscordUser user, string speed = null) + public Task Pet(CommandContext ctx, DiscordUser user, string? speed = null) { - var request = new GetAnimatedUserAvatar(ctx) - { - User = user, - Speed = speed, - }; + var request = new PetUserRequest(ctx, user, speed); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("pet")] + [Cooldown(5, 10, CooldownBucketType.Global)] + public Task Pet(CommandContext ctx, string? speed = null) + { + var request = new PetImageRequest(ctx, speed); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } [Command("dalle")] - [Cooldown(5, 60, CooldownBucketType.Global)] + [Cooldown(5, 30, CooldownBucketType.Global)] public Task Dalle(CommandContext ctx, [RemainingText] string prompt) { var request = new DallePromptCommand(ctx, prompt); return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); } + + [Command("dallify")] + [Cooldown(5, 30, CooldownBucketType.Global)] + public Task Dallify(CommandContext ctx, DiscordEmoji emoji) + { + var request = new DallifyEmoteRequest(ctx, emoji); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("dallify")] + [Cooldown(5, 30, CooldownBucketType.Global)] + public Task Dallify(CommandContext ctx, DiscordUser user) + { + var request = new DallifyUserRequest(ctx, user); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } + + [Command("dallify")] + [Cooldown(5, 30, CooldownBucketType.Global)] +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + public Task Dallify(CommandContext ctx, string _ = "") +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + var request = new DallifyImageRequest(ctx); + + return _commandParallelQueue.Writer.WriteAsync(request).AsTask(); + } } diff --git a/Kattbot/CommandModules/ResultFormatters/EmbedBuilderHelper.cs b/Kattbot/CommandModules/ResultFormatters/EmbedBuilderHelper.cs deleted file mode 100644 index 511e8cb..0000000 --- a/Kattbot/CommandModules/ResultFormatters/EmbedBuilderHelper.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using DSharpPlus.Entities; -using Kattbot.Common.Models.Emotes; -using Kattbot.Helpers; - -namespace Kattbot.CommandModules.ResultFormatters; - -public static class EmbedBuilderHelper -{ - public static string EmbedSpacer = "\u200b"; - - public static DiscordEmbed BuildSimpleEmbed(string title, string message) - { - var eb = new DiscordEmbedBuilder(); - - eb.AddField(title, message); - - DiscordEmbed result = eb.Build(); - - return result; - } - - public static List FormatEmoteStats(List emoteStats) - { - int rank = 1; - - var textLines = new List(); - - foreach (EmoteStats emoteUsage in emoteStats) - { - string emoteCode = EmoteHelper.BuildEmoteCode(emoteUsage.EmoteId, emoteUsage.IsAnimated); - - textLines.Add($"{rank}.{emoteCode}:{emoteUsage.Usage}"); - - rank++; - } - - return textLines; - } - - public static List FormatExtendedEmoteStats(List emoteStats) - { - int rank = 1; - - var textLines = new List(); - - foreach (ExtendedEmoteStats emoteUsage in emoteStats) - { - string formattedPercentage = emoteUsage.PercentageOfTotal.ToString("P"); - - textLines.Add($"{rank}.{emoteUsage.EmoteCode}:{emoteUsage.Usage} ({formattedPercentage})"); - - rank++; - } - - return textLines; - } - - public static DiscordEmbed BuildEmbedFromLines(string title, List allLines, int linesPerColumn = 10) - { - int columnCount = (int)Math.Ceiling((double)allLines.Count / linesPerColumn); - - var sbs = new StringBuilder[columnCount]; - - for (int i = 0; i < allLines.Count; i++) - { - int columnToPushTo = i / linesPerColumn; - - if (sbs[columnToPushTo] == null) - { - sbs[columnToPushTo] = new StringBuilder(); - } - - sbs[columnToPushTo].AppendLine(allLines[i]); - } - - var eb = new DiscordEmbedBuilder(); - - eb.WithTitle(title); - - for (int i = 0; i < sbs.Length; i++) - { - string sbString = sbs[i].ToString(); - - eb.AddField(EmbedSpacer, sbString, true); - } - - DiscordEmbed result = eb.Build(); - - return result; - } -} diff --git a/Kattbot/CommandModules/ResultFormatters/FormattedResultHelper.cs b/Kattbot/CommandModules/ResultFormatters/FormattedResultHelper.cs index 0b76a88..537e0e7 100644 --- a/Kattbot/CommandModules/ResultFormatters/FormattedResultHelper.cs +++ b/Kattbot/CommandModules/ResultFormatters/FormattedResultHelper.cs @@ -96,11 +96,4 @@ public static string BuildBody(List allLines) return sb.ToString(); } - - public static string BuildMessage(string title, string message) - { - string result = $"{title}\r\n{message}"; - - return result; - } } diff --git a/Kattbot/CommandModules/StatsCommandModule.cs b/Kattbot/CommandModules/StatsCommandModule.cs index 49bf8e8..cb42908 100644 --- a/Kattbot/CommandModules/StatsCommandModule.cs +++ b/Kattbot/CommandModules/StatsCommandModule.cs @@ -163,35 +163,6 @@ private Task GetEmoteStats(CommandContext ctx, TempEmote emote, string interval) return _commandQueue.Writer.WriteAsync(request).AsTask(); } - [Command("help")] - [Description("Help about stats")] - public Task GetHelpStats(CommandContext ctx) - { - var sb = new StringBuilder(); - - string commandPrefix = _options.CommandPrefix; - - sb.AppendLine(); - sb.AppendLine($"Command arguments:"); - sb.AppendLine($"`username .. Discord username with # identifier or @mention`"); - sb.AppendLine($"`emote .. Discord emote (server emotes only)`"); - sb.AppendLine($"`-p, --page .. Displays a different page of the result set (default 1st page)`"); - sb.AppendLine($"`-i, --interval .. Limits result set to given interval (default 2 months)`"); - sb.AppendLine($"` Valid interval units: \"m\", \"w\", \"d\"`"); - sb.AppendLine($"` Optionally use interval value \"lifetime\"`"); - sb.AppendLine(); - sb.AppendLine($"Usage examples:"); - sb.AppendLine($"`{commandPrefix}stats best`"); - sb.AppendLine($"`{commandPrefix}stats worst --page 2`"); - sb.AppendLine($"`{commandPrefix}stats User#1234 --interval 3m`"); - sb.AppendLine($"`{commandPrefix}stats me -p 2 -i 2w`"); - sb.AppendLine($"`{commandPrefix}stats :a_server_emote:`"); - - string result = FormattedResultHelper.BuildMessage($"Shows server-wide emote stats-or for a specific user", sb.ToString()); - - return ctx.RespondAsync(result); - } - private bool TryGetDateFromInterval(IntervalValue interval, out DateTime? dateTime) { if (interval.IsLifetime) diff --git a/Kattbot/CommandModules/UtilsModule.cs b/Kattbot/CommandModules/UtilsModule.cs index 3b45bc5..a022eea 100644 --- a/Kattbot/CommandModules/UtilsModule.cs +++ b/Kattbot/CommandModules/UtilsModule.cs @@ -13,13 +13,6 @@ namespace Kattbot.CommandModules; [Group("utils")] public class UtilsModule : BaseCommandModule { - private readonly DiscordErrorLogger _discordErrorLogger; - - public UtilsModule(DiscordErrorLogger discordErrorLogger) - { - _discordErrorLogger = discordErrorLogger; - } - [Command("emoji-code")] public Task GetEmojiCode(CommandContext ctx, DiscordEmoji emoji) { @@ -61,16 +54,8 @@ public Task GetEmojiCode(CommandContext ctx, DiscordEmoji emoji) [Command("role-id")] public Task GetRoleId(CommandContext ctx, string roleName) { - TryResolveResult result = DiscordRoleResolver.TryResolveByName(ctx.Guild, roleName, out DiscordRole? discordRole); + TryResolveResult result = DiscordResolver.TryResolveRoleByName(ctx.Guild, roleName, out DiscordRole? discordRole); return !result.Resolved ? ctx.RespondAsync(result.ErrorMessage) : ctx.RespondAsync($"Role {roleName} has id {discordRole.Id}"); } - - [Command("test-log-sync")] - public Task TestLogSync(CommandContext ctx) - { - _discordErrorLogger.LogError("Test error sync"); - - return Task.CompletedTask; - } } diff --git a/Kattbot/Helpers/DiscordConstants.cs b/Kattbot/Helpers/DiscordConstants.cs index 5afb424..0375677 100644 --- a/Kattbot/Helpers/DiscordConstants.cs +++ b/Kattbot/Helpers/DiscordConstants.cs @@ -4,4 +4,5 @@ public class DiscordConstants { public const int MaxMessageLength = 2000; public const int MaxEmbedContentLength = 4096; + public const int DefaultEmbedColor = 9648895; // #4d5cac } diff --git a/Kattbot/Helpers/DiscordExtensions.cs b/Kattbot/Helpers/DiscordExtensions.cs index f18ee00..f5230e3 100644 --- a/Kattbot/Helpers/DiscordExtensions.cs +++ b/Kattbot/Helpers/DiscordExtensions.cs @@ -1,4 +1,9 @@ -using DSharpPlus.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.Entities; namespace Kattbot.Helpers; @@ -24,4 +29,78 @@ public static string GetEmojiImageUrl(this DiscordEmoji emoji) return isEmote ? emoji.Url : EmoteHelper.GetExternalEmojiImageUrl(emoji.Name); } + + public static async Task GetImageUrlFromMessage(this DiscordMessage message) + { + var imgUrl = message.GetAttachmentOrStickerImage(); + + if (imgUrl != null) + return imgUrl; + + if (message.ReferencedMessage != null) + imgUrl = message.ReferencedMessage.GetAttachmentOrStickerImage(); + + if (imgUrl != null) + return imgUrl; + + var waitTasks = new List> { message.WaitForEmbedImage() }; + + if (message.ReferencedMessage != null) + waitTasks.Add(message.ReferencedMessage.WaitForEmbedImage()); + + imgUrl = await (await Task.WhenAny(waitTasks)); + + return imgUrl; + } + + private static string? GetAttachmentOrStickerImage(this DiscordMessage message) + { + if (message.Attachments.Count > 0) + { + var imgAttachment = message.Attachments.Where(a => a.MediaType.StartsWith("image")).FirstOrDefault(); + + if (imgAttachment != null) + { + return imgAttachment.Url; + } + } + else if (message.Stickers.Count > 0) + { + return message.Stickers[0].StickerUrl; + } + + return null; + } + + private static async Task WaitForEmbedImage(this DiscordMessage message) + { + const int maxWaitDurationms = 5 * 1000; + const int delayMs = 100; + + var cts = new CancellationTokenSource(maxWaitDurationms); + + try + { + while (!cts.IsCancellationRequested) + { + if (message.Embeds.Count > 0) + { + var imgEmbed = message.Embeds.Where(e => e.Type == "image").FirstOrDefault(); + + if (imgEmbed != null) + { + return imgEmbed.Url.AbsoluteUri; + } + } + + await Task.Delay(delayMs); + } + } + catch (OperationCanceledException) + { + return null; + } + + return null; + } } diff --git a/Kattbot/Helpers/DiscordResolver.cs b/Kattbot/Helpers/DiscordResolver.cs new file mode 100644 index 0000000..df56cfd --- /dev/null +++ b/Kattbot/Helpers/DiscordResolver.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.Entities; +using Kattbot.Services; + +namespace Kattbot.Helpers; + +public class DiscordResolver +{ + private readonly DiscordErrorLogger _discordErrorLogger; + + public DiscordResolver(DiscordErrorLogger discordErrorLogger) + { + _discordErrorLogger = discordErrorLogger; + } + + public static TryResolveResult TryResolveRoleByName(DiscordGuild guild, string discordRoleName, out DiscordRole discordRole) + { + var matchingDiscordRoles = guild.Roles + .Where(kv => kv.Value.Name.Contains(discordRoleName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingDiscordRoles.Count == 0) + { + discordRole = null!; + return new TryResolveResult(false, $"No role matches the name {discordRoleName}"); + } + else if (matchingDiscordRoles.Count > 1) + { + discordRole = null!; + return new TryResolveResult(false, $"More than 1 role matches the name {discordRoleName}"); + } + + discordRole = matchingDiscordRoles[0].Value; + + return new TryResolveResult(true); + } + + public async Task ResolveGuildMember(DiscordGuild guild, ulong userId) + { + var memberExists = guild.Members.TryGetValue(userId, out DiscordMember? member); + + if (memberExists) return member; + + try + { + return (await guild.GetMemberAsync(userId)) ?? throw new ArgumentException($"Missing member with id {userId}"); + } + catch (Exception) + { + _discordErrorLogger.LogError("Missing member"); + + return null; + } + } +} diff --git a/Kattbot/Helpers/DiscordRoleResolver.cs b/Kattbot/Helpers/DiscordRoleResolver.cs deleted file mode 100644 index 1e77cf1..0000000 --- a/Kattbot/Helpers/DiscordRoleResolver.cs +++ /dev/null @@ -1,34 +0,0 @@ -using DSharpPlus.Entities; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Kattbot.Helpers -{ - public static class DiscordRoleResolver - { - public static TryResolveResult TryResolveByName(DiscordGuild guild, string discordRoleName, out DiscordRole discordRole) - { - var matchingDiscordRoles = guild.Roles - .Where(kv => kv.Value.Name.Contains(discordRoleName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (matchingDiscordRoles.Count == 0) - { - discordRole = null!; - return new TryResolveResult(false, $"No role matches the name {discordRoleName}"); - } - else if (matchingDiscordRoles.Count > 1) - { - discordRole = null!; - return new TryResolveResult(false, $"More than 1 role matches the name {discordRoleName}"); - } - - discordRole = matchingDiscordRoles[0].Value; - - return new TryResolveResult(true); - } - } -} diff --git a/Kattbot/Helpers/EmbedBuilderHelper.cs b/Kattbot/Helpers/EmbedBuilderHelper.cs new file mode 100644 index 0000000..aa5f18d --- /dev/null +++ b/Kattbot/Helpers/EmbedBuilderHelper.cs @@ -0,0 +1,24 @@ +using System; +using DSharpPlus.Entities; + +namespace Kattbot.Helpers; + +public static class EmbedBuilderHelper +{ + public static DiscordEmbed BuildSimpleEmbed(string title, string message) + { + return BuildSimpleEmbed(title, message, DiscordConstants.DefaultEmbedColor); + } + + public static DiscordEmbed BuildSimpleEmbed(string title, string message, int color) + { + var truncatedMessage = message.Substring(0, Math.Min(message.Length, DiscordConstants.MaxEmbedContentLength)); + + var eb = new DiscordEmbedBuilder() + .WithTitle(title) + .WithDescription(truncatedMessage) + .WithColor(color); + + return eb.Build(); + } +} diff --git a/Kattbot/Helpers/StringExtensions.cs b/Kattbot/Helpers/StringExtensions.cs index a980c91..ce06eb4 100644 --- a/Kattbot/Helpers/StringExtensions.cs +++ b/Kattbot/Helpers/StringExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; namespace Kattbot.Helpers; @@ -61,4 +62,15 @@ public static List SplitString(this string input, int chunkLength, strin return result; } + + public static string ToSafeFilename(this string input, string extension) + { + string safeFilename = new(Encoding.ASCII.GetString(Encoding.ASCII.GetBytes(input)) + .Select(c => char.IsLetterOrDigit(c) ? c : '_') + .ToArray()); + + string filename = $"{safeFilename}.{extension}"; + + return filename; + } } diff --git a/Kattbot/Program.cs b/Kattbot/Program.cs index 64bd4a5..9904480 100644 --- a/Kattbot/Program.cs +++ b/Kattbot/Program.cs @@ -123,6 +123,7 @@ private static void AddInternalServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } private static void AddRepositories(IServiceCollection services) diff --git a/Kattbot/Services/Dalle/DalleHttpClient.cs b/Kattbot/Services/Dalle/DalleHttpClient.cs index f074849..56b4795 100644 --- a/Kattbot/Services/Dalle/DalleHttpClient.cs +++ b/Kattbot/Services/Dalle/DalleHttpClient.cs @@ -19,7 +19,6 @@ public DalleHttpClient(HttpClient client, IOptions options) _client = client; _client.BaseAddress = new Uri("https://api.openai.com/v1/images/"); - _client.DefaultRequestHeaders.Add("Accept", "application/json"); _client.DefaultRequestHeaders.Add("Authorization", $"Bearer {options.Value.OpenAiApiKey}"); } @@ -55,4 +54,51 @@ public async Task CreateImage(CreateImageRequest request) throw new Exception($"HTTP {ex.StatusCode}: {ex.Message}"); } } + + public async Task CreateImageVariation(CreateImageVariationRequest request, string fileName) + { + var postBody = new MultipartFormDataContent(); + + if (request.N.HasValue) + postBody.Add(new StringContent(request.N.ToString()!), "n"); + + if (request.Size != null) + postBody.Add(new StringContent(request.Size), "size"); + + if (request.ResponseFormat != null) + postBody.Add(new StringContent(request.ResponseFormat), "response_format"); + + if (request.User != null) + postBody.Add(new StringContent(request.User), "user"); + + postBody.Add(new ByteArrayContent(request.Image), "image", fileName); + + HttpResponseMessage? response; + Stream? responseContentStream = null; + + try + { + response = await _client.PostAsync("variations", postBody); + + responseContentStream = await response.Content.ReadAsStreamAsync(); + + response.EnsureSuccessStatusCode(); + + var parsedResponse = await JsonSerializer.DeserializeAsync(responseContentStream) + ?? throw new Exception("Failed to parse response"); + + return parsedResponse; + } + catch (HttpRequestException) when (responseContentStream != null) + { + var parsedResponse = await JsonSerializer.DeserializeAsync(responseContentStream) + ?? throw new Exception("Failed to parse error response"); + + throw new Exception(parsedResponse.Error.Message); + } + catch (HttpRequestException ex) + { + throw new Exception($"HTTP {ex.StatusCode}: {ex.Message}"); + } + } } diff --git a/Kattbot/Services/Dalle/DalleModels.cs b/Kattbot/Services/Dalle/DalleModels.cs index 3f16cce..1333a30 100644 --- a/Kattbot/Services/Dalle/DalleModels.cs +++ b/Kattbot/Services/Dalle/DalleModels.cs @@ -3,6 +3,7 @@ namespace Kattbot.Services.Dalle; +#pragma warning disable SA1402 // File may only contain a single type public record CreateImageRequest { /// @@ -40,6 +41,48 @@ public record CreateImageRequest /// Gets or sets a unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. /// https://platform.openai.com/docs/api-reference/chat/create#chat/create-user. /// + [JsonPropertyName("user")] + public string? User { get; set; } +} + +public record CreateImageVariationRequest +{ + /// + /// Gets or sets the image to use as the basis for the variation(s). Must be a valid PNG file, less than 4MB, and square. + /// https://platform.openai.com/docs/api-reference/images/createVariation#image. + /// + [JsonPropertyName("image")] + public byte[] Image { get; set; } = null!; + + /// + /// Gets or sets the number of images to generate. Must be between 1 and 10. + /// Defaults to 1. + /// https://platform.openai.com/docs/api-reference/images/createVariation#n. + /// + [JsonPropertyName("n")] + public int? N { get; set; } + + /// + /// Gets or sets the size of the generated images. Must be one of 256x256, 512x512, or 1024x1024. + /// Defaults to 1024x1024 + /// https://platform.openai.com/docs/api-reference/images/createVariation#size. + /// + [JsonPropertyName("size")] + public string? Size { get; set; } + + /// + /// Gets or sets the format in which the generated images are returned. Must be one of url or b64_json. + /// Defaults to url + /// https://platform.openai.com/docs/api-reference/images/createVariation#response_format. + /// + [JsonPropertyName("response_format")] + public string? ResponseFormat { get; set; } + + /// + /// Gets or sets a unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. + /// https://platform.openai.com/docs/api-reference/images/createVariation#user. + /// + [JsonPropertyName("user")] public string? User { get; set; } } diff --git a/Kattbot/Services/Images/ImageService.cs b/Kattbot/Services/Images/ImageService.cs index e243d90..5349ba7 100644 --- a/Kattbot/Services/Images/ImageService.cs +++ b/Kattbot/Services/Images/ImageService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; @@ -15,10 +14,12 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using Color = SixLabors.ImageSharp.Color; -using Point = SixLabors.ImageSharp.Point; namespace Kattbot.Services.Images; +public delegate Image ImageTransformDelegate(Image input) + where TPixel : unmanaged, IPixel; + public class ImageService { private readonly IHttpClientFactory _httpClientFactory; @@ -33,19 +34,56 @@ public static Image LoadImage(byte[] imageBytes) return Image.Load(imageBytes); } + public async Task ConvertImageToPng(Image image, int? maxSizeInMb = null) + { + using var pngMemoryStream = new MemoryStream(); + + await image.SaveAsPngAsync(pngMemoryStream); + + var sizeInMb = (double)pngMemoryStream.Length / (1024 * 1024); + + var imageLargerThanMaxSize = maxSizeInMb.HasValue && sizeInMb > maxSizeInMb; + var imageNotPng = image.Metadata.DecodedImageFormat is not PngFormat; + + if (!imageLargerThanMaxSize && !imageNotPng) + { + return image; + } + + pngMemoryStream.Position = 0; + var imageAsPng = await Image.LoadAsync(pngMemoryStream); + + if (imageLargerThanMaxSize) + { + double differenceRatio = sizeInMb / (int)maxSizeInMb!; + imageAsPng = ScaleImage(imageAsPng, 1 / differenceRatio); + } + + return imageAsPng; + } + public async Task DownloadImage(string url) { - byte[] imageBytes; + var bytes = await DownloadImageBytes(url); + return Image.Load(bytes); + } + + public async Task> DownloadImage(string url) + where TPixel : unmanaged, IPixel + { + var bytes = await DownloadImageBytes(url); + + return Image.Load(bytes); + } + + private async Task DownloadImageBytes(string url) + { try { HttpClient client = _httpClientFactory.CreateClient(); - imageBytes = await client.GetByteArrayAsync(url); - - var image = Image.Load(imageBytes); - - return image; + return await client.GetByteArrayAsync(url); } catch (HttpRequestException) { @@ -57,101 +95,141 @@ public async Task DownloadImage(string url) } } - public Task ScaleImage(Image image, uint scaleFactor) + public Image ScaleImage(Image image, double scaleFactor) { - int newWidth = image.Width * (int)scaleFactor; - int newHeight = image.Height * (int)scaleFactor; + int newWidth = (int)(image.Width * scaleFactor); + int newHeight = (int)(image.Height * scaleFactor); image.Mutate(i => i.Resize(newWidth, newHeight, KnownResamplers.Hermite)); - return GetImageStream(image); + return image; } - public Task DeepFryImage(Image image, uint scaleFactor) + public Image DeepFryImage(Image image) { - int newWidth = image.Width * (int)scaleFactor; - int newHeight = image.Height * (int)scaleFactor; - image.Mutate(i => { - i.Resize(newWidth, newHeight, KnownResamplers.Welch); i.Contrast(5f); i.Brightness(1.5f); i.GaussianSharpen(5f); i.Saturate(5f); }); - return GetImageStream(image); + return image; } - public Task OilPaintImage(Image image, uint scaleFactor) + public Image OilPaintImage(Image image) { - int newWidth = image.Width * (int)scaleFactor; - int newHeight = image.Height * (int)scaleFactor; - int paintLevel = 25; image.Mutate(i => { - i.Resize(newWidth, newHeight, KnownResamplers.Welch); i.OilPaint(paintLevel, paintLevel); }); - return GetImageStream(image); + return image; } - public Image CropImageToCircle(Image image) + /// + /// Twirls an image + /// Source: jhlabs.com. + /// + /// Source image. + /// Angle in degrees. + /// Twirled image. + public Image TwirlImage(Image src, float angleDeg = 180) { - var ellipsePath = new EllipsePolygon(image.Width / 2, image.Height / 2, image.Width, image.Height); + var dest = new Image(src.Width, src.Height); + + var centerX = src.Width / 2; + var centerY = src.Height / 2; + var radius = Math.Min(centerX, centerY); + var radius2 = radius * radius; + float angleRad = (float)(angleDeg * Math.PI / 180); - var cloned = image.Clone(i => + var transformFn = (int x, int y) => { - i.SetGraphicsOptions(new GraphicsOptions() + int newX = x; + int newY = x; + + float dx = x - centerX; + float dy = y - centerY; + float distance = (dx * dx) + (dy * dy); + + if (distance <= radius2) { - Antialias = true, - AlphaCompositionMode = PixelAlphaCompositionMode.DestIn, - }); + distance = (float)Math.Sqrt(distance); + float a = (float)Math.Atan2(dy, dx) + (angleRad * (radius - distance) / radius); - i.Fill(Color.Red, ellipsePath); - }); + newX = (int)Math.Floor(centerX + (distance * (float)Math.Cos(a))); + newY = (int)Math.Floor(centerY + (distance * (float)Math.Sin(a))); + } - return cloned; + return (x: newX, y: newY); + }; + + for (int x = 0; x < src.Width; x++) + { + for (int y = 0; y < src.Height; y++) + { + var trans = transformFn(x, y); + dest[x, y] = src[trans.x, trans.y]; + } + } + + return dest; } - public async Task CombineImages(string[] base64Images) + public Image CropToCircle(Image image) + where TPixel : unmanaged, IPixel { - IEnumerable bytesImages = base64Images.Select(Convert.FromBase64String); - - var images = bytesImages.Select(x => Image.Load(x)).ToList(); + var ellipsePath = new EllipsePolygon(image.Width / 2, image.Height / 2, image.Width, image.Height); - // Assume all images have the same size. If this turns out to not be true, - // might have to upscale/downscale them to get them to be the same size. - int imageWidth = images.First().Width; - int imageHeight = images.First().Height; + Image imageAsPngWithTransparency; - int gridSize = (int)Math.Ceiling(Math.Sqrt(images.Count)); + if (image.Metadata.DecodedImageFormat is not PngFormat || + image.Metadata.GetPngMetadata().ColorType is not PngColorType.RgbWithAlpha) + { + using var stream = new MemoryStream(); - int canvasWidth = imageWidth * gridSize; - int canvasHeight = imageHeight * gridSize; + image.SaveAsPngAsync(stream, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha }); - var outputImage = new Image(canvasWidth, canvasHeight); + stream.Position = 0; - for (int i = 0; i < images.Count; i++) + imageAsPngWithTransparency = Image.Load(stream); + } + else { - int x = i % gridSize; - int y = i / gridSize; + imageAsPngWithTransparency = image; + } - Image image = images[i]; + var cloned = imageAsPngWithTransparency.Clone(i => + { + var opts = new DrawingOptions() + { + GraphicsOptions = new GraphicsOptions() + { + Antialias = true, + AlphaCompositionMode = PixelAlphaCompositionMode.DestIn, + }, + }; + + i.Fill(opts, Color.Black, ellipsePath); + }); - int positionX = imageWidth * x; - int positionY = imageHeight * y; + return cloned; + } - outputImage.Mutate(x => x.DrawImage(image, new Point(positionX, positionY), 1f)); - } + public Image CropToSquare(Image image) + { + int newSize = Math.Min(image.Width, image.Height); - ImageStreamResult outputImageStream = await GetImageStream(outputImage); + image.Mutate(i => + { + i.Crop(newSize, newSize); + }); - return outputImageStream; + return image; } public async Task SaveImageToTempPath(Image image, string filename) @@ -188,7 +266,16 @@ public async Task GetImageStream(Image image) return new ImageStreamResult(outputStream, extensionName); } - private IImageEncoder GetImageEncoderByFileType(string fileType) + public string GetImageFileExtension(Image image) + { + var format = image.Metadata.GetFormatOrDefault(); + + string extensionName = format.FileExtensions.First(); + + return extensionName; + } + + private static IImageEncoder GetImageEncoderByFileType(string fileType) { return fileType switch { diff --git a/Kattbot/Services/Images/PetPetClient.cs b/Kattbot/Services/Images/PetPetClient.cs index 69bc754..2ab6c3f 100644 --- a/Kattbot/Services/Images/PetPetClient.cs +++ b/Kattbot/Services/Images/PetPetClient.cs @@ -77,7 +77,6 @@ private static string ParseSpeed(string? speed = null) const int speedSlow = 8; const int speedNormal = 16; const int speedFast = 32; - const int speedFaster = 48; const int speedLightspeed = 60; return ((speed ?? string.Empty).ToLower() switch @@ -85,7 +84,6 @@ private static string ParseSpeed(string? speed = null) "slow" => speedSlow, "normal" => speedNormal, "fast" => speedFast, - "faster" => speedFaster, "lightspeed" => speedLightspeed, _ => speedNormal, }).ToString();