Ported RegexModerator from RegexBot

Changes:
-Renamed from AutoMod
-Some options have been simplified to reduce complexity
-Response exectution has been totally reworked to accomodate new and
upcoming features

Currently untested.
This commit is contained in:
Noikoio 2019-06-16 22:37:11 -07:00
parent e87a985067
commit 90feeb47b0
3 changed files with 609 additions and 0 deletions

View file

@ -0,0 +1,233 @@
using Discord;
using Discord.WebSocket;
using Kerobot.Common;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace Kerobot.Modules.RegexModerator
{
/// <summary>
/// Representation of a single RegexModerator rule for a guild.
/// Data in this class is immutable. Contains various helper methods.
/// </summary>
[DebuggerDisplay("RM rule '{_label}' for {_guild}")]
class ConfDefinition
{
public string Label { get; }
readonly RegexModerator _module; // TODO is this needed?
readonly ulong _guild; // corresponding guild, for debug purposes. (is this needed?)
// Matching settings
readonly IEnumerable<Regex> _regex;
readonly FilterList _filter;
readonly bool _ignoreMods;
readonly bool _embedScan;
// Response settings
public string ReplyInChannel { get; }
public string ReplyInDM { get; }
public EntityName RoleAdd { get; } // keep in mind it's possible to have both add and remove role available at once
public EntityName RoleRemove { get; }
//readonly bool _rRolePersist; // TODO use when feature exists
public EntityName ReportingChannel { get; }
public Kerobot.RemovalType RemovalAction { get; } // ban, kick?
public int BanPurgeDays { get; }
public string RemovalReason { get; } // reason to place into audit log, notification
public bool RemovalSendUserNotification; // send ban/kick notification to user?
public bool DeleteMessage { get; }
public ConfDefinition(RegexModerator instance, JObject def, ulong guildId)
{
_module = instance;
Label = def["Label"].Value<string>();
if (string.IsNullOrWhiteSpace(Label))
throw new ModuleLoadException("A rule does not have a label defined.");
string errpostfx = $" in the rule definition for '{Label}'.";
// Regex loading
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline.
opts |= RegexOptions.Singleline;
// IgnoreCase is enabled by default; must be explicitly set to false
bool? rxci = def["IgnoreCase"]?.Value<bool>();
if (rxci ?? true) opts |= RegexOptions.IgnoreCase;
const string ErrNoRegex = "Regular expression patterns are not defined";
var rxs = new List<Regex>();
var rxconf = def["regex"];
if (rxconf == null) throw new ModuleLoadException(ErrNoRegex + errpostfx);
if (rxconf.Type == JTokenType.Array)
{
foreach (var input in rxconf.Values<string>())
{
try
{
// TODO HIGH IMPORTANCE: sanitize input regex; don't allow inline editing of options
Regex r = new Regex(input, opts);
rxs.Add(r);
}
catch (ArgumentException)
{
throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
}
}
}
else
{
string rxstr = rxconf.Value<string>();
try
{
Regex r = new Regex(rxstr, opts);
rxs.Add(r);
}
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
{
throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
}
}
if (rxs.Count == 0)
{
throw new ModuleLoadException(ErrNoRegex + errpostfx);
}
_regex = rxs.ToArray();
// Filtering
_filter = new FilterList(def);
// Misc options
// IgnoreMods is enabled by default; must be explicitly set to false
bool? bypass = def["IgnoreMods"]?.Value<bool>();
_ignoreMods = bypass ?? true;
bool? embedScan = def["EmbedScanMode"]?.Value<bool>();
_embedScan = embedScan ?? false; // false by default
// Response options
var resp = def["Response"] as JObject;
if (resp == null)
throw new ModuleLoadException("Cannot find a valid response section" + errpostfx);
ReplyInChannel = resp[nameof(ReplyInChannel)]?.Value<string>();
ReplyInDM = resp[nameof(ReplyInDM)]?.Value<string>();
const string ErrRole = "The role value specified is not properly defined as a role";
// TODO make this error message nicer
var rolestr = resp[nameof(RoleAdd)]?.Value<string>();
if (!string.IsNullOrWhiteSpace(rolestr))
{
RoleAdd = new EntityName(rolestr);
if (RoleAdd.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx);
}
else RoleAdd = null;
rolestr = resp[nameof(RoleRemove)]?.Value<string>();
if (!string.IsNullOrWhiteSpace(rolestr))
{
RoleRemove = new EntityName(rolestr);
if (RoleRemove.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx);
}
else RoleRemove = null;
//_rRolePersist = resp["RolePersist"]?.Value<bool>() ?? false;
var reportstr = resp[nameof(ReportingChannel)]?.Value<string>();
if (!string.IsNullOrWhiteSpace(reportstr))
{
ReportingChannel = new EntityName(reportstr);
if (ReportingChannel.Type != EntityType.Channel)
throw new ModuleLoadException("The reporting channel specified is not properly defined as a channel" + errpostfx);
}
else ReportingChannel = null;
var removestr = resp[nameof(RemovalAction)]?.Value<string>();
// accept values ban, kick, none
switch (removestr)
{
case "ban": RemovalAction = Kerobot.RemovalType.Ban; break;
case "kick": RemovalAction = Kerobot.RemovalType.Kick; break;
case "none": RemovalAction = Kerobot.RemovalType.None; break;
default:
if (removestr != null)
throw new ModuleLoadException("RemoveAction is not set to a proper value" + errpostfx);
break;
}
// TODO extract BanPurgeDays
int? banpurgeint;
try { banpurgeint = resp[nameof(BanPurgeDays)]?.Value<int>(); }
catch (InvalidCastException) { throw new ModuleLoadException("BanPurgeDays must be a numeric value."); }
if (banpurgeint.HasValue)
{
if (banpurgeint > 7 || banpurgeint < 0)
throw new ModuleLoadException("BanPurgeDays must be a value between 0 and 7 inclusive.");
BanPurgeDays = banpurgeint ?? 0;
}
RemovalReason = resp[nameof(RemovalReason)]?.Value<string>();
RemovalSendUserNotification = resp[nameof(RemovalSendUserNotification)]?.Value<bool>() ?? false;
DeleteMessage = resp[nameof(DeleteMessage)]?.Value<bool>() ?? false;
}
/// <summary>
/// Checks the given message to determine if it matches this definition's constraints.
/// </summary>
/// <returns>True if match.</returns>
public bool IsMatch(SocketMessage m, bool senderIsModerator)
{
// TODO keep id: true or false?
if (_filter.IsFiltered(m, false)) return false;
if (senderIsModerator && _ignoreMods) return false;
var matchText = _embedScan ? SerializeEmbed(m.Embeds) : m.Content;
foreach (var regex in _regex)
{
// TODO enforce maximum execution time
// TODO multi-processing of multiple regexes?
// TODO metrics: temporary tracking of regex execution times
if (regex.IsMatch(m.Content)) return true;
}
return false;
}
private string SerializeEmbed(IReadOnlyCollection<Embed> e)
{
var text = new StringBuilder();
foreach (var item in e) text.AppendLine(SerializeEmbed(item));
return text.ToString();
}
/// <summary>
/// Converts an embed to a plain string for easier matching.
/// </summary>
private string SerializeEmbed(Embed e)
{
StringBuilder result = new StringBuilder();
if (e.Author.HasValue) result.AppendLine(e.Author.Value.Name ?? "" + e.Author.Value.Url ?? "");
if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
foreach (var f in e.Fields)
{
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
}
if (e.Footer.HasValue)
{
result.AppendLine(e.Footer.Value.Text ?? "");
}
return result.ToString();
}
}
}

View file

@ -0,0 +1,77 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Kerobot.Modules.RegexModerator
{
/// <summary>
/// The 'star' feature of Kerobot. Users define pattern-based rules with other constraints.
/// When triggered, each rule executes one or more different actions.
/// </summary>
[KerobotModule]
public class RegexModerator : ModuleBase
{
public RegexModerator(Kerobot kb) : base(kb)
{
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
}
public override Task<object> CreateGuildStateAsync(ulong guildID, JToken config)
{
if (config == null) return Task.FromResult<object>(null);
var defs = new List<ConfDefinition>();
foreach (var def in config.Children<JObject>())
defs.Add(new ConfDefinition(this, def, guildID));
if (defs.Count == 0) return Task.FromResult<object>(null);
return Task.FromResult<object>(defs.AsReadOnly());
}
private Task DiscordClient_MessageUpdated(Discord.Cacheable<Discord.IMessage, ulong> arg1,
SocketMessage arg2, ISocketMessageChannel arg3)
=> ReceiveIncomingMessage(arg2);
private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg);
/// <summary>
/// Does initial message checking before further processing.
/// </summary>
private async Task ReceiveIncomingMessage(SocketMessage msg)
{
// Ignore non-guild channels
if (!(msg.Channel is SocketGuildChannel ch)) return;
// Get config?
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
if (defs == null) return;
// Send further processing to thread pool.
// Match checking is a CPU-intensive task, thus very little checking is done here.
foreach (var item in defs)
{
// Need to check sender's moderator status here. Definition can't access mod list.
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
var match = item.IsMatch(msg, isMod);
await Task.Run(async () => await ProcessMessage(item, msg, isMod));
}
}
/// <summary>
/// Does further message checking and response execution.
/// Invocations of this method are meant to be on the thread pool.
/// </summary>
private async Task ProcessMessage(ConfDefinition def, SocketMessage msg, bool isMod)
{
// Reminder: IsMatch handles matching execution time
if (!def.IsMatch(msg, isMod)) return;
// TODO logging options for match result; handle here?
var executor = new ResponseExecutor(def, Kerobot);
await executor.Execute(msg);
}
}
}

