Rewrite RegexModerator
This commit is contained in:
parent
13324999cc
commit
ffaae04bc6
4 changed files with 425 additions and 575 deletions
|
@ -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>
|
||||||
/// <summary>
|
/// Representation of a single RegexModerator rule for a guild.
|
||||||
/// Representation of a single RegexModerator rule for a guild.
|
/// Data in this class is immutable. Contains various helper methods.
|
||||||
/// Data in this class is immutable. Contains various helper methods.
|
/// </summary>
|
||||||
/// </summary>
|
[DebuggerDisplay("RM rule '{Label}'")]
|
||||||
[DebuggerDisplay("RM rule '{Label}'")]
|
class ConfDefinition {
|
||||||
class ConfDefinition
|
|
||||||
{
|
|
||||||
public string Label { get; }
|
public string Label { get; }
|
||||||
|
|
||||||
// Matching settings
|
// Matching settings
|
||||||
readonly IEnumerable<Regex> _regex;
|
private IEnumerable<Regex> Regex { get; }
|
||||||
readonly FilterList _filter;
|
private FilterList Filter { get; }
|
||||||
readonly bool _ignoreMods;
|
private bool IgnoreMods { get; }
|
||||||
readonly bool _embedScan;
|
private bool ScanEmbeds { get; }
|
||||||
|
|
||||||
// Response settings
|
// Response settings
|
||||||
public string ReplyInChannel { get; }
|
public EntityName? ReportingChannel { get; }
|
||||||
public string ReplyInDM { get; }
|
public IReadOnlyList<string> Response { 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 int BanPurgeDays { get; }
|
||||||
public string RemoveReason { get; } // reason to place into audit log and notification
|
public bool NotifyChannelOfRemoval { get; }
|
||||||
public bool RemoveAnnounce { get; } // send success/failure message in invoking channel? default: true
|
public bool NotifyUserOfRemoval { get; }
|
||||||
public bool RemoveNotifyTarget { get; } // send ban/kick notification to user?
|
|
||||||
public bool DeleteMessage { get; }
|
|
||||||
|
|
||||||
public ConfDefinition(JObject def)
|
public ConfDefinition(JObject def) {
|
||||||
{
|
Label = def[nameof(Label)]?.Value<string>()
|
||||||
Label = def["Label"].Value<string>();
|
?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}.");
|
||||||
if (string.IsNullOrWhiteSpace(Label))
|
|
||||||
throw new ModuleLoadException("A rule does not have a label defined.");
|
|
||||||
|
|
||||||
string errpostfx = $" in the rule definition for '{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
|
// Regex loading
|
||||||
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||||
// TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline.
|
// 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;
|
opts |= RegexOptions.Singleline;
|
||||||
// IgnoreCase is enabled by default; must be explicitly set to false
|
// IgnoreCase is enabled by default; must be explicitly set to false
|
||||||
bool? rxci = def["IgnoreCase"]?.Value<bool>();
|
if (def["IgnoreCase"]?.Value<bool>() ?? true) opts |= RegexOptions.IgnoreCase;
|
||||||
if (rxci ?? true) opts |= RegexOptions.IgnoreCase;
|
const string ErrBadRegex = "Unable to parse regular expression pattern";
|
||||||
|
var regexRules = new List<Regex>();
|
||||||
const string ErrNoRegex = "Regular expression patterns are not defined";
|
List<string> regexStrings;
|
||||||
var rxs = new List<Regex>();
|
try {
|
||||||
var rxconf = def["Regex"];
|
regexStrings = Misc.LoadStringOrStringArray(def[nameof(Regex)]);
|
||||||
if (rxconf == null) throw new ModuleLoadException(ErrNoRegex + errpostfx);
|
} catch (ArgumentNullException) {
|
||||||
if (rxconf.Type == JTokenType.Array)
|
throw new ModuleLoadException($"No patterns were defined under '{nameof(Regex)}'{errpostfx}");
|
||||||
{
|
} catch (ArgumentException) {
|
||||||
foreach (var input in rxconf.Values<string>())
|
throw new ModuleLoadException($"'{nameof(Regex)}' is not properly defined{errpostfx}");
|
||||||
{
|
|
||||||
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)
|
foreach (var input in regexStrings) {
|
||||||
{
|
try {
|
||||||
throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
|
regexRules.Add(new Regex(input, opts));
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
throw new ModuleLoadException($"{ErrBadRegex}{errpostfx}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Regex = regexRules.AsReadOnly();
|
||||||
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
|
// Filtering
|
||||||
_filter = new FilterList(def);
|
Filter = new FilterList(def);
|
||||||
|
|
||||||
// Misc options
|
// Misc options
|
||||||
// IgnoreMods is enabled by default; must be explicitly set to false
|
// IgnoreMods is enabled by default; must be explicitly set to false
|
||||||
bool? bypass = def["IgnoreMods"]?.Value<bool>();
|
IgnoreMods = def[nameof(IgnoreMods)]?.Value<bool>() ?? true;
|
||||||
_ignoreMods = bypass ?? true;
|
ScanEmbeds = def[nameof(ScanEmbeds)]?.Value<bool>() ?? false; // false by default
|
||||||
|
|
||||||
bool? embedScan = def["EmbedScanMode"]?.Value<bool>();
|
// Load response(s) and response settings
|
||||||
_embedScan = embedScan ?? false; // false by default
|
try {
|
||||||
|
Response = Misc.LoadStringOrStringArray(def[nameof(Response)]).AsReadOnly();
|
||||||
// Response options
|
} catch (ArgumentNullException) {
|
||||||
var resp = def["Response"] as JObject;
|
throw new ModuleLoadException($"No responses were defined under '{nameof(Response)}'{errpostfx}");
|
||||||
if (resp == null)
|
} catch (ArgumentException) {
|
||||||
throw new ModuleLoadException("Cannot find a valid response section" + errpostfx);
|
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{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;
|
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
|
||||||
rolestr = resp[nameof(RoleRemove)]?.Value<string>();
|
NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value<bool>() ?? true;
|
||||||
if (!string.IsNullOrWhiteSpace(rolestr))
|
NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value<bool>() ?? true;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the given message to determine if it matches this definition's constraints.
|
/// Checks the given message to determine if it matches this definition's constraints.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>True if match.</returns>
|
/// <returns>True if match.</returns>
|
||||||
public bool IsMatch(SocketMessage m, bool senderIsModerator)
|
public bool IsMatch(SocketMessage m, bool senderIsModerator) {
|
||||||
{
|
if (Filter.IsFiltered(m, false)) return false;
|
||||||
// TODO keep id: true or false?
|
if (senderIsModerator && IgnoreMods) return 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) {
|
||||||
|
if (ScanEmbeds && regex.IsMatch(SerializeEmbed(m.Embeds))) return true;
|
||||||
foreach (var regex in _regex)
|
if (regex.IsMatch(m.Content)) return true;
|
||||||
{
|
|
||||||
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeEmbed(IReadOnlyCollection<Embed> e)
|
private static string SerializeEmbed(IReadOnlyCollection<Embed> e) {
|
||||||
{
|
static string serialize(Embed e) {
|
||||||
var text = new StringBuilder();
|
var result = new StringBuilder();
|
||||||
foreach (var item in e) text.AppendLine(SerializeEmbed(item));
|
if (e.Author.HasValue) result.AppendLine($"{e.Author.Value.Name} {e.Author.Value.Url}");
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,42 @@
|
||||||
using Discord.WebSocket;
|
using Discord;
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace RegexBot.Modules.RegexModerator
|
namespace RegexBot.Modules.RegexModerator;
|
||||||
{
|
/// <summary>
|
||||||
/// <summary>
|
/// The namesake of RegexBot. This module allows users to define pattern-based rules with other constraints.
|
||||||
/// The 'star' feature of Kerobot. Users define pattern-based rules with other constraints.
|
/// When triggered, one or more actions are executed as defined in its configuration.
|
||||||
/// When triggered, each rule executes one or more different actions.
|
/// </summary>
|
||||||
/// </summary>
|
[RegexbotModule]
|
||||||
[RegexbotModule]
|
public class RegexModerator : RegexbotModule {
|
||||||
public class RegexModerator : ModuleBase
|
public RegexModerator(RegexbotClient bot) : base(bot) {
|
||||||
{
|
|
||||||
public RegexModerator(RegexbotClient bot) : base(bot)
|
|
||||||
{
|
|
||||||
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
||||||
DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
|
DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<object> CreateGuildStateAsync(ulong guildID, JToken config)
|
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
|
||||||
{
|
if (config == null) return Task.FromResult<object?>(null);
|
||||||
if (config == null) return Task.FromResult<object>(null);
|
|
||||||
var defs = new List<ConfDefinition>();
|
var defs = new List<ConfDefinition>();
|
||||||
|
|
||||||
if (config.Type != JTokenType.Array)
|
if (config.Type != JTokenType.Array)
|
||||||
throw new ModuleLoadException(this.Name + " configuration must be a JSON array.");
|
throw new ModuleLoadException(Name + " configuration must be a JSON array.");
|
||||||
|
|
||||||
// TODO better error reporting during this process
|
// TODO better error reporting during this process
|
||||||
foreach (var def in config.Children<JObject>())
|
foreach (var def in config.Children<JObject>())
|
||||||
defs.Add(new ConfDefinition(def));
|
defs.Add(new ConfDefinition(def));
|
||||||
|
|
||||||
if (defs.Count == 0) return Task.FromResult<object>(null);
|
if (defs.Count == 0) return Task.FromResult<object?>(null);
|
||||||
return Task.FromResult<object>(defs.AsReadOnly());
|
Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s).");
|
||||||
|
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);
|
private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg);
|
||||||
|
private Task DiscordClient_MessageUpdated(Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
||||||
|
=> ReceiveIncomingMessage(arg2);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Does initial message checking before further processing.
|
/// Does initial message checking before further processing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ReceiveIncomingMessage(SocketMessage msg)
|
private async Task ReceiveIncomingMessage(SocketMessage msg) {
|
||||||
{
|
if (!Common.Misc.IsValidUserMessage(msg, out var ch)) return;
|
||||||
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?
|
// Get config?
|
||||||
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
|
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
|
||||||
|
@ -61,13 +45,12 @@ namespace RegexBot.Modules.RegexModerator
|
||||||
// Send further processing to thread pool.
|
// Send further processing to thread pool.
|
||||||
// Match checking is a CPU-intensive task, thus very little checking is done here.
|
// Match checking is a CPU-intensive task, thus very little checking is done here.
|
||||||
var msgProcessingTasks = new List<Task>();
|
var msgProcessingTasks = new List<Task>();
|
||||||
foreach (var item in defs)
|
foreach (var item in defs) {
|
||||||
{
|
|
||||||
// Need to check sender's moderator status here. Definition can't access mod list.
|
// Need to check sender's moderator status here. Definition can't access mod list.
|
||||||
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
|
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
|
||||||
|
|
||||||
var match = item.IsMatch(msg, isMod);
|
var match = item.IsMatch(msg, isMod);
|
||||||
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, msg, isMod)));
|
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod)));
|
||||||
}
|
}
|
||||||
await Task.WhenAll(msgProcessingTasks);
|
await Task.WhenAll(msgProcessingTasks);
|
||||||
}
|
}
|
||||||
|
@ -76,15 +59,12 @@ namespace RegexBot.Modules.RegexModerator
|
||||||
/// Does further message checking and response execution.
|
/// Does further message checking and response execution.
|
||||||
/// Invocations of this method are meant to be placed onto a thread separate from the caller.
|
/// Invocations of this method are meant to be placed onto a thread separate from the caller.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ProcessMessage(ConfDefinition def, SocketMessage msg, bool isMod)
|
private async Task ProcessMessage(ConfDefinition def, SocketGuild g, SocketMessage msg, bool isMod) {
|
||||||
{
|
|
||||||
// Reminder: IsMatch handles matching execution time
|
|
||||||
if (!def.IsMatch(msg, isMod)) return;
|
if (!def.IsMatch(msg, isMod)) return;
|
||||||
|
|
||||||
// TODO logging options for match result; handle here?
|
// TODO logging options for match result; handle here?
|
||||||
|
|
||||||
var executor = new ResponseExecutor(def, BotClient);
|
var executor = new ResponseExecutor(def, Bot, msg, (string logLine) => Log(g, logLine));
|
||||||
await executor.Execute(msg);
|
await executor.Execute();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
{
|
|
||||||
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.";
|
const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue.";
|
||||||
|
|
||||||
private Task<ResponseExecutionResult> DoBan(SocketGuild g, SocketMessage msg)
|
private readonly ConfDefinition _rule;
|
||||||
{
|
private readonly RegexbotClient _bot;
|
||||||
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;
|
private readonly SocketGuild _guild;
|
||||||
if (_rule.RemoveAnnounce)
|
private readonly SocketGuildUser _user;
|
||||||
{
|
private readonly SocketMessage _msg;
|
||||||
try
|
|
||||||
{
|
private readonly List<(string, ResponseResult)> _reports;
|
||||||
await msg.Channel.SendMessageAsync(result.GetResultString(_bot, g.Id));
|
private Action<string> Log { get; }
|
||||||
|
|
||||||
|
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
|
||||||
|
_rule = rule;
|
||||||
|
_bot = bot;
|
||||||
|
|
||||||
|
_msg = msg;
|
||||||
|
_user = (SocketGuildUser)msg.Author;
|
||||||
|
_guild = _user.Guild;
|
||||||
|
|
||||||
|
_reports = new();
|
||||||
|
Log = logger;
|
||||||
}
|
}
|
||||||
catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
|
||||||
{
|
public async Task Execute() {
|
||||||
logAnnounce = "Could not send " + (t == RemovalType.Ban ? "ban" : "kick") + " announcement to channel "
|
var reportTarget = _rule.ReportingChannel?.FindChannelIn(_guild, true);
|
||||||
+ "due to a permissions issue.";
|
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}'.")); }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = await runLine(param);
|
||||||
|
_reports.Add((cmd, result));
|
||||||
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
|
||||||
|
_reports.Add((cmd, FromError(ForbiddenGenericError)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.ErrorForbidden)
|
// Handle reporting
|
||||||
{
|
if (reportTarget != null) {
|
||||||
return new ResponseExecutionResult(false, ForbiddenGenericError);
|
// Set up report
|
||||||
}
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
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();
|
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()
|
|
||||||
},
|
},
|
||||||
Description = invokingLine,
|
new EmbedFieldBuilder() {
|
||||||
|
Name = "Response status",
|
||||||
Footer = new EmbedFooterBuilder() { Text = $"Rule: {_rule.Label}" },
|
|
||||||
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
|
|
||||||
}.AddField(new EmbedFieldBuilder()
|
|
||||||
{
|
|
||||||
Name = "Actions taken:",
|
|
||||||
Value = rptOutput.ToString()
|
Value = rptOutput.ToString()
|
||||||
}).Build();
|
}
|
||||||
|
)
|
||||||
|
.WithAuthor(
|
||||||
|
name: $"{_msg.Author.Username}#{_msg.Author.Discriminator} said:",
|
||||||
|
iconUrl: _msg.Author.GetAvatarUrl(),
|
||||||
|
url: _msg.GetJumpUrl()
|
||||||
|
)
|
||||||
|
.WithDescription(invokingLine)
|
||||||
|
.WithFooter(
|
||||||
|
text: $"Rule: {_rule.Label}",
|
||||||
|
iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
|
||||||
|
)
|
||||||
|
.WithCurrentTimestamp()
|
||||||
|
.Build();
|
||||||
|
try {
|
||||||
|
await reportTarget.SendMessageAsync(embed: resultEmbed);
|
||||||
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
|
||||||
|
Log("Encountered 403 error when attempting to send report.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
#region Response delegates
|
||||||
{
|
private static Task<ResponseResult> CmdComment(string? parameter) => Task.FromResult(FromSuccess(parameter));
|
||||||
await ch.SendMessageAsync(embed: resultEm);
|
|
||||||
return new ResponseExecutionResult(true, null);
|
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);
|
||||||
}
|
}
|
||||||
catch (Discord.Net.HttpException ex)
|
if (result.ErrorForbidden) return FromError(ForbiddenGenericError);
|
||||||
{
|
if (result.ErrorNotFound) return FromError("The target user is no longer in the server.");
|
||||||
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
|
||||||
{
|
return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM.");
|
||||||
return new ResponseExecutionResult(false, ForbiddenGenericError);
|
|
||||||
}
|
}
|
||||||
else throw;
|
|
||||||
|
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
|
#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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue