diff --git a/Cliptok.csproj b/Cliptok.csproj index 77857bcc..43c5f383 100644 --- a/Cliptok.csproj +++ b/Cliptok.csproj @@ -36,6 +36,8 @@ + + diff --git a/Commands/DebugCmds.cs b/Commands/DebugCmds.cs index d1f23c15..889c8fc8 100644 --- a/Commands/DebugCmds.cs +++ b/Commands/DebugCmds.cs @@ -9,6 +9,41 @@ public class DebugCmds { public static Dictionary OverridesPendingAddition = new(); + [Command("gif")] + [Description("Debug GIF properties.")] + public async Task GifDebug(CommandContext ctx, string gifToCheck) + { + if (Program.cfgjson.SeizureDetection is null) + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Seizure detection is not configured! Please check `seizureDetection` in config.json."); + return; + } + + string gifUrl; + var emojiMatches = Constants.RegexConstants.animoji_rx.Matches(gifToCheck); + if (emojiMatches.Count > 0) + { + gifUrl = "https://cdn.discordapp.com/emojis/" + Constants.RegexConstants.id_rx.Match(emojiMatches.First().Value).Value + ".gif"; + } + else + { + gifUrl = gifToCheck; + } + + SeizureDetectionHelpers.ImageInfo Gif = await SeizureDetectionHelpers.GetGifPropertiesAsync(gifUrl); + string strOut = "**GIF information**\n"; + strOut += $"----------\nFrame count: **{Gif.FrameCount}**\n"; + strOut += $"Unique frame count: **{Gif.UniqueFrameCount}**\n"; + strOut += $"Average frame difference: **{Math.Round(Gif.AverageFrameDifference, 2)}**\n"; + strOut += $"Average frame contrast: **{Math.Round(Gif.AverageContrast, 2)}**\n"; + strOut += $"Length: **{Gif.Length}ms**\n"; + strOut += $"Frame duration: **{Math.Round(Gif.Duration, 2)}ms**\n"; + strOut += $"Framerate: **{Math.Round(Gif.FrameRate, 2)}fps**\n"; + strOut += $"Seizure-inducing: **{Gif.IsSeizureInducing}**\n"; + strOut += "----------"; + await ctx.RespondAsync(strOut); + } + [Command("logprint")] public async Task LogPrint(TextCommandContext ctx) { diff --git a/Constants/RegexConstants.cs b/Constants/RegexConstants.cs index 8c220356..d6562eff 100644 --- a/Constants/RegexConstants.cs +++ b/Constants/RegexConstants.cs @@ -21,5 +21,6 @@ public class RegexConstants readonly public static Regex webhook_rx = new("(?:https?:\\/\\/)?discord(?:app)?.com\\/api\\/(?:v\\d\\/)?webhooks\\/(?\\d+)\\/(?[A-Za-z0-9_\\-]+)", RegexOptions.ECMAScript); readonly public static Regex id_rx = new("[0-9]{17,}"); readonly public static Regex image_url_rx = new(@"https:\/\/(?:(?:i\.)?imgur\.com\/(?:a\/)?[A-Za-z0-9]+(?:\.[A-Za-z]{3,4})?|i\.ibb\.co\/[A-Za-z0-9]+\/[A-Za-z0-9]+\.[A-Za-z]{3,4}|(?:cdn\.discordapp\.com|media\.discordapp\.net)\/attachments\/[0-9]{17,}\/[0-9]{17,}\/[A-Za-z0-9]+\.[A-Za-z]{3,4}\?ex=[A-Za-z0-9]+&is=[A-Za-z0-9]+&hm=[A-Za-z0-9]+&?(?:=&format=[A-Za-z0-9]+&width=[0-9]+&height=[0-9]+)?)"); + readonly public static Regex animoji_rx = new Regex(""); } } diff --git a/Events/MessageEvent.cs b/Events/MessageEvent.cs index aee08550..2d620d34 100644 --- a/Events/MessageEvent.cs +++ b/Events/MessageEvent.cs @@ -329,6 +329,8 @@ public static async Task MessageHandlerAsync(DiscordClient client, MockDiscordMe if (await RunPhishingApiFilterAsync(client, message, channel, member, permLevel, msgContentWithEmbedData, isAnEdit, limitFilters, wasAutoModBlock)) return; + if (await RunSeizureInducingEmojiFilterAsync(client, message, channel, member, permLevel, msgContentWithEmbedData, isAnEdit, limitFilters, wasAutoModBlock)) return; + if (await RunEveryoneHerePingFilterAsync(client, message, channel, member, permLevel, msgContentWithEmbedData, isAnEdit, limitFilters, wasAutoModBlock)) return; if (await RunMassMentionsWarnFilterAsync(client, message, channel, member, permLevel, msgContentWithEmbedData, isAnEdit, limitFilters, wasAutoModBlock)) return; @@ -1210,6 +1212,39 @@ private static async Task RunPhishingApiFilterAsync(DiscordClient client, return false; } + private static async Task RunSeizureInducingEmojiFilterAsync(DiscordClient client, MockDiscordMessage message, DiscordChannel channel, DiscordMember member, ServerPermLevel permLevel, string msgContentWithEmbedData, bool isAnEdit, bool limitFilters, bool wasAutoModBlock) + { + if (Program.cfgjson.SeizureDetection is null) + return false; + + var emojiMatches = animoji_rx.Matches(message.Content); + if (emojiMatches.Count > 0) + { + foreach (Match emoji in emojiMatches) + { + string id = id_rx.Match(emoji.Value).Value; + string url = "https://cdn.discordapp.com/emojis/" + id + ".gif"; + + if ((await SeizureDetectionHelpers.GetGifPropertiesAsync(url)).IsSeizureInducing) + { + if (wasAutoModBlock) + { + Program.discord.Logger.LogDebug("AutoMod-blocked message in {channelId} by user {userId} triggered seizure-inducing emoji filter", channel.Id, message.Author.Id); + } + else + { + Program.discord.Logger.LogDebug("Message {messageId} in {channelId} by user {userId} triggered seizure-inducing emoji filter", message.Id, channel.Id, message.Author.Id); + } + + await DeleteAndWarnAsync(message, "Sent a seizure-inducing emoji", client, wasAutoModBlock: wasAutoModBlock); + return true; + } + } + } + + return false; + } + private static async Task RunEveryoneHerePingFilterAsync(DiscordClient client, MockDiscordMessage message, DiscordChannel channel, DiscordMember member, ServerPermLevel permLevel, string messageContentOverride = default, bool isAnEdit = false, bool limitFilters = false, bool wasAutoModBlock = false) { var msgContent = message.Content; diff --git a/GlobalUsings.cs b/GlobalUsings.cs index b03ee8f1..0f39030a 100644 --- a/GlobalUsings.cs +++ b/GlobalUsings.cs @@ -25,6 +25,7 @@ global using Newtonsoft.Json.Linq; global using Serilog; global using Serilog.Sinks.SystemConsole.Themes; +global using SkiaSharp; global using StackExchange.Redis; global using System; global using System.Collections.Generic; diff --git a/Helpers/SeizureDetectionHelpers.cs b/Helpers/SeizureDetectionHelpers.cs new file mode 100644 index 00000000..e5e12023 --- /dev/null +++ b/Helpers/SeizureDetectionHelpers.cs @@ -0,0 +1,310 @@ +namespace Cliptok.Helpers +{ + public class SeizureDetectionHelpers + { + static int comparisonAccuracy = Program.cfgjson.SeizureDetection.ComparisonAccuracy; // Only compares every [value] pixels + static int maxDifferingPixels = Program.cfgjson.SeizureDetection.MaxDifferingPixels; // Ignore [value] differing pixels per cycle + static int maxComparableFrames = Program.cfgjson.SeizureDetection.MaxComparableFrames; // If GIF has more than [value] frames, don't compare it frame-by-frame + static int harmlessAverageFrameDifference = Program.cfgjson.SeizureDetection.HarmlessAverageFrameDifference; // Ignore GIFs where the average difference between all their frames is less than [value]% + static int safeAverageFrameDifference = Program.cfgjson.SeizureDetection.SafeAverageFrameDifference; // Treat GIFs less harshly if the average difference between all their frames is less than [value]% + static int penaltyAverageFrameDifference = Program.cfgjson.SeizureDetection.PenaltyAverageFrameDifference; // Apply a penalty for GIFs where the average difference between all their frames is greater than [value]% + static int maxAverageFrameDifference = Program.cfgjson.SeizureDetection.MaxAverageFrameDifference; // Flag GIFs where the average difference between all their frames is greater than [value]% + static List unsafeGifValues = Program.cfgjson.SeizureDetection.UnsafeGifValues; // If GIF has only [key] frames, then the average length of each frame has to be at least [value] ms long + + public static async Task GetGifPropertiesAsync(string input) + { + ImageInfo Gif = await GetImageInfoAsync(input); + + // The actual check for whether or not the GIF might trigger a seizure + try + { + if (Gif.AverageFrameDifference > maxAverageFrameDifference && Gif.AverageContrast < 2) + { + Gif.IsSeizureInducing = false; + } + else + { + if (Gif.AverageFrameDifference < harmlessAverageFrameDifference) // Checks to see if its too low to possibly be harmful + { + Gif.IsSeizureInducing = false; + } + else if (Gif.AverageFrameDifference > maxAverageFrameDifference && Gif.FrameCount <= (2 * unsafeGifValues.Count)) // Checks to see if its high enough to be potentially risky + { + Gif.IsSeizureInducing = true; + } + else + { + if (safeAverageFrameDifference > Gif.AverageFrameDifference && unsafeGifValues[Gif.UniqueFrameCount] > (Gif.Duration + (Gif.Duration / 2))) + { + Gif.IsSeizureInducing = false; + } + else if (Gif.UniqueFrameCount <= unsafeGifValues.Count) + { + if (unsafeGifValues[Gif.UniqueFrameCount] > Gif.Duration) + { + Gif.IsSeizureInducing = true; + } + else if (penaltyAverageFrameDifference < Gif.AverageFrameDifference && unsafeGifValues[Gif.UniqueFrameCount] > (double)Gif.Duration * 1.5) + { + Gif.IsSeizureInducing = true; + } + } + else + { + Gif.IsSeizureInducing = false; + } + } + } + + } + catch (ArgumentOutOfRangeException) + { + Gif.IsSeizureInducing = false; + } + + Program.discord.Logger.LogDebug("GIF: {gif}\n" + + "Frame count: {frameCount}\n" + + "Unique frame count: {uniqueFrameCount}\n" + + "Average frame difference: {averageFrameDifference}\n" + + "Average frame contrast: {averageContrast}\n" + + "Length: {length}ms\n" + + "Frame duration: {duration}ms\n" + + "Framerate: {frameRate}fps\n" + + "Seizure-inducing: {isSeizureInducing}.\n", + input, Gif.FrameCount, Gif.UniqueFrameCount, Math.Round(Gif.AverageFrameDifference, 2), + Math.Round(Gif.AverageContrast, 2), Gif.Length, Math.Round(Gif.Duration, 2), Math.Round(Gif.FrameRate, 2), Gif.IsSeizureInducing); + + return Gif; + } + + public static async Task GetImageInfoAsync(string url) + { + ImageInfo info = new ImageInfo(); + + using (SKCodec image = SKCodec.Create(new MemoryStream(await Program.httpClient.GetByteArrayAsync(url)))) + { + info.Height = image.Info.Height; + info.Width = image.Info.Width; + + if (image.EncodedFormat == SKEncodedImageFormat.Gif) + { + if (image.FrameCount > 1) + { + int frameCount = image.FrameCount; + decimal delay = 0; + decimal this_delay = 0; + decimal averageFrameDifference = 0; + decimal averageContrast = 0; + List ExtractedFrames = new List(); + List> isUniqueList = new List>(frameCount); + for (int i = 0; i < frameCount; i++) + { + isUniqueList.Add(new List()); // -1 means the image hasn't yet been checked at all + } + + int uniqueFrameCount = 0; + + if (frameCount < maxComparableFrames) + { + for (int e = 0; e < frameCount; e++) + { + SKBitmap bitmap = new SKBitmap(image.Info);; + image.GetPixels(image.Info, bitmap.GetPixels(), new SKCodecOptions(e)); + ExtractedFrames.Add(bitmap); + } + } + + SKCodecFrameInfo[] frameInfo = image.FrameInfo; + + //\\ ================ Beginning of compare loop ================ //\\ + for (int f = 0; f < frameCount; f++) + { + this_delay = frameInfo[f].Duration; + delay += this_delay; + + if (frameCount < maxComparableFrames) + { + int p = 0; + foreach (SKBitmap bmp1 in ExtractedFrames) + { + bool compareFullFrame = false; + if (p == f + 1) + { + compareFullFrame = true; + } + if (p != f && isUniqueList[f].Count == 0) + { + var comparisonData = Compare(bmp1, ExtractedFrames[f], compareFullFrame); + if (comparisonData.Item1) + { + isUniqueList[p].Add(f); + + } + if (comparisonData.Item2 != -1) + { + averageFrameDifference += comparisonData.Item2; + averageContrast += comparisonData.Item3; + } + } + else if (compareFullFrame == true && p != f) + { + var comparsionData = Compare(bmp1, ExtractedFrames[f], compareFullFrame); + if (comparsionData.Item2 != -1) + { + averageFrameDifference += comparsionData.Item2; + averageContrast += comparsionData.Item3; + } + } + p++; + } + } + } + //\\ =================== End of compare loop =================== //\\ + + // Get number of unique frames + foreach (List b in isUniqueList) + { + if (b.Count == 0) + { + uniqueFrameCount++; + } + } + // If it's zero then every frame is unique uwu + if (uniqueFrameCount == 0) + { + uniqueFrameCount = frameCount; + } + // Dispose of all those nasty bitmaps + foreach (SKBitmap bmp in ExtractedFrames) + { + bmp.Dispose(); + } + + + // Adds info to struct + try + { + info.Duration = delay / frameCount; + info.FrameCount = frameCount; + info.UniqueFrameCount = uniqueFrameCount; + info.AverageFrameDifference = averageFrameDifference / (frameCount - 1); + info.AverageContrast = averageContrast / (frameCount - 1); + info.IsAnimated = true; + info.IsLooped = image.RepetitionCount != 0; + info.Length = delay; + info.FrameRate = frameCount / (info.Length / 1000); + } + // If anything is accidentally zero because GIFs suck then just assume its safe + // Mods can deal with the chaos that ensues + catch (DivideByZeroException) + { + info.Duration = 1000; + info.FrameCount = frameCount; + info.UniqueFrameCount = uniqueFrameCount; + info.AverageFrameDifference = 0; + info.AverageContrast = 0; + info.IsAnimated = true; + info.IsLooped = image.RepetitionCount != 0; + info.Length = 1000 * frameCount; + info.FrameRate = -1; + } + } + } + } + return info; // Finally! We're free of this mess! + } + + // The almighty comparison mechanism. Takes in two images and checks to see if they're the same. Optionally it will compare the whole image. + // Returns a bool stating whether or not the images are identical, and a decimal that contains a percentage of how similar the two images are (if compareFullFrame is true) + public static (bool, decimal, decimal) Compare(SKBitmap bmp1, SKBitmap bmp2, bool compareFullFrame) + { + int pixelCount = bmp1.Width * bmp1.Height; // Total pixels in the frame + int differingPixels = 0; // Keeps track of how many pixels differ between the two images + decimal pixelDifference = 0; // Keeps a percentage of how many differing pixels there are + decimal pixelContrast = 0; + + for (int x = 0; x < bmp1.Width; x += comparisonAccuracy) + { + for (int y = 0; y < bmp1.Height; y += comparisonAccuracy) + { + if (bmp1.GetPixel(x, y) != bmp2.GetPixel(x, y)) + { + differingPixels++; + if (differingPixels > maxDifferingPixels) + { + if (!compareFullFrame) + { + return (false, pixelDifference, pixelContrast); + } + } + } + } + } + if (compareFullFrame) + { + pixelDifference = (Convert.ToDecimal(differingPixels) / Convert.ToDecimal(pixelCount)) * Convert.ToDecimal(100) * (comparisonAccuracy * comparisonAccuracy); + pixelContrast = (decimal)GetContrast(GetAverageColour(bmp1), GetAverageColour(bmp2)); + } + if (differingPixels > maxDifferingPixels && compareFullFrame) + { + return (false, pixelDifference, pixelContrast); + } + else + { + return (true, -1, -1); + } + } + + // Gets the average colour of an image + private static SKColor GetAverageColour(SKBitmap bmp) + { + SKBitmap bmp1px = new SKBitmap(1, 1); + using (SKCanvas c = new SKCanvas(bmp1px)) + { + c.DrawBitmap(bmp, new SKRect(0, 0, 1, 1), new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear)); + } + return bmp1px.GetPixel(0, 0); + } + + private static double GetLuminance(SKColor c) + { + byte[] colourArray = { c.Red, c.Green, c.Blue }; + double[] luminanceArray = new double[3]; + for (int i = 0; i < 3; i++) + { + luminanceArray[i] = colourArray[i] / 255.0; + luminanceArray[i] = luminanceArray[i] <= 0.03928 + ? luminanceArray[i] / 12.92 + : Math.Pow((luminanceArray[i] + 0.055) / 1.055, 2.4); + } + return luminanceArray[0] * 0.2126 + luminanceArray[1] * 0.7152 + luminanceArray[2] * 0.0722; + } + + private static double GetContrast(SKColor c1, SKColor c2) + { + var lum1 = GetLuminance(c1); + var lum2 = GetLuminance(c2); + var brightest = Math.Max(lum1, lum2); + var darkest = Math.Min(lum1, lum2); + return (brightest + 0.05) + / (darkest + 0.05); + } + + // Definition for a loaded GIF + public struct ImageInfo + { + public bool IsSeizureInducing; // Whether or not the GIF likely to be painful + public int Width; // Width of GIF + public int Height; // Height of GIF + public int FrameCount; // Number of frames in the GIF + public int UniqueFrameCount; // Number of unique frames in the GIF + public decimal AverageFrameDifference; // Average difference between all frames + public decimal AverageContrast; // Average contrast between all frames + public bool IsAnimated; // Whether or not the GIF is animated + public bool IsLooped; // Whether or not the GIF loops + public decimal Duration; // Average duration of one frame in milliseconds + public decimal Length; // Length of entire GIF in milliseconds + public decimal FrameRate; // Average framerate of entire GIF in FPS + } + } +} diff --git a/Types/Config.cs b/Types/Config.cs index 74221837..e05f7bc1 100644 --- a/Types/Config.cs +++ b/Types/Config.cs @@ -315,6 +315,9 @@ public ulong InsidersChannel [JsonProperty("duplicateMessageExcludedChannels")] public List DuplicateMessageExcludedChannels {get; set; } = new(); + + [JsonProperty("seizureDetection")] + public SeizureDetectionConfig SeizureDetection { get; private set; } } public class AutoModRuleConfig @@ -495,4 +498,30 @@ public class ReactionEmojiConfig public ulong Error { get; private set; } = 0; } + public class SeizureDetectionConfig + { + [JsonProperty("comparisonAccuracy")] + public int ComparisonAccuracy { get; private set; } + + [JsonProperty("maxDifferingPixels")] + public int MaxDifferingPixels { get; private set; } + + [JsonProperty("maxComparableFrames")] + public int MaxComparableFrames { get; private set; } + + [JsonProperty("harmlessAverageFrameDifference")] + public int HarmlessAverageFrameDifference { get; private set; } + + [JsonProperty("safeAverageFrameDifference")] + public int SafeAverageFrameDifference { get; private set; } + + [JsonProperty("penaltyAverageFrameDifference")] + public int PenaltyAverageFrameDifference { get; private set; } + + [JsonProperty("maxAverageFrameDifference")] + public int MaxAverageFrameDifference { get; private set; } + + [JsonProperty("unsafeGifValues")] + public List UnsafeGifValues { get; private set; } + } } diff --git a/config.json b/config.json index 85e91964..5d710765 100644 --- a/config.json +++ b/config.json @@ -353,5 +353,27 @@ "modActionReplyAutoWarnReason": "rule 12", "duplicateMessageExcludedChannels": [ 929902761519239208 - ] + ], + "seizureDetection": { + "comparisonAccuracy": 10, + "maxDifferingPixels": 2, + "maxComparableFrames": 300, + "harmlessAverageFrameDifference": 5, + "safeAverageFrameDifference": 20, + "penaltyAverageFrameDifference": 50, + "maxAverageFrameDifference": 95, + "unsafeGifValues": [ + 0, + 0, + 60, + 40, + 30, + 30, + 30, + 25, + 23, + 20, + 18 + ] + } }