Rewrite RegexModerator

This commit is contained in:
Noi 2022-07-05 20:59:19 -07:00
parent 13324999cc
commit ffaae04bc6
4 changed files with 425 additions and 575 deletions

View file

@ -1,231 +1,126 @@
using Discord; using Discord;
using Discord.WebSocket;
using RegexBot.Common; using RegexBot.Common;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace RegexBot.Modules.RegexModerator namespace RegexBot.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}'")]
class ConfDefinition {
public string Label { get; }
// Matching settings
private IEnumerable<Regex> Regex { get; }
private FilterList Filter { get; }
private bool IgnoreMods { get; }
private bool ScanEmbeds { get; }
// Response settings
public EntityName? ReportingChannel { get; }
public IReadOnlyList<string> Response { get; }
public int BanPurgeDays { get; }
public bool NotifyChannelOfRemoval { get; }
public bool NotifyUserOfRemoval { get; }
public ConfDefinition(JObject def) {
Label = def[nameof(Label)]?.Value<string>()
?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}.");
var errpostfx = $" in the rule definition for '{Label}'.";
var rptch = def[nameof(ReportingChannel)]?.Value<string>();
if (rptch != null) {
ReportingChannel = new EntityName(rptch);
if (ReportingChannel.Type != EntityType.Channel)
throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
}
// Regex loading
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline.
// Reminder: in Singleline mode, all contents are subject to the same regex (useful if e.g. spammer separates words line by line)
opts |= RegexOptions.Singleline;
// IgnoreCase is enabled by default; must be explicitly set to false
if (def["IgnoreCase"]?.Value<bool>() ?? true) opts |= RegexOptions.IgnoreCase;
const string ErrBadRegex = "Unable to parse regular expression pattern";
var regexRules = new List<Regex>();
List<string> regexStrings;
try {
regexStrings = Misc.LoadStringOrStringArray(def[nameof(Regex)]);
} catch (ArgumentNullException) {
throw new ModuleLoadException($"No patterns were defined under '{nameof(Regex)}'{errpostfx}");
} catch (ArgumentException) {
throw new ModuleLoadException($"'{nameof(Regex)}' is not properly defined{errpostfx}");
}
foreach (var input in regexStrings) {
try {
regexRules.Add(new Regex(input, opts));
} catch (ArgumentException) {
throw new ModuleLoadException($"{ErrBadRegex}{errpostfx}");
}
}
Regex = regexRules.AsReadOnly();
// Filtering
Filter = new FilterList(def);
// Misc options
// IgnoreMods is enabled by default; must be explicitly set to false
IgnoreMods = def[nameof(IgnoreMods)]?.Value<bool>() ?? true;
ScanEmbeds = def[nameof(ScanEmbeds)]?.Value<bool>() ?? false; // false by default
// Load response(s) and response settings
try {
Response = Misc.LoadStringOrStringArray(def[nameof(Response)]).AsReadOnly();
} catch (ArgumentNullException) {
throw new ModuleLoadException($"No responses were defined under '{nameof(Response)}'{errpostfx}");
} catch (ArgumentException) {
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
}
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value<bool>() ?? true;
NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value<bool>() ?? true;
}
/// <summary> /// <summary>
/// Representation of a single RegexModerator rule for a guild. /// Checks the given message to determine if it matches this definition's constraints.
/// Data in this class is immutable. Contains various helper methods.
/// </summary> /// </summary>
[DebuggerDisplay("RM rule '{Label}'")] /// <returns>True if match.</returns>
class ConfDefinition public bool IsMatch(SocketMessage m, bool senderIsModerator) {
{ if (Filter.IsFiltered(m, false)) return false;
public string Label { get; } if (senderIsModerator && IgnoreMods) return false;
// Matching settings foreach (var regex in Regex) {
readonly IEnumerable<Regex> _regex; if (ScanEmbeds && regex.IsMatch(SerializeEmbed(m.Embeds))) return true;
readonly FilterList _filter; if (regex.IsMatch(m.Content)) return true;
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 RegexBot.RemovalType RemoveAction { get; } // ban, kick?
public int BanPurgeDays { get; }
public string RemoveReason { get; } // reason to place into audit log and notification
public bool RemoveAnnounce { get; } // send success/failure message in invoking channel? default: true
public bool RemoveNotifyTarget { get; } // send ban/kick notification to user?
public bool DeleteMessage { get; }
public ConfDefinition(JObject def)
{
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(RemoveAction)]?.Value<string>();
// accept values ban, kick, none
switch (removestr)
{
case "ban": RemoveAction = RegexBot.RemovalType.Ban; break;
case "kick": RemoveAction = RegexBot.RemovalType.Kick; break;
case "none": RemoveAction = RegexBot.RemovalType.None; break;
default:
if (removestr != null)
throw new ModuleLoadException("RemoveAction is not set to a proper value" + errpostfx);
break;
}
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;
}
RemoveReason = resp[nameof(RemoveReason)]?.Value<string>();
RemoveAnnounce = resp[nameof(RemoveAnnounce)]?.Value<bool>() ?? true;
RemoveNotifyTarget = resp[nameof(RemoveNotifyTarget)]?.Value<bool>() ?? false;
DeleteMessage = resp[nameof(DeleteMessage)]?.Value<bool>() ?? false;
} }
return false;
}
/// <summary> private static string SerializeEmbed(IReadOnlyCollection<Embed> e) {
/// Checks the given message to determine if it matches this definition's constraints. static string serialize(Embed e) {
/// </summary> var result = new StringBuilder();
/// <returns>True if match.</returns> if (e.Author.HasValue) result.AppendLine($"{e.Author.Value.Name} {e.Author.Value.Url}");
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(matchText)) 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.Title)) result.AppendLine(e.Title);
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description); if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
foreach (var f in e.Fields) foreach (var f in e.Fields) {
{
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name); if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value); if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
} }
if (e.Footer.HasValue) {
if (e.Footer.HasValue)
{
result.AppendLine(e.Footer.Value.Text ?? ""); result.AppendLine(e.Footer.Value.Text ?? "");
} }
return result.ToString(); return result.ToString();
} }
var text = new StringBuilder();
foreach (var item in e) text.AppendLine(serialize(item));
return text.ToString();
} }
} }

View file

@ -1,90 +1,70 @@
using Discord.WebSocket; using Discord;
using Newtonsoft.Json.Linq;
using System.Collections.Generic; namespace RegexBot.Modules.RegexModerator;
using System.Threading.Tasks; /// <summary>
/// The namesake of RegexBot. This module allows users to define pattern-based rules with other constraints.
/// When triggered, one or more actions are executed as defined in its configuration.
/// </summary>
[RegexbotModule]
public class RegexModerator : RegexbotModule {
public RegexModerator(RegexbotClient bot) : base(bot) {
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>();
if (config.Type != JTokenType.Array)
throw new ModuleLoadException(Name + " configuration must be a JSON array.");
// TODO better error reporting during this process
foreach (var def in config.Children<JObject>())
defs.Add(new ConfDefinition(def));
if (defs.Count == 0) return Task.FromResult<object?>(null);
Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s).");
return Task.FromResult<object?>(defs.AsReadOnly());
}
private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg);
private Task DiscordClient_MessageUpdated(Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
=> ReceiveIncomingMessage(arg2);
namespace RegexBot.Modules.RegexModerator
{
/// <summary> /// <summary>
/// The 'star' feature of Kerobot. Users define pattern-based rules with other constraints. /// Does initial message checking before further processing.
/// When triggered, each rule executes one or more different actions.
/// </summary> /// </summary>
[RegexbotModule] private async Task ReceiveIncomingMessage(SocketMessage msg) {
public class RegexModerator : ModuleBase if (!Common.Misc.IsValidUserMessage(msg, out var ch)) return;
{
public RegexModerator(RegexbotClient bot) : base(bot) // Get config?
{ var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
DiscordClient.MessageReceived += DiscordClient_MessageReceived; if (defs == null) return;
DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
// Send further processing to thread pool.
// Match checking is a CPU-intensive task, thus very little checking is done here.
var msgProcessingTasks = new List<Task>();
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);
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod)));
} }
await Task.WhenAll(msgProcessingTasks);
}
public override Task<object> CreateGuildStateAsync(ulong guildID, JToken config) /// <summary>
{ /// Does further message checking and response execution.
if (config == null) return Task.FromResult<object>(null); /// Invocations of this method are meant to be placed onto a thread separate from the caller.
var defs = new List<ConfDefinition>(); /// </summary>
private async Task ProcessMessage(ConfDefinition def, SocketGuild g, SocketMessage msg, bool isMod) {
if (!def.IsMatch(msg, isMod)) return;
if (config.Type != JTokenType.Array) // TODO logging options for match result; handle here?
throw new ModuleLoadException(this.Name + " configuration must be a JSON array.");
// TODO better error reporting during this process var executor = new ResponseExecutor(def, Bot, msg, (string logLine) => Log(g, logLine));
foreach (var def in config.Children<JObject>()) await executor.Execute();
defs.Add(new ConfDefinition(def));
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)
{
if (msg.Author.Id == 0)
{
// TODO what changed to cause this? this wasn't happening before.
System.Console.WriteLine($"Skip processing of message with empty metadata. Msg ID {msg.Id} - Msg content: {msg.Content} - Embed count: {msg.Embeds.Count}");
return;
}
// 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.
var msgProcessingTasks = new List<Task>();
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);
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, msg, isMod)));
}
await Task.WhenAll(msgProcessingTasks);
}
/// <summary>
/// Does further message checking and response execution.
/// Invocations of this method are meant to be placed onto a thread separate from the caller.
/// </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, BotClient);
await executor.Execute(msg);
}
} }
} }

View file

@ -1,311 +1,261 @@
using Discord; using Discord;
using Discord.WebSocket;
using RegexBot.Common; using RegexBot.Common;
using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Threading.Tasks;
using static RegexBot.RegexbotClient;
namespace RegexBot.Modules.RegexModerator namespace RegexBot.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 RegexbotClient _bot;
private List<(ResponseAction, ResponseExecutionResult)> _results;
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot) /// <summary>
{ /// Transient helper class which handles response interpreting and execution.
_rule = rule; /// </summary>
_bot = bot; class ResponseExecutor {
} delegate Task<ResponseResult> ResponseHandler(string? parameter);
public async Task Execute(SocketMessage msg) const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue.";
{
var g = ((SocketGuildUser)msg.Author).Guild; private readonly ConfDefinition _rule;
_results = new List<(ResponseAction, ResponseExecutionResult)>(); private readonly RegexbotClient _bot;
var tasks = new List<Task>
{ private readonly SocketGuild _guild;
ExecuteAction(DoReplyToChannel, g, msg), private readonly SocketGuildUser _user;
ExecuteAction(DoReplyToInvokerDM, g, msg), private readonly SocketMessage _msg;
ExecuteAction(DoRoleAdd, g, msg),
ExecuteAction(DoRoleRemove, g, msg), private readonly List<(string, ResponseResult)> _reports;
ExecuteAction(DoBan, g, msg), private Action<string> Log { get; }
ExecuteAction(DoKick, g, msg),
ExecuteAction(DoDelete, g, msg) public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
// TODO role add/remove: add persistence option _rule = rule;
// TODO add note to user log _bot = bot;
// TODO add warning to user log
_msg = msg;
_user = (SocketGuildUser)msg.Author;
_guild = _user.Guild;
_reports = new();
Log = logger;
}
public async Task Execute() {
var reportTarget = _rule.ReportingChannel?.FindChannelIn(_guild, true);
if (_rule.ReportingChannel != null && reportTarget == null)
Log("Could not find target reporting channel.");
foreach (var line in _rule.Response) {
var item = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries & StringSplitOptions.TrimEntries);
var cmd = item[0];
var param = item.Length >= 2 ? item[1] : null;
ResponseHandler runLine = cmd.ToLowerInvariant() switch {
"comment" => CmdComment,
"rem" => CmdComment,
"#" => CmdComment,
"ban" => CmdBan,
"delete" => CmdDelete,
"remove" => CmdDelete,
"kick" => CmdKick,
"note" => CmdNote,
"roleadd" => CmdRoleAdd,
"addrole" => CmdRoleAdd,
"roledel" => CmdRoleDel,
"delrole" => CmdRoleDel,
"say" => CmdSay,
"send" => CmdSay,
"reply" => CmdSay,
"timeout" => CmdTimeout,
"mute" => CmdTimeout,
"warn" => CmdWarn,
_ => delegate (string? p) { return Task.FromResult(FromError($"Unknown command '{cmd}'.")); }
}; };
await Task.WhenAll(tasks);
// Report can only run after all previous actions have been performed. try {
await ExecuteAction(DoReport, g, msg); var result = await runLine(param);
_reports.Add((cmd, result));
// TODO pass any local error messages to guild log } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
} _reports.Add((cmd, FromError(ForbiddenGenericError)));
#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.RemoveAction != RemovalType.Ban) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemoveAction);
}
private Task<ResponseExecutionResult> DoKick(SocketGuild g, SocketMessage msg)
{
if (_rule.RemoveAction != RemovalType.Kick) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemoveAction);
}
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.RemoveReason, _rule.RemoveNotifyTarget);
string logAnnounce = null;
if (_rule.RemoveAnnounce)
{
try
{
await msg.Channel.SendMessageAsync(result.GetResultString(_bot, g.Id));
}
catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
logAnnounce = "Could not send " + (t == RemovalType.Ban ? "ban" : "kick") + " announcement to channel "
+ "due to a permissions issue.";
}
}
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, logAnnounce);
}
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) // Handle reporting
{ if (reportTarget != null) {
if (string.IsNullOrWhiteSpace(_rule.ReplyInChannel)) return null; // Set up report
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(); var rptOutput = new StringBuilder();
foreach (var (action, result) in _results) // Locking of _results not necessary at this point foreach (var (action, result) in _reports) {
{
if (result == null) continue;
rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:"); rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:");
rptOutput.Append(" " + action.Method.Name); rptOutput.Append($" `{action}`");
if (result.Notice != null) if (result.LogLine != null) {
rptOutput.Append(" - " + result.Notice); rptOutput.Append(": ");
rptOutput.Append(result.LogLine);
}
rptOutput.AppendLine(); 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 // We can only afford to show a preview of the message being reported, due to embeds
// being constrained to the same 2000 character limit. // being constrained to the same 2000 character limit as normal messages.
const string TruncateWarning = "**Notice: Full message has been truncated.**\n"; const string TruncateWarning = "**Notice: Full message has been truncated.**\n";
const int TruncateMaxLength = 990; const int TruncateMaxLength = 990;
var invokingLine = msg.Content; var invokingLine = _msg.Content;
if (invokingLine.Length > TruncateMaxLength) if (invokingLine.Length > TruncateMaxLength) {
{ invokingLine = string.Concat(TruncateWarning, invokingLine.AsSpan(0, TruncateMaxLength - TruncateWarning.Length));
invokingLine = TruncateWarning + invokingLine.Substring(0, TruncateMaxLength - TruncateWarning.Length);
} }
var resultEm = new EmbedBuilder() var resultEmbed = new EmbedBuilder()
{ .WithFields(
Color = new Color(0xEDCE00), // TODO configurable later? new EmbedFieldBuilder() {
Name = "Context",
Author = new EmbedAuthorBuilder() Value =
{ $"User: {_user.Mention} `{_user.Id}`\n" +
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:", $"Channel: <#{_msg.Channel.Id}> `#{_msg.Channel.Name}`"
IconUrl = msg.Author.GetAvatarUrl() },
}, new EmbedFieldBuilder() {
Description = invokingLine, Name = "Response status",
Value = rptOutput.ToString()
Footer = new EmbedFooterBuilder() { Text = $"Rule: {_rule.Label}" }, }
Timestamp = msg.EditedTimestamp ?? msg.Timestamp )
}.AddField(new EmbedFieldBuilder() .WithAuthor(
{ name: $"{_msg.Author.Username}#{_msg.Author.Discriminator} said:",
Name = "Actions taken:", iconUrl: _msg.Author.GetAvatarUrl(),
Value = rptOutput.ToString() url: _msg.GetJumpUrl()
}).Build(); )
.WithDescription(invokingLine)
try .WithFooter(
{ text: $"Rule: {_rule.Label}",
await ch.SendMessageAsync(embed: resultEm); iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
return new ResponseExecutionResult(true, null); )
} .WithCurrentTimestamp()
catch (Discord.Net.HttpException ex) .Build();
{ try {
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) await reportTarget.SendMessageAsync(embed: resultEmbed);
{ } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
return new ResponseExecutionResult(false, ForbiddenGenericError); Log("Encountered 403 error when attempting to send report.");
}
else throw;
} }
} }
#endregion
} }
#region Response delegates
private static Task<ResponseResult> CmdComment(string? parameter) => Task.FromResult(FromSuccess(parameter));
private Task<ResponseResult> CmdBan(string? parameter) => CmdBanKick(RemovalType.Ban, parameter);
private Task<ResponseResult> CmdKick(string? parameter) => CmdBanKick(RemovalType.Kick, parameter);
private async Task<ResponseResult> CmdBanKick(RemovalType rt, string? parameter) {
BanKickResult result;
if (rt == RemovalType.Ban) {
result = await _bot.BanAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
_rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval);
} else {
result = await _bot.KickAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
parameter, _rule.NotifyUserOfRemoval);
}
if (result.ErrorForbidden) return FromError(ForbiddenGenericError);
if (result.ErrorNotFound) return FromError("The target user is no longer in the server.");
if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM.");
}
private Task<ResponseResult> CmdRoleAdd(string? parameter) => CmdRoleManipulation(parameter, true);
private Task<ResponseResult> CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false);
private async Task<ResponseResult> CmdRoleManipulation(string? parameter, bool add) {
// parameters: @_, &, reason?
// TODO add persistence option if/when implemented
if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters.");
var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (param.Length < 2) return FromError("Incorrect number of parameters.");
// Find targets
SocketGuildUser? tuser;
SocketRole? trole;
try {
var userName = new EntityName(param[0]);
if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value);
else {
if (userName.Name == "_") tuser = _user;
else tuser = userName.FindUserIn(_guild);
}
if (tuser == null) return FromError($"Unable to find user '{userName.Name}'.");
var roleName = new EntityName(param[1]);
if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value);
else trole = roleName.FindRoleIn(_guild);
if (trole == null) return FromError($"Unable to find role '{roleName.Name}'.");
} catch (ArgumentException) {
return FromError("User or role were not correctly set in configuration.");
}
// Do action
var rq = new RequestOptions() { AuditLogReason = $"Rule '{_rule.Label}'" };
if (param.Length == 3 && !string.IsNullOrWhiteSpace(param[2])) {
rq.AuditLogReason += " - " + param[2];
}
if (add) await tuser.AddRoleAsync(trole, rq);
else await tuser.RemoveRoleAsync(trole, rq);
return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}.");
}
private async Task<ResponseResult> CmdDelete(string? parameter) {
// TODO detailed audit log deletion reason?
if (parameter != null) return FromError("This response does not accept parameters.");
try {
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = $"Rule {_rule.Label}" });
return FromSuccess();
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
return FromError("The message had already been deleted.");
}
}
private async Task<ResponseResult> CmdSay(string? parameter) {
// parameters: [#_/@_] message
if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters.");
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (param.Length != 2) return FromError("Incorrect number of parameters.");
// Get target
IMessageChannel? targetCh;
EntityName name;
try {
name = new EntityName(param[0]);
} catch (ArgumentException) {
return FromError("Reply target was not correctly set in configuration.");
}
bool isUser;
if (name.Type == EntityType.Channel) {
if (name.Name == "_") targetCh = _msg.Channel;
else targetCh = name.FindChannelIn(_guild);
if (targetCh == null) return FromError($"Unable to find channel '{name.Name}'.");
isUser = false;
} else if (name.Type == EntityType.User) {
if (name.Name == "_") targetCh = await _msg.Author.CreateDMChannelAsync();
else {
var searchedUser = name.FindUserIn(_guild);
if (searchedUser == null) return FromError($"Unable to find user '{name.Name}'.");
targetCh = await searchedUser.CreateDMChannelAsync();
}
isUser = true;
} else {
return FromError("Channel or user were not correctly set in configuration.");
}
if (targetCh == null) return FromError("Could not acquire target channel.");
await targetCh.SendMessageAsync(param[1]);
return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}.");
}
private Task<ResponseResult> CmdNote(string? parameter) {
#warning Not implemented
return Task.FromResult(FromError("not implemented"));
}
private Task<ResponseResult> CmdTimeout(string? parameter) {
#warning Not implemented
return Task.FromResult(FromError("not implemented"));
}
private Task<ResponseResult> CmdWarn(string? parameter) {
#warning Not implemented
return Task.FromResult(FromError("not implemented"));
}
#endregion
#region Response reporting
private struct ResponseResult {
public bool Success;
public string? LogLine;
}
private static ResponseResult FromSuccess(string? logLine = null) => new() { Success = true, LogLine = logLine };
private static ResponseResult FromError(string? logLine = null) => new() { Success = false, LogLine = logLine };
#endregion
} }

View file

@ -1,5 +1,6 @@
using Discord; using Discord;
using Discord.WebSocket; using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
namespace RegexBot.Common; namespace RegexBot.Common;
@ -20,4 +21,28 @@ public static class Misc {
channel = ch; channel = ch;
return true; return true;
} }
/// <summary>
/// Given a JToken, gets all string-based values out of it if the token may be a string
/// or an array of strings.
/// </summary>
/// <param name="token">The JSON token to analyze and retrieve strings from.</param>
/// <exception cref="ArgumentException">Thrown if the given token is not a string or array containing all strings.</exception>
/// <exception cref="ArgumentNullException">Thrown if the given token is null.</exception>
public static List<string> LoadStringOrStringArray(JToken? token) {
const string ExNotString = "This token contains a non-string element.";
if (token == null) throw new ArgumentNullException(nameof(token), "The provided token is null.");
var results = new List<string>();
if (token.Type == JTokenType.String) {
results.Add(token.Value<string>()!);
} else if (token.Type == JTokenType.Array) {
foreach (var entry in token.Values()) {
if (entry.Type != JTokenType.String) throw new ArgumentException(ExNotString, nameof(token));
results.Add(entry.Value<string>()!);
}
} else {
throw new ArgumentException(ExNotString, nameof(token));
}
return results;
}
} }