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
+ ]
+ }
}