diff --git a/Clients/CompatApiClient/CompatApiClient.csproj b/Clients/CompatApiClient/CompatApiClient.csproj index a5405ea7..594e085a 100644 --- a/Clients/CompatApiClient/CompatApiClient.csproj +++ b/Clients/CompatApiClient/CompatApiClient.csproj @@ -15,7 +15,7 @@ - + diff --git a/CompatBot/Commands/Antipiracy.cs b/CompatBot/Commands/Antipiracy.cs index 9d6f0885..1292a09b 100644 --- a/CompatBot/Commands/Antipiracy.cs +++ b/CompatBot/Commands/Antipiracy.cs @@ -1,76 +1,684 @@ -using System.Collections.Generic; +using System; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using CompatApiClient.Utils; using CompatBot.Commands.Attributes; using CompatBot.Database; -using CompatBot.Database.Providers; using CompatBot.Utils; +using CompatBot.Utils.Extensions; +using DiscUtils.Streams; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using DSharpPlus.Interactivity; using Microsoft.EntityFrameworkCore; +using Exception = System.Exception; namespace CompatBot.Commands { - [Group("piracy"), RequiresBotSudoerRole, RequiresDm] - [Description("Used to manage piracy filters **in DM**")] + [Group("filters"), Aliases("piracy", "filter"), RequiresBotSudoerRole, RequiresDm] + [Description("Used to manage content filters. **Works only in DM**")] internal sealed class Antipiracy: BaseCommandModuleCustom { + private static readonly TimeSpan InteractTimeout = TimeSpan.FromMinutes(5); + private static readonly char[] Separators = {' ', ',', ';', '|'}; + [Command("list"), Aliases("show")] [Description("Lists all filters")] public async Task List(CommandContext ctx) { var table = new AsciiTable( new AsciiColumn("ID", alignToRight: true), - new AsciiColumn("Trigger") + new AsciiColumn("Trigger"), + new AsciiColumn("Validation"), + new AsciiColumn("Context"), + new AsciiColumn("Actions"), + new AsciiColumn("Custom message") ); using (var db = new BotDb()) - foreach (var item in await db.Piracystring.ToListAsync().ConfigureAwait(false)) - table.Add(item.Id.ToString(), item.String); + foreach (var item in await db.Piracystring.Where(ps => !ps.Disabled).OrderBy(ps => ps.String).ToListAsync().ConfigureAwait(false)) + { + table.Add( + item.Id.ToString(), + item.String.Sanitize(), + item.ValidatingRegex, + item.Context.ToString(), + item.Actions.ToFlagsString(), + string.IsNullOrEmpty(item.CustomMessage) ? "" : "✅" + ); + } await ctx.SendAutosplitMessageAsync(table.ToString()).ConfigureAwait(false); + await ctx.RespondAsync(FilterActionExtensions.GetLegend()).ConfigureAwait(false); } - [Command("add")] - [Description("Adds a new piracy filter trigger")] + [Command("add"), Aliases("create")] + [Description("Adds a new content filter")] public async Task Add(CommandContext ctx, [RemainingText, Description("A plain string to match")] string trigger) { - var wasSuccessful = await PiracyStringProvider.AddAsync(trigger).ConfigureAwait(false); - if (wasSuccessful) + using (var db = new BotDb()) { - await ctx.ReactWithAsync(Config.Reactions.Success, "New trigger successfully saved!").ConfigureAwait(false); - var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - await ctx.Client.ReportAsync("🤬 Piracy filter added", $"{member.GetMentionWithNickname()} added a new piracy filter:\n```{trigger.Sanitize()}```", null, ReportSeverity.Low).ConfigureAwait(false); + Piracystring filter; + if (string.IsNullOrEmpty(trigger)) + filter = new Piracystring(); + else + { + filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String == trigger).ConfigureAwait(false); + if (filter == null) + filter = new Piracystring(); + else + filter.Disabled = false; + } + var isNewFilter = filter.Id == default; + if (isNewFilter) + { + filter.Context = FilterContext.Chat | FilterContext.Log; + filter.Actions = FilterAction.RemoveMessage | FilterAction.IssueWarning | FilterAction.SendMessage; + } + + var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); + if (success) + { + if (isNewFilter) + await db.Piracystring.AddAsync(filter).ConfigureAwait(false); + await db.SaveChangesAsync().ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Created a new content filter")).ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + await ctx.Client.ReportAsync("🆕 Content filter created", $"{member.GetMentionWithNickname()} added a new content filter: `{filter.String.Sanitize()}`", null, ReportSeverity.Low).ConfigureAwait(false); + } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter creation aborted").ConfigureAwait(false); + } + } + + [Command("edit"), Aliases("fix", "update", "change")] + [Description("Modifies the specified content filter")] + public async Task Edit(CommandContext ctx, [Description("Filter ID")] int id) + { + using (var db = new BotDb()) + { + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id && !ps.Disabled).ConfigureAwait(false); + if (filter == null) + { + await ctx.RespondAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); + } + } + + [Command("edit")] + public async Task Edit(CommandContext ctx, [Description("Trigger to edit"), RemainingText] string trigger) + { + using (var db = new BotDb()) + { + var filter = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String.Equals(trigger, StringComparison.InvariantCultureIgnoreCase) && !ps.Disabled).ConfigureAwait(false); + if (filter == null) + { + await ctx.RespondAsync("Specified filter does not exist").ConfigureAwait(false); + return; + } + + await EditFilterCmd(ctx, db, filter).ConfigureAwait(false); } - else - await ctx.ReactWithAsync(Config.Reactions.Failure, "Trigger already defined.").ConfigureAwait(false); - if (wasSuccessful) - await List(ctx).ConfigureAwait(false); } [Command("remove"), Aliases("delete", "del")] [Description("Removes a piracy filter trigger")] public async Task Remove(CommandContext ctx, [Description("Filter IDs to remove, separated with spaces")] params int[] ids) { - var failedIds = new List(); - var removedFilters = new List(); - foreach (var id in ids) + int removedFilters; + var removedTriggers = new StringBuilder(); + using (var db = new BotDb()) { - var trigger = await PiracyStringProvider.GetTriggerAsync(id).ConfigureAwait(false); - if (await PiracyStringProvider.RemoveAsync(id).ConfigureAwait(false)) - removedFilters.Add(trigger.Sanitize()); - else - failedIds.Add(id); + foreach (var f in db.Piracystring.Where(ps => ids.Contains(ps.Id))) + { + f.Disabled = true; + removedTriggers.Append($"\n`{f.String.Sanitize()}`"); + } + removedFilters = await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); } - if (failedIds.Count > 0) - await ctx.RespondAsync("Some ids couldn't be removed: " + string.Join(", ", failedIds)).ConfigureAwait(false); + if (removedFilters < ids.Length) + await ctx.RespondAsync("Some ids couldn't be removed.").ConfigureAwait(false); else { await ctx.ReactWithAsync(Config.Reactions.Success, $"Trigger{StringUtils.GetSuffix(ids.Length)} successfully removed!").ConfigureAwait(false); var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); - var s = removedFilters.Count == 1 ? "" : "s"; - await ctx.Client.ReportAsync($"🤬 Piracy filter{s} removed", $"{member.GetMentionWithNickname()} removed {removedFilters.Count} piracy filter{s}:\n```\n{string.Join('\n', removedFilters)}\n```", null, ReportSeverity.Medium).ConfigureAwait(false); + var s = removedFilters == 1 ? "" : "s"; + var filterList = removedTriggers.ToString(); + if (removedFilters == 1) + filterList = filterList.TrimStart(); + await ctx.Client.ReportAsync($"📴 Piracy filter{s} removed", $"{member.GetMentionWithNickname()} removed {removedFilters} piracy filter{s}: {filterList}".Trim(EmbedPager.MaxDescriptionLength), null, ReportSeverity.Medium).ConfigureAwait(false); } - await List(ctx).ConfigureAwait(false); + } + + [Command("remove")] + public async Task Remove(CommandContext ctx, [Description("Trigger to remove"), RemainingText] string trigger) + { + if (string.IsNullOrWhiteSpace(trigger)) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "No trigger was specified").ConfigureAwait(false); + return; + } + + using (var db = new BotDb()) + { + var f = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String.Equals(trigger, StringComparison.InvariantCultureIgnoreCase) && !ps.Disabled).ConfigureAwait(false); + if (f == null) + { + await ctx.ReactWithAsync(Config.Reactions.Failure, "Specified filter does not exist").ConfigureAwait(false); + return; + } + + f.Disabled = true; + await db.SaveChangesAsync(Config.Cts.Token).ConfigureAwait(false); + } + + await ctx.ReactWithAsync(Config.Reactions.Success, "Trigger was removed").ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + await ctx.Client.ReportAsync("📴 Piracy filter removed", $"{member.GetMentionWithNickname()} removed 1 piracy filter: `{trigger.Sanitize()}`", null, ReportSeverity.Medium).ConfigureAwait(false); + } + + private async Task EditFilterCmd(CommandContext ctx, BotDb db, Piracystring filter) + { + var (success, msg) = await EditFilterPropertiesAsync(ctx, db, filter).ConfigureAwait(false); + if (success) + { + await db.SaveChangesAsync().ConfigureAwait(false); + await msg.UpdateOrCreateMessageAsync(ctx.Channel, embed: FormatFilter(filter).WithTitle("Updated content filter")).ConfigureAwait(false); + var member = ctx.Member ?? ctx.Client.GetMember(ctx.User); + await ctx.Client.ReportAsync("🆙 Content filter updated", $"{member.GetMentionWithNickname()} changed content filter: `{filter.String.Sanitize()}`", null, ReportSeverity.Low) + .ConfigureAwait(false); + } + else + await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Content filter update aborted").ConfigureAwait(false); + } + + private async Task<(bool success, DiscordMessage message)> EditFilterPropertiesAsync(CommandContext ctx, BotDb db, Piracystring filter) + { + var interact = ctx.Client.GetInteractivity(); + var abort = DiscordEmoji.FromUnicode("🛑"); + var lastPage = DiscordEmoji.FromUnicode("↪"); + var firstPage = DiscordEmoji.FromUnicode("↩"); + var previousPage = DiscordEmoji.FromUnicode("⏪"); + var nextPage = DiscordEmoji.FromUnicode("⏩"); + var trash = DiscordEmoji.FromUnicode("🗑"); + var saveEdit = DiscordEmoji.FromUnicode("💾"); + + var letterC = DiscordEmoji.FromUnicode("🇨"); + var letterL = DiscordEmoji.FromUnicode("🇱"); + var letterR = DiscordEmoji.FromUnicode("🇷"); + var letterW = DiscordEmoji.FromUnicode("🇼"); + var letterM = DiscordEmoji.FromUnicode("🇲"); + var letterE = DiscordEmoji.FromUnicode("🇪"); + + DiscordMessage msg = null; + string errorMsg = null; + DiscordMessage txt; + MessageReactionAddEventArgs emoji; + + step1: + // step 1: define trigger string + var embed = FormatFilter(filter, errorMsg, 1) + .WithDescription( + "Any simple string that is used to flag potential content for a check using Validation regex.\n" + + "**Must** be sufficiently long to reduce the number of checks." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify a new **trigger**", embed: embed).ConfigureAwait(false); + errorMsg = null; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, lastPage, nextPage, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == lastPage) + { + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + } + else if (txt?.Content != null) + { + var existing = await db.Piracystring.FirstOrDefaultAsync(ps => ps.String.Equals(txt.Content, StringComparison.InvariantCultureIgnoreCase)).ConfigureAwait(false); + if (existing != null) + { + if (existing.Disabled) + db.Piracystring.Remove(existing); + else + { + errorMsg = $"Trigger `{txt.Content.Sanitize()}` already exists"; + goto step1; + } + } + + if (txt.Content.Length < Config.MinimumPiracyTriggerLength) + { + errorMsg = "Trigger is too short"; + goto step1; + } + + filter.String = txt.Content; + } + else + return (false, msg); + + step2: + // step 2: context of the filter where it is applicable + embed = FormatFilter(filter, errorMsg, 2) + .WithDescription( + "Context of the filter indicates where it is applicable.\n" + + $"**`C`** = **`{FilterContext.Chat}`** will apply it in filtering discord messages.\n" + + $"**`L`** = **`{FilterContext.Log}`** will apply it during log parsing.\n" + + "Reactions will toggle the context, text message will set the specified flags." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify filter **context(s)**", embed: embed).ConfigureAwait(false); + errorMsg = null; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, nextPage, letterC, letterL, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + goto step1; + + if (emoji.Emoji == letterC) + { + filter.Context ^= FilterContext.Chat; + goto step2; + } + + if (emoji.Emoji == letterL) + { + filter.Context ^= FilterContext.Log; + goto step2; + } + } + else if (txt != null) + { + var flagsTxt = txt.Content.Split(Separators, StringSplitOptions.RemoveEmptyEntries); + FilterContext newCtx = 0; + foreach (var f in flagsTxt) + { + switch (f.ToUpperInvariant()) + { + case "C": + case "CHAT": + newCtx |= FilterContext.Chat; + break; + case "L": + case "LOG": + case "LOGS": + newCtx |= FilterContext.Log; + break; + case "ABORT": + return (false, msg); + case "-": + case "SKIP": + case "NEXT": + break; + default: + errorMsg = $"Unknown context `{f}`."; + goto step2; + } + } + filter.Context = newCtx; + } + else + return (false, msg); + + step3: + // step 3: actions that should be performed on match + embed = FormatFilter(filter, errorMsg, 3) + .WithDescription( + "Actions that will be executed on positive match.\n" + + $"**`R`** = **`{FilterAction.RemoveMessage}`** will remove the message / log.\n" + + $"**`W`** = **`{FilterAction.IssueWarning}`** will issue a warning to the user.\n" + + $"**`M`** = **`{FilterAction.SendMessage}`** send _a_ message with an explanation of why it was removed.\n" + + $"**`E`** = **`{FilterAction.ShowExplain}`** show `explain` for the specified term (**not implemented**).\n" + + "Reactions will toggle the action, text message will set the specified flags." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify filter **action(s)**", embed: embed).ConfigureAwait(false); + errorMsg = null; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, nextPage, letterR, letterW, letterM, letterE, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + goto step2; + + if (emoji.Emoji == letterR) + { + filter.Actions ^= FilterAction.RemoveMessage; + goto step3; + } + + if (emoji.Emoji == letterW) + { + filter.Actions ^= FilterAction.IssueWarning; + goto step3; + } + + if (emoji.Emoji == letterM) + { + filter.Actions ^= FilterAction.SendMessage; + goto step3; + } + + if (emoji.Emoji == letterE) + { + filter.Actions ^= FilterAction.ShowExplain; + goto step3; + } + } + else if (txt != null) + { + var flagsTxt = txt.Content.ToUpperInvariant().Split(Separators, StringSplitOptions.RemoveEmptyEntries); + if (flagsTxt.Length == 1 + && flagsTxt[0].Length <= Enum.GetValues(typeof(FilterAction)).Length) + flagsTxt = flagsTxt[0].Select(c => c.ToString()).ToArray(); + FilterAction newActions = 0; + foreach (var f in flagsTxt) + { + switch (f) + { + case "R": + case "REMOVE": + case "REMOVEMESSAGE": + newActions |= FilterAction.RemoveMessage; + break; + case "W": + case "WARN": + case "WARNING": + case "ISSUEWARNING": + newActions |= FilterAction.IssueWarning; + break; + case "M": + case "MSG": + case "MESSAGE": + case "SENDMESSAGE": + newActions |= FilterAction.SendMessage; + break; + case "E": + case "X": + case "EXPLAIN": + case "SHOWEXPLAIN": + case "SENDEXPLAIN": + newActions |= FilterAction.ShowExplain; + break; + case "ABORT": + return (false, msg); + case "-": + case "SKIP": + case "NEXT": + break; + default: + errorMsg = $"Unknown action `{f.ToLowerInvariant()}`."; + goto step2; + } + } + filter.Actions = newActions; + } + else + return (false, msg); + + step4: + // step 4: validation regex to filter out false positives of the plaintext triggers + embed = FormatFilter(filter, errorMsg, 4) + .WithDescription( + "Validation [regex](https://docs.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-language-quick-reference) to optionally perform more strict trigger check.\n" + + "**Please [test](https://regex101.com/) your regex**. Following flags are enabled: Multiline, IgnoreCase.\n" + + "Additional validation can help reduce false positives of a plaintext trigger match." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify filter **validation regex**", embed: embed).ConfigureAwait(false); + errorMsg = null; + var next = (filter.Actions & (FilterAction.SendMessage | FilterAction.ShowExplain)) == 0 ? firstPage : nextPage; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, next, trash, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + goto step3; + + if (emoji.Emoji == firstPage) + goto step1; + + if (emoji.Emoji == trash) + filter.ValidatingRegex = null; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-" || txt.Content == ".*") + filter.ValidatingRegex = null; + else + { + try + { + Regex.IsMatch("test", txt.Content, RegexOptions.Multiline | RegexOptions.IgnoreCase); + } + catch (Exception e) + { + errorMsg = "Invalid regex expression: " + e.Message; + goto step4; + } + + filter.ValidatingRegex = txt.Content; + } + } + else + return (false, msg); + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + else if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + else + goto stepConfirm; + + step5: + // step 5: optional custom message for the user + embed = FormatFilter(filter, errorMsg, 5) + .WithDescription( + "Optional custom message sent to the user.\n" + + "If left empty, default piracy warning message will be used." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify filter **validation regex**", embed: embed).ConfigureAwait(false); + errorMsg = null; + next = (filter.Actions.HasFlag(FilterAction.ShowExplain) ? nextPage : firstPage); + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, next, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + goto step4; + + if (emoji.Emoji == firstPage) + goto step1; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") + filter.CustomMessage = null; + else + filter.CustomMessage = txt.Content; + } + else + return (false, msg); + + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + else + goto stepConfirm; + + step6: + // step 6: show explanation for the term + embed = FormatFilter(filter, errorMsg, 6) + .WithDescription( + "Explanation term that is used to show an explanation.\n" + + "**__Currently not implemented__**." + ); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Please specify filter **explanation term**", embed: embed).ConfigureAwait(false); + errorMsg = null; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, firstPage, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + { + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + else + goto step4; + } + + if (emoji.Emoji == firstPage) + goto step1; + } + else if (txt != null) + { + if (string.IsNullOrWhiteSpace(txt.Content) || txt.Content == "-") + filter.ExplainTerm = null; + else + { + var existingTerm = await db.Explanation.FirstOrDefaultAsync(exp => exp.Keyword == txt.Content.ToLowerInvariant()).ConfigureAwait(false); + if (existingTerm == null) + { + errorMsg = $"Term `{txt.Content.ToLowerInvariant().Sanitize()}` is not defined."; + goto step6; + } + + filter.ExplainTerm = txt.Content; + } + } + else + return (false, msg); + + stepConfirm: + // last step: confirm + if (errorMsg == null && !filter.IsComplete()) + errorMsg = "Some required properties are not defined"; + embed = FormatFilter(filter, errorMsg); + msg = await msg.UpdateOrCreateMessageAsync(ctx.Channel, "Does this look good? (y/n)", embed: embed.Build()).ConfigureAwait(false); + errorMsg = null; + (msg, txt, emoji) = await interact.WaitForMessageOrReactionAsync(msg, ctx.User, InteractTimeout, abort, previousPage, firstPage, (filter.IsComplete() ? saveEdit : null)).ConfigureAwait(false); + if (emoji != null) + { + if (emoji.Emoji == abort) + return (false, msg); + + if (emoji.Emoji == saveEdit) + return (true, msg); + + if (emoji.Emoji == previousPage) + { + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + + if (emoji.Emoji == firstPage) + goto step1; + } + else if (!string.IsNullOrEmpty(txt?.Content)) + { + if (!filter.IsComplete()) + goto step5; + + switch (txt.Content.ToLowerInvariant()) + { + case "yes": + case "y": + case "✅": + case "☑": + case "✔": + case "👌": + case "👍": + return (true, msg); + case "no": + case "n": + case "❎": + case "❌": + case "👎": + return (false, msg); + default: + errorMsg = "I don't know what you mean, so I'll just abort"; + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + goto step6; + + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + goto step5; + + goto step4; + } + } + else + { + return (false, msg); + } + return (false, msg); + } + + private static DiscordEmbedBuilder FormatFilter(Piracystring filter, string error = null, int highlight = -1) + { + var field = 1; + var result = new DiscordEmbedBuilder + { + Title = "Filter preview", + Color = string.IsNullOrEmpty(error) ? Config.Colors.Help : Config.Colors.Maintenance, + }; + if (!string.IsNullOrEmpty(error)) + result.AddField("Entry error", error); + + result.AddFieldEx((string.IsNullOrEmpty(filter.String) ? "⚠ " : "") + "Trigger", filter.String, highlight == field++, true) + .AddFieldEx("Context", filter.Context.ToString(), highlight == field++, true) + .AddFieldEx("Actions", filter.Actions.ToFlagsString(), highlight == field++, true) + .AddFieldEx("Validation", filter.ValidatingRegex, highlight == field++, true); + if (filter.Actions.HasFlag(FilterAction.SendMessage)) + result.AddFieldEx("Message", filter.CustomMessage, highlight == field, true); + field++; + if (filter.Actions.HasFlag(FilterAction.ShowExplain)) + result.AddFieldEx((string.IsNullOrEmpty(filter.ExplainTerm) ? "⚠ " : "") + "Explain", filter.ExplainTerm, highlight == field, true); + return result; } } } diff --git a/CompatBot/Commands/Attributes/RequiresDm.cs b/CompatBot/Commands/Attributes/RequiresDm.cs index 76270887..8680df3c 100644 --- a/CompatBot/Commands/Attributes/RequiresDm.cs +++ b/CompatBot/Commands/Attributes/RequiresDm.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Net.Http; using System.Threading.Tasks; using DSharpPlus.CommandsNext; using DSharpPlus.CommandsNext.Attributes; @@ -8,12 +10,20 @@ namespace CompatBot.Commands.Attributes [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false)] internal class RequiresDm: CheckBaseAttribute { + private const string Source = "https://cdn.discordapp.com/attachments/417347469521715210/534798232858001418/24qx11.jpg"; + private static readonly Lazy Poster = new Lazy(() => + { + using (var client = HttpClientFactory.Create()) + return client.GetByteArrayAsync(Source).ConfigureAwait(true).GetAwaiter().GetResult(); + }); + public override async Task ExecuteCheckAsync(CommandContext ctx, bool help) { if (ctx.Channel.IsPrivate || help) return true; - await ctx.RespondAsync($"{ctx.Message.Author.Mention} https://cdn.discordapp.com/attachments/417347469521715210/534798232858001418/24qx11.jpg").ConfigureAwait(false); + using (var stream = new MemoryStream(Poster.Value)) + await ctx.RespondWithFileAsync("senpai_plz.jpg", stream).ConfigureAwait(false); return false; } } diff --git a/CompatBot/Commands/CompatList.cs b/CompatBot/Commands/CompatList.cs index 02e8aa62..0ee7583b 100644 --- a/CompatBot/Commands/CompatList.cs +++ b/CompatBot/Commands/CompatList.cs @@ -60,7 +60,7 @@ namespace CompatBot.Commands if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) return; - if (!await AntipiracyMonitor.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) + if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) return; var productCodes = ProductCodeLookup.GetProductIds(ctx.Message.Content); @@ -89,7 +89,7 @@ Example usage: !top 10 playable !top 10 new ingame !top 10 old loadable bluray")] - public async Task Top(CommandContext ctx, [Description("To see all filters do !filters")] params string[] filters) + public async Task Top(CommandContext ctx, [Description("You can use game status or media (psn/blu-ray)")] params string[] filters) { var requestBuilder = RequestBuilder.Start(); var age = "new"; @@ -126,18 +126,6 @@ Example usage: await DoRequestAndRespond(ctx, requestBuilder).ConfigureAwait(false); } - [Command("filters"), Hidden] - [Description("Provides information about available filters for the !top command")] - public async Task Filters(CommandContext ctx) - { - var embed = new DiscordEmbedBuilder {Description = "List of recognized tokens in each filter category", Color = Config.Colors.Help} - .AddField("Statuses", DicToDesc(ApiConfig.Statuses)) - .AddField("Release types", DicToDesc(ApiConfig.ReleaseTypes)) - .Build(); - var ch = await ctx.GetChannelForSpamAsync().ConfigureAwait(false); - await ch.SendMessageAsync(embed: embed).ConfigureAwait(false); - } - [Group("latest"), TriggersTyping] [Description("Provides links to the latest RPCS3 build")] [Cooldown(1, 30, CooldownBucketType.Channel)] diff --git a/CompatBot/Commands/Explain.cs b/CompatBot/Commands/Explain.cs index 2380b0a4..089cd907 100644 --- a/CompatBot/Commands/Explain.cs +++ b/CompatBot/Commands/Explain.cs @@ -62,7 +62,7 @@ namespace CompatBot.Commands if (!await DiscordInviteFilter.CheckMessageForInvitesAsync(ctx.Client, ctx.Message).ConfigureAwait(false)) return; - if (!await AntipiracyMonitor.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) + if (!await ContentFilter.IsClean(ctx.Client, ctx.Message).ConfigureAwait(false)) return; term = term.ToLowerInvariant(); diff --git a/CompatBot/CompatBot.csproj b/CompatBot/CompatBot.csproj index eb6af075..067cf2a6 100644 --- a/CompatBot/CompatBot.csproj +++ b/CompatBot/CompatBot.csproj @@ -22,9 +22,9 @@ - - - + + + @@ -37,9 +37,10 @@ - + - + + diff --git a/CompatBot/Config.cs b/CompatBot/Config.cs index 06c23ee6..22d49f06 100644 --- a/CompatBot/Config.cs +++ b/CompatBot/Config.cs @@ -8,10 +8,14 @@ using System.Threading; using DSharpPlus.Entities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; +using Microsoft.Extensions.Logging; using NLog; +using NLog.Extensions.Logging; using NLog.Filters; using NLog.Targets; using NLog.Targets.Wrappers; +using ILogger = NLog.ILogger; +using LogLevel = NLog.LogLevel; namespace CompatBot { @@ -19,6 +23,7 @@ namespace CompatBot { private static readonly IConfigurationRoot config; internal static readonly ILogger Log; + internal static readonly ILoggerFactory LoggerFactory; internal static readonly ConcurrentDictionary inMemorySettings = new ConcurrentDictionary(); public static readonly CancellationTokenSource Cts = new CancellationTokenSource(); @@ -46,6 +51,8 @@ namespace CompatBot public static int LogSizeLimit => config.GetValue(nameof(LogSizeLimit), 64 * 1024 * 1024); public static int MinimumBufferSize => config.GetValue(nameof(MinimumBufferSize), 512); public static int BuildNumberDifferenceForOutdatedBuilds => config.GetValue(nameof(BuildNumberDifferenceForOutdatedBuilds), 10); + public static int MinimumPiracyTriggerLength => config.GetValue(nameof(MinimumPiracyTriggerLength), 4); + public static string Token => config.GetValue(nameof(Token), ""); public static string LogPath => config.GetValue(nameof(LogPath), "logs/bot.log"); // paths are relative to the assembly, so this will put it in the project's root public static string IrdCachePath => config.GetValue(nameof(IrdCachePath), "./ird/"); @@ -147,6 +154,7 @@ namespace CompatBot .AddInMemoryCollection(inMemorySettings) // higher priority .Build(); Log = GetLog(); + LoggerFactory = new NLogLoggerFactory(); } catch (Exception e) { diff --git a/CompatBot/Database/BotDb.cs b/CompatBot/Database/BotDb.cs index ec84f773..9e39c4ed 100644 --- a/CompatBot/Database/BotDb.cs +++ b/CompatBot/Database/BotDb.cs @@ -21,6 +21,9 @@ namespace CompatBot.Database protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var dbPath = DbImporter.GetDbPath("bot.db", Environment.SpecialFolder.ApplicationData); +#if DEBUG + optionsBuilder.UseLoggerFactory(Config.LoggerFactory); +#endif optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); } @@ -30,6 +33,8 @@ namespace CompatBot.Database modelBuilder.Entity().HasIndex(m => m.Key).IsUnique().HasName("bot_state_key"); modelBuilder.Entity().HasIndex(m => m.DiscordId).IsUnique().HasName("moderator_discord_id"); modelBuilder.Entity().HasIndex(ps => ps.String).IsUnique().HasName("piracystring_string"); + modelBuilder.Entity().Property(ps => ps.Context).HasDefaultValue(FilterContext.Chat | FilterContext.Log); + modelBuilder.Entity().Property(ps => ps.Actions).HasDefaultValue(FilterAction.RemoveMessage | FilterAction.IssueWarning | FilterAction.SendMessage); modelBuilder.Entity().HasIndex(w => w.DiscordId).HasName("warning_discord_id"); modelBuilder.Entity().HasIndex(e => e.Keyword).IsUnique().HasName("explanation_keyword"); modelBuilder.Entity().HasIndex(c => c.Command).IsUnique().HasName("disabled_command_command"); @@ -64,6 +69,28 @@ namespace CompatBot.Database public int Id { get; set; } [Required, Column(TypeName = "varchar(255)")] public string String { get; set; } + public string ValidatingRegex { get; set; } + public FilterContext Context { get; set; } + public FilterAction Actions { get; set; } + public string ExplainTerm { get; set; } + public string CustomMessage { get; set; } + public bool Disabled { get; set; } + } + + [Flags] + internal enum FilterContext: byte + { + Chat = 0b_0000_0001, + Log = 0b_0000_0010, + } + + [Flags] + internal enum FilterAction + { + RemoveMessage = 0b_0000_0001, + IssueWarning = 0b_0000_0010, + ShowExplain = 0b_0000_0100, + SendMessage = 0b_0000_1000, } internal class Warning diff --git a/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.Designer.cs b/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.Designer.cs new file mode 100644 index 00000000..d328f146 --- /dev/null +++ b/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.Designer.cs @@ -0,0 +1,290 @@ +// +using System; +using CompatBot.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CompatBot.Database.Migrations +{ + [DbContext(typeof(BotDb))] + [Migration("20190723151803_ContentFilter2.0")] + partial class ContentFilter20 + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("CompatBot.Database.BotState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Key") + .HasColumnName("key"); + + b.Property("Value") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Key") + .IsUnique() + .HasName("bot_state_key"); + + b.ToTable("bot_state"); + }); + + modelBuilder.Entity("CompatBot.Database.DisabledCommand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Command") + .IsRequired() + .HasColumnName("command"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Command") + .IsUnique() + .HasName("disabled_command_command"); + + b.ToTable("disabled_commands"); + }); + + modelBuilder.Entity("CompatBot.Database.EventSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("End") + .HasColumnName("end"); + + b.Property("EventName") + .HasColumnName("event_name"); + + b.Property("Name") + .HasColumnName("name"); + + b.Property("Start") + .HasColumnName("start"); + + b.Property("Year") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Year", "EventName") + .HasName("event_schedule_year_event_name"); + + b.ToTable("event_schedule"); + }); + + modelBuilder.Entity("CompatBot.Database.Explanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Attachment") + .HasColumnName("attachment") + .HasMaxLength(7340032); + + b.Property("AttachmentFilename") + .HasColumnName("attachment_filename"); + + b.Property("Keyword") + .IsRequired() + .HasColumnName("keyword"); + + b.Property("Text") + .IsRequired() + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Keyword") + .IsUnique() + .HasName("explanation_keyword"); + + b.ToTable("explanation"); + }); + + modelBuilder.Entity("CompatBot.Database.Moderator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("Sudoer") + .HasColumnName("sudoer"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .IsUnique() + .HasName("moderator_discord_id"); + + b.ToTable("moderator"); + }); + + modelBuilder.Entity("CompatBot.Database.Piracystring", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Actions") + .ValueGeneratedOnAdd() + .HasColumnName("actions") + .HasDefaultValue(11); + + b.Property("Context") + .ValueGeneratedOnAdd() + .HasColumnName("context") + .HasDefaultValue((byte)3); + + b.Property("CustomMessage") + .HasColumnName("custom_message"); + + b.Property("Disabled") + .HasColumnName("disabled"); + + b.Property("ExplainTerm") + .HasColumnName("explain_term"); + + b.Property("String") + .IsRequired() + .HasColumnName("string") + .HasColumnType("varchar(255)"); + + b.Property("ValidatingRegex") + .HasColumnName("validating_regex"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("String") + .IsUnique() + .HasName("piracystring_string"); + + b.ToTable("piracystring"); + }); + + modelBuilder.Entity("CompatBot.Database.Stats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasColumnName("category"); + + b.Property("ExpirationTimestamp") + .HasColumnName("expiration_timestamp"); + + b.Property("Key") + .IsRequired() + .HasColumnName("key"); + + b.Property("Value") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("Category", "Key") + .IsUnique() + .HasName("stats_category_key"); + + b.ToTable("stats"); + }); + + modelBuilder.Entity("CompatBot.Database.Warning", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("DiscordId") + .HasColumnName("discord_id"); + + b.Property("FullReason") + .IsRequired() + .HasColumnName("full_reason"); + + b.Property("IssuerId") + .HasColumnName("issuer_id"); + + b.Property("Reason") + .IsRequired() + .HasColumnName("reason"); + + b.Property("Retracted") + .HasColumnName("retracted"); + + b.Property("RetractedBy") + .HasColumnName("retracted_by"); + + b.Property("RetractionReason") + .HasColumnName("retraction_reason"); + + b.Property("RetractionTimestamp") + .HasColumnName("retraction_timestamp"); + + b.Property("Timestamp") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("DiscordId") + .HasName("warning_discord_id"); + + b.ToTable("warning"); + }); + + modelBuilder.Entity("CompatBot.Database.WhitelistedInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnName("id"); + + b.Property("GuildId") + .HasColumnName("guild_id"); + + b.Property("InviteCode") + .HasColumnName("invite_code"); + + b.Property("Name") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("id"); + + b.HasIndex("GuildId") + .IsUnique() + .HasName("whitelisted_invite_guild_id"); + + b.ToTable("whitelisted_invites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.cs b/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.cs new file mode 100644 index 00000000..a1cde6e9 --- /dev/null +++ b/CompatBot/Database/Migrations/BotDb/20190723151803_ContentFilter2.0.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CompatBot.Database.Migrations +{ + public partial class ContentFilter20 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "actions", + table: "piracystring", + nullable: false, + defaultValue: 11); + + migrationBuilder.AddColumn( + name: "context", + table: "piracystring", + nullable: false, + defaultValue: (byte)3); + + migrationBuilder.AddColumn( + name: "custom_message", + table: "piracystring", + nullable: true); + + migrationBuilder.AddColumn( + name: "disabled", + table: "piracystring", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "explain_term", + table: "piracystring", + nullable: true); + + migrationBuilder.AddColumn( + name: "validating_regex", + table: "piracystring", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "actions", + table: "piracystring"); + + migrationBuilder.DropColumn( + name: "context", + table: "piracystring"); + + migrationBuilder.DropColumn( + name: "custom_message", + table: "piracystring"); + + migrationBuilder.DropColumn( + name: "disabled", + table: "piracystring"); + + migrationBuilder.DropColumn( + name: "explain_term", + table: "piracystring"); + + migrationBuilder.DropColumn( + name: "validating_regex", + table: "piracystring"); + } + } +} diff --git a/CompatBot/Database/Migrations/BotDb/BotDbModelSnapshot.cs b/CompatBot/Database/Migrations/BotDb/BotDbModelSnapshot.cs index acd6e9b4..d0c3b95f 100644 --- a/CompatBot/Database/Migrations/BotDb/BotDbModelSnapshot.cs +++ b/CompatBot/Database/Migrations/BotDb/BotDbModelSnapshot.cs @@ -14,7 +14,7 @@ namespace CompatBot.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "2.2.2-servicing-10034"); + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); modelBuilder.Entity("CompatBot.Database.BotState", b => { @@ -147,11 +147,33 @@ namespace CompatBot.Database.Migrations .ValueGeneratedOnAdd() .HasColumnName("id"); + b.Property("Actions") + .ValueGeneratedOnAdd() + .HasColumnName("actions") + .HasDefaultValue(11); + + b.Property("Context") + .ValueGeneratedOnAdd() + .HasColumnName("context") + .HasDefaultValue((byte)3); + + b.Property("CustomMessage") + .HasColumnName("custom_message"); + + b.Property("Disabled") + .HasColumnName("disabled"); + + b.Property("ExplainTerm") + .HasColumnName("explain_term"); + b.Property("String") .IsRequired() .HasColumnName("string") .HasColumnType("varchar(255)"); + b.Property("ValidatingRegex") + .HasColumnName("validating_regex"); + b.HasKey("Id") .HasName("id"); diff --git a/CompatBot/Database/Providers/ContentFilter.cs b/CompatBot/Database/Providers/ContentFilter.cs new file mode 100644 index 00000000..45717b92 --- /dev/null +++ b/CompatBot/Database/Providers/ContentFilter.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CompatApiClient.Utils; +using CompatBot.Commands; +using CompatBot.Utils; +using CompatBot.Utils.Extensions; +using DSharpPlus; +using DSharpPlus.Entities; +using Microsoft.EntityFrameworkCore; +using NReco.Text; + +namespace CompatBot.Database.Providers +{ + internal static class ContentFilter + { + private static readonly object SyncObject = new object(); + private static Dictionary> filters = new Dictionary>(); + + static ContentFilter() + { + RebuildMatcher(); + } + + public static Task FindTriggerAsync(FilterContext ctx, string str) + { + if (string.IsNullOrEmpty(str)) + return Task.FromResult((Piracystring)null); + + + if (!filters.TryGetValue(ctx, out var matcher)) + return Task.FromResult((Piracystring)null); + + Piracystring result = null; + matcher?.ParseText(str, h => + { + if (string.IsNullOrEmpty(h.Value.ValidatingRegex) || Regex.IsMatch(str, h.Value.ValidatingRegex, RegexOptions.IgnoreCase | RegexOptions.Multiline)) + { + result = h.Value; + return h.Value.Actions.HasFlag(FilterAction.RemoveMessage); + } + return true; + }); + + return Task.FromResult(result); + } + + private static void RebuildMatcher() + { + var newFilters = new Dictionary>(); + using (var db = new BotDb()) + foreach (FilterContext ctx in Enum.GetValues(typeof(FilterContext))) + { + var f = db.Piracystring.Where(ps => ps.Disabled == false && ps.Context.HasFlag(ctx)).AsNoTracking().ToList(); + newFilters[ctx] = f.Count == 0 ? null : new AhoCorasickDoubleArrayTrie(f.ToDictionary(s => s.String, s => s), true); + } + filters = newFilters; + } + + + public static async Task IsClean(DiscordClient client, DiscordMessage message) + { + if (message.Channel.IsPrivate) + return true; + + if (message.Author.IsBotSafeCheck()) + return true; + +#if !DEBUG + if (message.Author.IsWhitelisted(client, message.Channel.Guild)) + return true; +#endif + + if (string.IsNullOrEmpty(message.Content)) + return true; + + var severity = ReportSeverity.Low; + var completedActions = new List(); + var trigger = await FindTriggerAsync(FilterContext.Chat, message.Content).ConfigureAwait(false); + if (trigger == null) + return true; + + if (trigger.Actions.HasFlag(FilterAction.RemoveMessage)) + { + try + { + await message.Channel.DeleteMessageAsync(message, $"Removed according to filter '{trigger}'").ConfigureAwait(false); + completedActions.Add(FilterAction.RemoveMessage); + } + catch + { + severity = ReportSeverity.High; + } + } + + if (trigger.Actions.HasFlag(FilterAction.IssueWarning)) + { + try + { + await Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Mention of piracy", message.Content.Sanitize()).ConfigureAwait(false); + completedActions.Add(FilterAction.IssueWarning); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Couldn't issue warning in #{message.Channel.Name}"); + } + } + + if (trigger.Actions.HasFlag(FilterAction.SendMessage)) + { + try + { + var msgContent = trigger.CustomMessage; + if (string.IsNullOrEmpty(msgContent)) + { + var rules = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); + msgContent = $"{message.Author.Mention} Please follow the {rules.Mention} and do not discuss piracy on this server. Repeated offence may result in a ban."; + } + await message.Channel.SendMessageAsync(msgContent).ConfigureAwait(false); + completedActions.Add(FilterAction.SendMessage); + } + catch (Exception e) + { + Config.Log.Warn(e, $"Failed to send message in #{message.Channel.Name}"); + } + } + + var actionList = ""; + foreach (FilterAction fa in Enum.GetValues(typeof(FilterAction))) + { + if (trigger.Actions.HasFlag(fa)) + actionList += (completedActions.Contains(fa) ? "✅" : "❌") + " " + fa + ' '; + } + + try + { + await client.ReportAsync("🤬 Content filter", message, trigger.String, message.Content, severity, actionList).ConfigureAwait(false); + } + catch (Exception e) + { + Config.Log.Error(e, "Failed to report content filter"); + } + return false; + } + } +} \ No newline at end of file diff --git a/CompatBot/Database/Providers/PiracyStringProvider.cs b/CompatBot/Database/Providers/PiracyStringProvider.cs deleted file mode 100644 index 0683c1cd..00000000 --- a/CompatBot/Database/Providers/PiracyStringProvider.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using NReco.Text; - -namespace CompatBot.Database.Providers -{ - internal static class PiracyStringProvider - { - private static readonly BotDb db = new BotDb(); - private static readonly object SyncObj = new object(); - private static readonly List PiracyStrings; - private static AhoCorasickDoubleArrayTrie matcher; - - static PiracyStringProvider() - { - PiracyStrings = db.Piracystring.Select(ps => ps.String).ToList(); - RebuildMatcher(); - } - - public static async Task AddAsync(string trigger) - { - if (PiracyStrings.Contains(trigger, StringComparer.InvariantCultureIgnoreCase)) - return false; - - lock (SyncObj) - { - if (PiracyStrings.Contains(trigger, StringComparer.InvariantCultureIgnoreCase)) - return false; - - PiracyStrings.Add(trigger); - RebuildMatcher(); - } - await db.Piracystring.AddAsync(new Piracystring {String = trigger}).ConfigureAwait(false); - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } - - public static async Task RemoveAsync(int id) - { - var dbItem = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id).ConfigureAwait(false); - if (dbItem == null) - return false; - - db.Piracystring.Remove(dbItem); - if (!PiracyStrings.Contains(dbItem.String)) - return false; - - lock (SyncObj) - { - if (!PiracyStrings.Remove(dbItem.String)) - return false; - - RebuildMatcher(); - } - - await db.SaveChangesAsync().ConfigureAwait(false); - return true; - } - - public static async Task GetTriggerAsync(int id) - { - var dbItem = await db.Piracystring.FirstOrDefaultAsync(ps => ps.Id == id).ConfigureAwait(false); - return dbItem?.String; - } - - public static Task FindTriggerAsync(string str) - { - string result = null; - matcher?.ParseText(str, h => - { - result = h.Value; - return false; - }); - return Task.FromResult(result); - } - - private static void RebuildMatcher() - { - matcher = PiracyStrings.Count == 0 ? null : new AhoCorasickDoubleArrayTrie(PiracyStrings.ToDictionary(s => s, s => s), true); - } - } -} \ No newline at end of file diff --git a/CompatBot/Database/ThumbnailDb.cs b/CompatBot/Database/ThumbnailDb.cs index c69b4d80..c2f0d2d7 100644 --- a/CompatBot/Database/ThumbnailDb.cs +++ b/CompatBot/Database/ThumbnailDb.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.IO; using CompatApiClient; using Microsoft.EntityFrameworkCore; @@ -15,6 +14,9 @@ namespace CompatBot.Database protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var dbPath = DbImporter.GetDbPath("thumbs.db", Environment.SpecialFolder.LocalApplicationData); +#if DEBUG + optionsBuilder.UseLoggerFactory(Config.LoggerFactory); +#endif optionsBuilder.UseSqlite($"Data Source=\"{dbPath}\""); } diff --git a/CompatBot/EventHandlers/AntipiracyMonitor.cs b/CompatBot/EventHandlers/AntipiracyMonitor.cs index 4fb3efca..598aa723 100644 --- a/CompatBot/EventHandlers/AntipiracyMonitor.cs +++ b/CompatBot/EventHandlers/AntipiracyMonitor.cs @@ -1,12 +1,7 @@ -using System; -using System.Threading.Tasks; -using CompatApiClient.Utils; -using CompatBot.Commands; +using System.Threading.Tasks; using CompatBot.Database.Providers; using CompatBot.Utils; using CompatBot.Utils.Extensions; -using DSharpPlus; -using DSharpPlus.Entities; using DSharpPlus.EventArgs; namespace CompatBot.EventHandlers @@ -15,12 +10,12 @@ namespace CompatBot.EventHandlers { public static async Task OnMessageCreated(MessageCreateEventArgs args) { - args.Handled = !await IsClean(args.Client, args.Message).ConfigureAwait(false); + args.Handled = !await ContentFilter.IsClean(args.Client, args.Message).ConfigureAwait(false); } public static async Task OnMessageUpdated(MessageUpdateEventArgs args) { - args.Handled = !await IsClean(args.Client, args.Message).ConfigureAwait(false); + args.Handled = !await ContentFilter.IsClean(args.Client, args.Message).ConfigureAwait(false); } public static async Task OnReaction(MessageReactionAddEventArgs e) @@ -33,55 +28,7 @@ namespace CompatBot.EventHandlers return; var message = await e.Channel.GetMessageAsync(e.Message.Id).ConfigureAwait(false); - await IsClean(e.Client, message).ConfigureAwait(false); - } - - public static async Task IsClean(DiscordClient client, DiscordMessage message) - { - if (message.Channel.IsPrivate) - return true; - - if (message.Author.IsBotSafeCheck()) - return true; - -#if !DEBUG - if (message.Author.IsWhitelisted(client, message.Channel.Guild)) - return true; -#endif - - if (string.IsNullOrEmpty(message.Content)) - return true; - - string trigger = null; - var severity = ReportSeverity.Low; - try - { - trigger = await PiracyStringProvider.FindTriggerAsync(message.Content); - if (trigger == null) - return true; - - await message.Channel.DeleteMessageAsync(message, $"Mention of piracy trigger '{trigger}'").ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't delete message in {message.Channel.Name}"); - severity = ReportSeverity.High; - } - try - { - var rules = await client.GetChannelAsync(Config.BotRulesChannelId).ConfigureAwait(false); - var yarr = client.GetEmoji(":piratethink:", "🦜"); - await Task.WhenAll( - message.Channel.SendMessageAsync($"{message.Author.Mention} Please follow the {rules.Mention} and do not discuss piracy on this server. Repeated offence may result in a ban."), - client.ReportAsync(yarr + " Mention of piracy", message, trigger, message.Content, severity), - Warnings.AddAsync(client, message, message.Author.Id, message.Author.Username, client.CurrentUser, "Mention of piracy", message.Content.Sanitize()) - ).ConfigureAwait(false); - } - catch (Exception e) - { - Config.Log.Warn(e, $"Couldn't finish piracy trigger actions for a message in {message.Channel.Name}"); - } - return false; + await ContentFilter.IsClean(e.Client, message).ConfigureAwait(false); } } } diff --git a/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs b/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs index 869a3b47..27a81bc6 100644 --- a/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs +++ b/CompatBot/EventHandlers/LogParsing/LogParser.LogSections.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Text.RegularExpressions; using System.Threading.Tasks; +using CompatBot.Database; using CompatBot.Database.Providers; using CompatBot.EventHandlers.LogParsing.POCOs; using CompatBot.Utils; @@ -210,9 +211,10 @@ namespace CompatBot.EventHandlers.LogParsing private static async Task PiracyCheckAsync(string line, LogParseState state) { - if (await PiracyStringProvider.FindTriggerAsync(line).ConfigureAwait(false) is string match) + if (await ContentFilter.FindTriggerAsync(FilterContext.Log, line).ConfigureAwait(false) is Piracystring match + && match.Actions.HasFlag(FilterAction.RemoveMessage)) { - state.PiracyTrigger = match; + state.PiracyTrigger = match.String; state.PiracyContext = line.ToUtf8(); state.Error = LogParseState.ErrorCode.PiracyDetected; } diff --git a/CompatBot/Utils/Extensions/BotDbExtensions.cs b/CompatBot/Utils/Extensions/BotDbExtensions.cs index 387f0bf7..c87d20ab 100644 --- a/CompatBot/Utils/Extensions/BotDbExtensions.cs +++ b/CompatBot/Utils/Extensions/BotDbExtensions.cs @@ -11,5 +11,15 @@ namespace CompatBot.Utils && evt.Year > 0 && !string.IsNullOrEmpty(evt.Name); } + + public static bool IsComplete(this Piracystring filter) + { + var result = !string.IsNullOrEmpty(filter.String) + && filter.String.Length > 4 + && filter.Actions != 0; + if (result && filter.Actions.HasFlag(FilterAction.ShowExplain)) + result = !string.IsNullOrEmpty(filter.ExplainTerm); + return result; + } } } diff --git a/CompatBot/Utils/Extensions/DiscordClientExtensions.cs b/CompatBot/Utils/Extensions/DiscordClientExtensions.cs index 4a8b0154..2c47adbc 100644 --- a/CompatBot/Utils/Extensions/DiscordClientExtensions.cs +++ b/CompatBot/Utils/Extensions/DiscordClientExtensions.cs @@ -103,10 +103,10 @@ namespace CompatBot.Utils return messages.TakeWhile(m => m.CreationTimestamp > afterTime).ToList().AsReadOnly(); } - public static async Task ReportAsync(this DiscordClient client, string infraction, DiscordMessage message, string trigger, string context, ReportSeverity severity) + public static async Task ReportAsync(this DiscordClient client, string infraction, DiscordMessage message, string trigger, string context, ReportSeverity severity, string actionList = null) { var getLogChannelTask = client.GetChannelAsync(Config.BotLogId); - var embedBuilder = MakeReportTemplate(client, infraction, message, severity); + var embedBuilder = MakeReportTemplate(client, infraction, message, severity, actionList); var reportText = string.IsNullOrEmpty(trigger) ? "" : $"Triggered by: `{trigger}`{Environment.NewLine}"; if (!string.IsNullOrEmpty(context)) reportText += $"Triggered in: ```{context.Sanitize()}```{Environment.NewLine}"; @@ -180,7 +180,7 @@ namespace CompatBot.Utils return channel.SendMessageAsync(message); } - private static DiscordEmbedBuilder MakeReportTemplate(DiscordClient client, string infraction, DiscordMessage message, ReportSeverity severity) + private static DiscordEmbedBuilder MakeReportTemplate(DiscordClient client, string infraction, DiscordMessage message, ReportSeverity severity, string actionList = null) { var content = message.Content; if (message.Channel.IsPrivate) @@ -222,8 +222,13 @@ namespace CompatBot.Utils .AddField("Channel", message.Channel.IsPrivate ? "Bot's DM" : message.Channel.Mention, true) .AddField("Time (UTC)", message.CreationTimestamp.ToString("yyyy-MM-dd HH:mm:ss"), true) .AddField("Content of the offending item", content); + if (!string.IsNullOrEmpty(actionList)) + result.AddField("Filter Actions", actionList, true); if (needsAttention && !message.Channel.IsPrivate) result.AddField("Link to the message", message.JumpLink.ToString()); +#if DEBUG + result.WithFooter("Test bot instance"); +#endif return result; } diff --git a/CompatBot/Utils/Extensions/DiscordEmbedBuilderExtensions.cs b/CompatBot/Utils/Extensions/DiscordEmbedBuilderExtensions.cs index 4a59efff..c8e630c9 100644 --- a/CompatBot/Utils/Extensions/DiscordEmbedBuilderExtensions.cs +++ b/CompatBot/Utils/Extensions/DiscordEmbedBuilderExtensions.cs @@ -6,6 +6,7 @@ namespace CompatBot.Utils { public static DiscordEmbedBuilder AddFieldEx(this DiscordEmbedBuilder builder, string header, string content, bool underline = false, bool inline = false) { + content = string.IsNullOrEmpty(content) ? "-" : content; return builder.AddField(underline ? $"__{header}__" : header, content, inline); } } diff --git a/CompatBot/Utils/Extensions/FilterActionExtensions.cs b/CompatBot/Utils/Extensions/FilterActionExtensions.cs new file mode 100644 index 00000000..2d46e11a --- /dev/null +++ b/CompatBot/Utils/Extensions/FilterActionExtensions.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using CompatBot.Database; + +namespace CompatBot.Utils.Extensions +{ + internal static class FilterActionExtensions + { + private static readonly Dictionary ActionFlags = new Dictionary + { + [FilterAction.RemoveMessage] = 'r', + [FilterAction.IssueWarning] = 'w', + [FilterAction.SendMessage] = 'm', + [FilterAction.ShowExplain] = 'e', + }; + + public static string ToFlagsString(this FilterAction flags) + { + var result = Enum.GetValues(typeof(FilterAction)) + .Cast() + .Select(fa => flags.HasFlag(fa) ? ActionFlags[fa] : '-') + .ToArray(); + return new string(result); + } + + public static string GetLegend() + { + var result = new StringBuilder("Actions flag legend:"); + foreach (FilterAction fa in Enum.GetValues(typeof(FilterAction))) + result.Append($"\n`{ActionFlags[fa]}` = {fa}"); + return result.ToString(); + } + } +} diff --git a/CompatBot/Utils/Extensions/InteractivityExtensions.cs b/CompatBot/Utils/Extensions/InteractivityExtensions.cs index c79afdcc..92387cf5 100644 --- a/CompatBot/Utils/Extensions/InteractivityExtensions.cs +++ b/CompatBot/Utils/Extensions/InteractivityExtensions.cs @@ -29,33 +29,46 @@ namespace CompatBot.Utils if (reactions.Length == 0) throw new ArgumentException("At least one reaction must be specified", nameof(reactions)); - reactions = reactions.Where(r => r != null).ToArray(); - foreach (var emoji in reactions) - await message.ReactWithAsync(interactivity.Client, emoji).ConfigureAwait(false); - var waitTextResponseTask = interactivity.WaitForMessageAsync(m => m.Author == user && m.Channel == message.Channel && !string.IsNullOrEmpty(m.Content), timeout); - var waitReactionResponse = interactivity.WaitForReactionAsync(arg => reactions.Contains(arg.Emoji), message, user, timeout); - await Task.WhenAny( - waitTextResponseTask, - waitReactionResponse - ).ConfigureAwait(false); try { - await message.DeleteAllReactionsAsync().ConfigureAwait(false); + reactions = reactions.Where(r => r != null).ToArray(); + foreach (var emoji in reactions) + await message.ReactWithAsync(interactivity.Client, emoji).ConfigureAwait(false); + var expectedChannel = message.Channel; + var waitTextResponseTask = interactivity.WaitForMessageAsync(m => m.Author == user && m.Channel == expectedChannel && !string.IsNullOrEmpty(m.Content), timeout); + var waitReactionResponse = interactivity.WaitForReactionAsync(arg => reactions.Contains(arg.Emoji), message, user, timeout); + await Task.WhenAny( + waitTextResponseTask, + waitReactionResponse + ).ConfigureAwait(false); + try + { + await message.DeleteAllReactionsAsync().ConfigureAwait(false); + } + catch + { + await message.DeleteAsync().ConfigureAwait(false); + message = null; + } + DiscordMessage text = null; + MessageReactionAddEventArgs reaction = null; + if (waitTextResponseTask.IsCompletedSuccessfully) + text = (await waitTextResponseTask).Result; + if (waitReactionResponse.IsCompletedSuccessfully) + reaction = (await waitReactionResponse).Result; + if (text != null) + try + { + await text.DeleteAsync().ConfigureAwait(false); + } + catch {} + return (message, text, reaction); } - catch + catch (Exception e) { - await message.DeleteAsync().ConfigureAwait(false); - message = null; + Config.Log.Warn(e, "Failed to get interactive reaction"); + return (message, null, null); } - DiscordMessage text = null; - MessageReactionAddEventArgs reaction = null; - if (waitTextResponseTask.IsCompletedSuccessfully) - text = (await waitTextResponseTask).Result; - if (waitReactionResponse.IsCompletedSuccessfully) - reaction = (await waitReactionResponse).Result; - if (text != null) - try { await text.DeleteAsync().ConfigureAwait(false); } catch { } - return (message, text, reaction); } } }