View file

@ -0,0 +1,299 @@
using Discord;
using Discord.WebSocket;
using Kerobot.Common;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using static Kerobot.Kerobot;
namespace Kerobot.Modules.RegexModerator
{
/// <summary>
/// Helper class to RegexModerator that executes the appropriate actions associated with a triggered rule.
/// </summary>
class ResponseExecutor
{
private readonly ConfDefinition _rule;
private readonly Kerobot _bot;
private List<(ResponseAction, ResponseExecutionResult)> _results;
public ResponseExecutor(ConfDefinition rule, Kerobot kb)
{
_rule = rule;
_bot = kb;
}
public async Task Execute(SocketMessage msg)
{
var g = ((SocketGuildUser)msg.Author).Guild;
_results = new List<(ResponseAction, ResponseExecutionResult)>();
var tasks = new List<Task>
{
ExecuteAction(DoReplyToChannel, g, msg),
ExecuteAction(DoReplyToInvokerDM, g, msg),
ExecuteAction(DoRoleAdd, g, msg),
ExecuteAction(DoRoleRemove, g, msg),
ExecuteAction(DoBan, g, msg),
ExecuteAction(DoKick, g, msg),
ExecuteAction(DoDelete, g, msg)
// TODO role add/remove: add persistence option
// TODO add note to user log
// TODO add warning to user log
};
await Task.WhenAll(tasks);
// Report can only run after all previous actions have been performed.
await ExecuteAction(DoReport, g, msg);
// TODO pass any local error messages to guild log
}
#region Response actions
/*
* For the sake of creating reports and notifying the user of any issues,
* every response method should have a signature that conforms to that of the
* ResponseAction delegate defined here.
* Methods here should attempt to handle their own expected exceptions, and leave the
* extraordinary exceptions for the wrapper to deal with.
*
* Methods may return null, but MUST only do so if they took no action (meaning, they were
* not meant to take any action per the input configuration). Data within each
* ResponseExecutionResult is then used to build a report (if requested) and/or place
* error messages into the guild log.
*/
delegate Task<ResponseExecutionResult> ResponseAction(SocketGuild g, SocketMessage msg);
const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue.";
private Task<ResponseExecutionResult> DoBan(SocketGuild g, SocketMessage msg)
{
if (_rule.RemovalAction != RemovalType.Ban) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemovalAction);
}
private Task<ResponseExecutionResult> DoKick(SocketGuild g, SocketMessage msg)
{
if (_rule.RemovalAction != RemovalType.Kick) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemovalAction);
}
private async Task<ResponseExecutionResult> DoBanOrKick(SocketGuild g, SocketMessage msg, RemovalType t)
{
var result = await _bot.BanOrKickAsync(t, g, $"Rule '{_rule.Label}'",
msg.Author.Id, _rule.BanPurgeDays, _rule.RemovalReason, _rule.RemovalSendUserNotification);
if (result.ErrorForbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else if (result.ErrorNotFound)
{
return new ResponseExecutionResult(false, "The target user is no longer in the server.");
}
else return new ResponseExecutionResult(true, null);
// TODO option to announce ban/kick result in the trigger channel
// ^ implementation: take result, reply to channel. don't alter BanOrKickAsync.
}
private async Task<ResponseExecutionResult> DoDelete(SocketGuild g, SocketMessage msg)
{
if (!_rule.DeleteMessage) return null;
try
{
await msg.DeleteAsync();
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
{
return new ResponseExecutionResult(false, "The message has already been deleted.");
}
else throw;
}
}
private async Task<ResponseExecutionResult> DoReplyToChannel(SocketGuild g, SocketMessage msg)
{
if (string.IsNullOrWhiteSpace(_rule.ReplyInChannel)) return null;
try
{
await msg.Channel.SendMessageAsync(_rule.ReplyInChannel);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
private async Task<ResponseExecutionResult> DoReplyToInvokerDM(SocketGuild g, SocketMessage msg)
{
if (string.IsNullOrWhiteSpace(_rule.ReplyInDM)) return null;
var target = await msg.Author.GetOrCreateDMChannelAsync(); // can this throw an exception?
try
{
await target.SendMessageAsync(_rule.ReplyInDM);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, "The target user is not accepting DMs.");
}
else throw;
}
}
private Task<ResponseExecutionResult> DoRoleAdd(SocketGuild g, SocketMessage msg)
=> RoleManipulationResponse(g, msg, true);
private Task<ResponseExecutionResult> DoRoleRemove(SocketGuild g, SocketMessage msg)
=> RoleManipulationResponse(g, msg, false);
private async Task<ResponseExecutionResult> RoleManipulationResponse(SocketGuild g, SocketMessage msg, bool add)
{
EntityName ck;
if (add)
{
if (_rule.RoleAdd == null) return null;
ck = _rule.RoleAdd;
}
else
{
if (_rule.RoleRemove == null) return null;
ck = _rule.RoleRemove;
}
SocketRole target = ck.FindRoleIn(g, false);
if (target == null)
{
return new ResponseExecutionResult(false,
$"Unable to determine the role to be {(add ? "added" : "removed")}. Does it still exist?");
}
try
{
if (add) await ((SocketGuildUser)msg.Author).AddRoleAsync(target);
else await ((SocketGuildUser)msg.Author).RemoveRoleAsync(target);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
#endregion
#region Reporting
private class ResponseExecutionResult
{
public bool Success { get; }
public string Notice { get; }
public ResponseExecutionResult(bool success, string log)
{
Success = success;
Notice = log;
}
}
private async Task ExecuteAction(ResponseAction action, SocketGuild g, SocketMessage arg)
{
ResponseExecutionResult result;
try { result = await action(g, arg); }
catch (Exception ex)
{
result = new ResponseExecutionResult(false,
"An unknown error occurred. The bot maintainer has been notified.");
await _bot.InstanceLogAsync(true, nameof(RegexModerator),
"An unexpected error occurred while executing a response. "
+ $"Guild: {g.Id} - Rule: '{_rule.Label}' - Exception detail:\n"
+ ex.ToString());
}
if (result != null)
{
lock (_results) _results.Add((action, result));
}
}
private async Task<ResponseExecutionResult> DoReport(SocketGuild g, SocketMessage msg)
{
if (_rule.ReportingChannel == null) return null;
// Determine channel before anything else
var ch = _rule.ReportingChannel.FindChannelIn(g, true);
if (ch == null) return new ResponseExecutionResult(false, "Unable to find reporting channel.");
var rptOutput = new StringBuilder();
foreach (var (action, result) in _results) // Locking of _results not necessary at this point
{
if (result == null) continue;
rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:");
rptOutput.Append(" " + action.Method.Name);
if (result.Notice != null)
rptOutput.Append(" - " + result.Notice);
rptOutput.AppendLine();
}
// Report status goes last. It is presumed to succeed. If it fails, the message won't make it anyway.
rptOutput.Append($":white_check_mark: {nameof(DoReport)}");
// We can only afford to show a preview of the message being reported, due to embeds
// being constrained to the same 2000 character limit.
const string TruncateWarning = "**Notice: Full message has been truncated.**\n";
const int TruncateMaxLength = 990;
var invokingLine = msg.Content;
if (invokingLine.Length > TruncateMaxLength)
{
invokingLine = TruncateWarning + invokingLine.Substring(0, TruncateMaxLength - TruncateWarning.Length);
}
var resultEm = new EmbedBuilder()
{
Color = new Color(0xEDCE00), // TODO configurable later?
Author = new EmbedAuthorBuilder()
{
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
IconUrl = msg.Author.GetAvatarUrl()
},
Description = invokingLine,
Footer = new EmbedFooterBuilder() { Text = $"Rule: {_rule.Label}" },
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
}.AddField(new EmbedFieldBuilder()
{
Name = "Actions taken:",
Value = rptOutput.ToString()
}).Build();
try
{
await ch.SendMessageAsync(embed: resultEm);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
#endregion
}
}