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:
parent
e87a985067
commit
90feeb47b0
3 changed files with 609 additions and 0 deletions
233
Modules-PublicInstance/RegexModerator/ConfDefinition.cs
Normal file
233
Modules-PublicInstance/RegexModerator/ConfDefinition.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
77
Modules-PublicInstance/RegexModerator/RegexModerator.cs
Normal file
77
Modules-PublicInstance/RegexModerator/RegexModerator.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
299
Modules-PublicInstance/RegexModerator/ResponseExecutor.cs
Normal file
299
Modules-PublicInstance/RegexModerator/ResponseExecutor.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue