Fully implemented AutoMod
This commit is contained in:
parent
0f3fd350fa
commit
3c88bce94a
11 changed files with 804 additions and 12 deletions
|
@ -18,6 +18,7 @@ namespace Noikoio.RegexBot
|
||||||
private readonly AsyncLogger _logger;
|
private readonly AsyncLogger _logger;
|
||||||
|
|
||||||
public abstract string Name { get; }
|
public abstract string Name { get; }
|
||||||
|
protected DiscordSocketClient Client => _client;
|
||||||
|
|
||||||
protected BotFeature(DiscordSocketClient client)
|
protected BotFeature(DiscordSocketClient client)
|
||||||
{
|
{
|
||||||
|
@ -61,6 +62,20 @@ namespace Noikoio.RegexBot
|
||||||
else return null;
|
else return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if the given message author or channel is in the server configuration's moderator list.
|
||||||
|
/// </summary>
|
||||||
|
protected bool IsModerator(ulong guildId, SocketMessage m)
|
||||||
|
{
|
||||||
|
var sc = RegexBot.Config.Servers.FirstOrDefault(g => g.Id == guildId);
|
||||||
|
if (sc == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("There is no known configuration associated with the given Guild ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sc.Moderators.ExistsInList(m);
|
||||||
|
}
|
||||||
|
|
||||||
protected async Task Log(string text)
|
protected async Task Log(string text)
|
||||||
{
|
{
|
||||||
await _logger(text);
|
await _logger(text);
|
||||||
|
|
|
@ -1,31 +1,96 @@
|
||||||
using System;
|
using Discord.WebSocket;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Discord.WebSocket;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Noikoio.RegexBot.Feature.AutoMod.Responses;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Noikoio.RegexBot.Feature.AutoMod
|
namespace Noikoio.RegexBot.Feature.AutoMod
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implements per-message regex matching and executes customizable responses.
|
/// Implements per-message regex matching and executes customizable responses.
|
||||||
/// The name RegexBot comes from the existence of this feature.
|
/// The name RegexBot comes from the existence of this feature.
|
||||||
///
|
|
||||||
/// Strictly for use as a moderation tool only. Triggers that respond only to messages
|
|
||||||
/// should be configured using <see cref="AutoRespond"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Strictly for use as a moderation tool only. Triggers that simply reply to messages
|
||||||
|
/// should be implemented using <see cref="AutoRespond"/>.
|
||||||
|
/// </remarks>
|
||||||
class AutoMod : BotFeature
|
class AutoMod : BotFeature
|
||||||
{
|
{
|
||||||
public override string Name => "AutoMod";
|
public override string Name => "AutoMod";
|
||||||
|
|
||||||
public AutoMod(DiscordSocketClient client) : base(client)
|
public AutoMod(DiscordSocketClient client) : base(client)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
client.MessageReceived += CMessageReceived;
|
||||||
|
client.MessageUpdated += CMessageUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<object> ProcessConfiguration(JToken configSection)
|
[ConfigSection("automod")]
|
||||||
|
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
List<Rule> rules = new List<Rule>();
|
||||||
|
foreach (JObject ruleconf in configSection)
|
||||||
|
{
|
||||||
|
var rule = new Rule(this, ruleconf);
|
||||||
|
rules.Add(rule);
|
||||||
|
await Log($"Added rule '{rule.Label}'");
|
||||||
|
}
|
||||||
|
return rules.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CMessageReceived(SocketMessage arg)
|
||||||
|
=> await ReceiveMessage(arg);
|
||||||
|
private async Task CMessageUpdated(Discord.Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
||||||
|
=> await ReceiveMessage(arg2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does initial message checking before sending to further processing.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReceiveMessage(SocketMessage m)
|
||||||
|
{
|
||||||
|
// Determine if incoming channel is in a guild
|
||||||
|
var ch = m.Channel as SocketGuildChannel;
|
||||||
|
if (ch == null) return;
|
||||||
|
|
||||||
|
// Get rules
|
||||||
|
var rules = GetConfig(ch.Guild.Id) as IEnumerable<Rule>;
|
||||||
|
if (rules == null) return;
|
||||||
|
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
// Checking for mod bypass here (Rule doesn't have access to mod list)
|
||||||
|
bool isMod = IsModerator(ch.Guild.Id, m);
|
||||||
|
//await Task.Run(async () => await ProcessMessage(m, rule, isMod));
|
||||||
|
await ProcessMessage(m, rule, isMod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the incoming message matches the given rule, and executes responses if necessary.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessMessage(SocketMessage m, Rule r, bool isMod)
|
||||||
|
{
|
||||||
|
if (!r.Match(m, isMod)) return;
|
||||||
|
|
||||||
|
// TODO make log optional; configurable
|
||||||
|
await Log($"{r} triggered by {m.Author.ToString()}");
|
||||||
|
|
||||||
|
foreach (Response resp in r.Response)
|
||||||
|
{
|
||||||
|
// TODO foreach await (when that becomes available)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await resp.Invoke(m);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Log($"Encountered an error while processing '{resp.CmdArg0}'. Details follow:");
|
||||||
|
await Log(ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public new Task Log(string text) => base.Log(text);
|
||||||
|
public new DiscordSocketClient Client => base.Client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
50
Feature/AutoMod/Responses/Ban.cs
Normal file
50
Feature/AutoMod/Responses/Ban.cs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bans the invoking user.
|
||||||
|
/// Parameters: ban [days = 0]
|
||||||
|
/// </summary>
|
||||||
|
class Ban : Response
|
||||||
|
{
|
||||||
|
readonly int _purgeDays;
|
||||||
|
|
||||||
|
public Ban(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length == 1)
|
||||||
|
{
|
||||||
|
_purgeDays = 0;
|
||||||
|
}
|
||||||
|
else if (line.Length == 2)
|
||||||
|
{
|
||||||
|
if (int.TryParse(line[1], out _purgeDays))
|
||||||
|
{
|
||||||
|
if (_purgeDays < 0 || _purgeDays > 7)
|
||||||
|
{
|
||||||
|
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new RuleImportException("Incorrect number of parameters.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Invoke(SocketMessage msg)
|
||||||
|
{
|
||||||
|
var target = (SocketGuildUser)msg.Author;
|
||||||
|
await target.Guild.AddBanAsync(target, _purgeDays, Uri.EscapeDataString($"Rule '{Rule.Label}'"));
|
||||||
|
// TODO remove string escaping when fixed in library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
Feature/AutoMod/Responses/Kick.cs
Normal file
28
Feature/AutoMod/Responses/Kick.cs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Kicks the invoking user.
|
||||||
|
/// Takes no parameters.
|
||||||
|
/// </summary>
|
||||||
|
class Kick : Response
|
||||||
|
{
|
||||||
|
public Kick(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
// Throw exception if extra parameters found
|
||||||
|
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
|
||||||
|
throw new RuleImportException("Incorrect number of parameters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Invoke(SocketMessage msg)
|
||||||
|
{
|
||||||
|
var target = (SocketGuildUser)msg.Author;
|
||||||
|
await target.KickAsync(Uri.EscapeDataString($"Rule '{Rule.Label}'"));
|
||||||
|
// TODO remove string escaping when fixed in library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
Feature/AutoMod/Responses/Remove.cs
Normal file
23
Feature/AutoMod/Responses/Remove.cs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the invoking message.
|
||||||
|
/// Takes no parameters.
|
||||||
|
/// </summary>
|
||||||
|
class Remove : Response
|
||||||
|
{
|
||||||
|
public Remove(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
// Throw exception if extra parameters found
|
||||||
|
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
|
||||||
|
throw new RuleImportException("Incorrect number of parameters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task Invoke(SocketMessage msg) => msg.DeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
93
Feature/AutoMod/Responses/Report.cs
Normal file
93
Feature/AutoMod/Responses/Report.cs
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a summary of the invoking message, along with information
|
||||||
|
/// about the rule making use of this command, to the given target.
|
||||||
|
/// Parameters: report (target)
|
||||||
|
/// </summary>
|
||||||
|
class Report : Response
|
||||||
|
{
|
||||||
|
readonly string _target;
|
||||||
|
|
||||||
|
public Report(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length != 2) throw new RuleImportException("Incorrect number of parameters");
|
||||||
|
_target = line[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Invoke(SocketMessage msg)
|
||||||
|
{
|
||||||
|
var target = await GetMessageTargetAsync(_target, msg);
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
await Log("Error: Unable to resolve the given target.");
|
||||||
|
}
|
||||||
|
await target.SendMessageAsync("", embed: BuildReportEmbed(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EmbedBuilder BuildReportEmbed(SocketMessage msg)
|
||||||
|
{
|
||||||
|
string invokeLine = msg.Content;
|
||||||
|
|
||||||
|
var responsebody = new StringBuilder();
|
||||||
|
responsebody.AppendLine("```");
|
||||||
|
foreach (var item in Rule.Response)
|
||||||
|
{
|
||||||
|
responsebody.AppendLine(item.CmdLine.Replace("\r", "").Replace("\n", "\\n"));
|
||||||
|
}
|
||||||
|
responsebody.Append("```");
|
||||||
|
|
||||||
|
// Discord has a 2000 character limit per single message.
|
||||||
|
// Enforcing separate length limits on line and response.
|
||||||
|
const int DescriptionLengthMax = 1600;
|
||||||
|
const int ResponseBodyLengthMax = 200;
|
||||||
|
if (invokeLine.Length > DescriptionLengthMax)
|
||||||
|
{
|
||||||
|
invokeLine = $"**Message length too long; showing first {DescriptionLengthMax} characters.**\n\n"
|
||||||
|
+ invokeLine.Substring(0, DescriptionLengthMax);
|
||||||
|
}
|
||||||
|
if (responsebody.Length > ResponseBodyLengthMax)
|
||||||
|
{
|
||||||
|
responsebody = new StringBuilder("(Response body too large to display.)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
{
|
||||||
|
Color = new Color(0xEDCE00), // configurable later?
|
||||||
|
|
||||||
|
Author = new EmbedAuthorBuilder()
|
||||||
|
{
|
||||||
|
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
|
||||||
|
IconUrl = msg.Author.GetAvatarUrl()
|
||||||
|
},
|
||||||
|
Description = invokeLine,
|
||||||
|
|
||||||
|
Footer = new EmbedFooterBuilder()
|
||||||
|
{
|
||||||
|
Text = $"Rule '{Rule.Label}'",
|
||||||
|
IconUrl = Rule.Discord.CurrentUser.GetAvatarUrl()
|
||||||
|
},
|
||||||
|
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
|
||||||
|
}.AddField(new EmbedFieldBuilder()
|
||||||
|
{
|
||||||
|
Name = "Additional info",
|
||||||
|
Value = $"Username: {msg.Author.Mention}\n"
|
||||||
|
+ $"Channel: <#{msg.Channel.Id}> #{msg.Channel.Name} ({msg.Channel.Id})\n"
|
||||||
|
+ $"Message ID: {msg.Id}"
|
||||||
|
}).AddField(new EmbedFieldBuilder()
|
||||||
|
{
|
||||||
|
// TODO consider replacing with configurable note. this section is a bit too much
|
||||||
|
Name = "Executing response:",
|
||||||
|
Value = responsebody.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
171
Feature/AutoMod/Responses/Response.cs
Normal file
171
Feature/AutoMod/Responses/Response.cs
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all Response classes.
|
||||||
|
/// Contains helper methods for use by response code.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("Response: {_cmdline}")]
|
||||||
|
abstract class Response
|
||||||
|
{
|
||||||
|
private readonly Rule _rule;
|
||||||
|
private readonly string _cmdline;
|
||||||
|
|
||||||
|
protected Rule Rule => _rule;
|
||||||
|
private DiscordSocketClient Client => _rule.Discord;
|
||||||
|
public string CmdLine => _cmdline;
|
||||||
|
public string CmdArg0 {
|
||||||
|
get {
|
||||||
|
int i = _cmdline.IndexOf(' ');
|
||||||
|
if (i != -1) return _cmdline.Substring(0, i);
|
||||||
|
return _cmdline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deriving constructor should do validation of incoming <paramref name="cmdline"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Response(Rule rule, string cmdline)
|
||||||
|
{
|
||||||
|
_rule = rule;
|
||||||
|
_cmdline = cmdline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Task Invoke(SocketMessage msg);
|
||||||
|
|
||||||
|
protected async Task Log(string text)
|
||||||
|
{
|
||||||
|
int dl = _cmdline.IndexOf(' ');
|
||||||
|
var prefix = _cmdline.Substring(0, dl);
|
||||||
|
await Rule.Logger(prefix + ": " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Config loading
|
||||||
|
private static readonly ReadOnlyDictionary<string, Type> _commands =
|
||||||
|
new ReadOnlyDictionary<string, Type>(
|
||||||
|
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
// Define all accepted commands and their corresponding types here
|
||||||
|
{ "say", typeof(Say) },
|
||||||
|
{ "send", typeof(Say) },
|
||||||
|
{ "report", typeof(Report) },
|
||||||
|
{ "addrole", typeof(RoleManipulation) },
|
||||||
|
{ "grantrole", typeof(RoleManipulation) },
|
||||||
|
{ "delrole", typeof(RoleManipulation) },
|
||||||
|
{ "removerole", typeof(RoleManipulation) },
|
||||||
|
{ "revokerole", typeof(RoleManipulation) },
|
||||||
|
{ "delete", typeof(Remove) },
|
||||||
|
{ "remove", typeof(Remove) },
|
||||||
|
{ "kick", typeof(Kick) },
|
||||||
|
{ "ban", typeof(Ban) }
|
||||||
|
});
|
||||||
|
|
||||||
|
public static Response[] ReadConfiguration(Rule r, IEnumerable<string> responses)
|
||||||
|
{
|
||||||
|
var result = new List<Response>();
|
||||||
|
foreach (var line in responses)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
throw new RuleImportException("Empty response line");
|
||||||
|
int i = line.IndexOf(' ');
|
||||||
|
string basecmd;
|
||||||
|
if (i != -1) basecmd = line.Substring(0, i);
|
||||||
|
else basecmd = line;
|
||||||
|
|
||||||
|
Type rt;
|
||||||
|
if (!_commands.TryGetValue(basecmd, out rt))
|
||||||
|
throw new RuleImportException($"'{basecmd}' is not a valid response");
|
||||||
|
|
||||||
|
var newresponse = Activator.CreateInstance(rt, r, line) as Response;
|
||||||
|
if (newresponse == null)
|
||||||
|
throw new Exception("An unknown error occurred when attempting to create a new Response object.");
|
||||||
|
result.Add(newresponse);
|
||||||
|
}
|
||||||
|
return result.ToArray();
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper methods
|
||||||
|
/// <summary>
|
||||||
|
/// Receives a string (beginning with @ or #) and returns an object
|
||||||
|
/// suitable for sending out messages
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<IMessageChannel> GetMessageTargetAsync(string targetName, SocketMessage m)
|
||||||
|
{
|
||||||
|
const string AEShort = "Target name is too short.";
|
||||||
|
|
||||||
|
EntityType et;
|
||||||
|
if (targetName.Length <= 1) throw new ArgumentException(AEShort);
|
||||||
|
|
||||||
|
if (targetName[0] == '#') et = EntityType.Channel;
|
||||||
|
else if (targetName[0] == '@') et = EntityType.User;
|
||||||
|
else throw new ArgumentException("Target is not specified to be either a channel or user.");
|
||||||
|
|
||||||
|
targetName = targetName.Substring(1);
|
||||||
|
if (targetName == "_")
|
||||||
|
{
|
||||||
|
if (et == EntityType.Channel) return m.Channel;
|
||||||
|
else return await m.Author.GetOrCreateDMChannelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
EntityName ei = new EntityName(targetName, et);
|
||||||
|
SocketGuild g = ((SocketGuildUser)m.Author).Guild;
|
||||||
|
|
||||||
|
if (et == EntityType.Channel)
|
||||||
|
{
|
||||||
|
if (targetName.Length < 2 || targetName.Length > 100)
|
||||||
|
throw new ArgumentException(AEShort);
|
||||||
|
|
||||||
|
foreach (var ch in g.TextChannels)
|
||||||
|
{
|
||||||
|
if (ei.Id.HasValue)
|
||||||
|
{
|
||||||
|
if (ei.Id.Value == ch.Id) return ch;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (string.Equals(ei.Name, ch.Name, StringComparison.OrdinalIgnoreCase)) return ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (ei.Id.HasValue)
|
||||||
|
{
|
||||||
|
// The easy way
|
||||||
|
return await Client.GetUser(ei.Id.Value).GetOrCreateDMChannelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hard way
|
||||||
|
foreach (var u in g.Users)
|
||||||
|
{
|
||||||
|
if (string.Equals(ei.Name, u.Username, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(ei.Name, u.Nickname, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return await u.GetOrCreateDMChannelAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected string ProcessText(string input, SocketMessage m)
|
||||||
|
{
|
||||||
|
// Maybe in the future this will do more.
|
||||||
|
// For now, replaces all instances of @_ with the message sender.
|
||||||
|
return input
|
||||||
|
.Replace("@_", m.Author.Mention)
|
||||||
|
.Replace("@\\_", "@_");
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
89
Feature/AutoMod/Responses/RoleManipulation.cs
Normal file
89
Feature/AutoMod/Responses/RoleManipulation.cs
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manipulates a given user's role.
|
||||||
|
/// Parameters: (command) (target) (role ID)
|
||||||
|
/// </summary>
|
||||||
|
class RoleManipulation : Response
|
||||||
|
{
|
||||||
|
enum ManipulationType { None, Add, Remove }
|
||||||
|
|
||||||
|
readonly ManipulationType _action;
|
||||||
|
readonly string _target;
|
||||||
|
readonly EntityName _role;
|
||||||
|
|
||||||
|
public RoleManipulation(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length != 3)
|
||||||
|
throw new RuleImportException("Incorrect number of parameters.");
|
||||||
|
|
||||||
|
// Ensure the strings here match those in Response._commands
|
||||||
|
switch (line[0].ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "addrole":
|
||||||
|
case "grantrole":
|
||||||
|
_action = ManipulationType.Add;
|
||||||
|
break;
|
||||||
|
case "delrole":
|
||||||
|
case "removerole":
|
||||||
|
case "revokerole":
|
||||||
|
_action = ManipulationType.Remove;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_action = ManipulationType.None;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (_action == ManipulationType.None)
|
||||||
|
throw new RuleImportException("Command not defined. This is a bug.");
|
||||||
|
|
||||||
|
_target = line[1];
|
||||||
|
_role = new EntityName(line[2], EntityType.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Invoke(SocketMessage msg)
|
||||||
|
{
|
||||||
|
// Find role
|
||||||
|
SocketRole rtarget;
|
||||||
|
var g = ((SocketGuildUser)msg.Author).Guild;
|
||||||
|
if (_role.Id.HasValue) rtarget = g.GetRole(_role.Id.Value);
|
||||||
|
else rtarget = g.Roles.FirstOrDefault(r =>
|
||||||
|
string.Equals(r.Name, _role.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (rtarget == null)
|
||||||
|
{
|
||||||
|
await Log("Error: Target role not found in server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
SocketGuildUser utarget;
|
||||||
|
if (_target == "@_") utarget = (SocketGuildUser)msg.Author;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
utarget = g.Users.FirstOrDefault(u =>
|
||||||
|
{
|
||||||
|
if (string.Equals(u.Nickname, _target, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
if (string.Equals(u.Username, _target, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (utarget == null)
|
||||||
|
{
|
||||||
|
await Log("Error: Target user not found in server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do action
|
||||||
|
if (_action == ManipulationType.Add)
|
||||||
|
await utarget.AddRoleAsync(rtarget);
|
||||||
|
else if (_action == ManipulationType.Remove)
|
||||||
|
await utarget.RemoveRoleAsync(rtarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
Feature/AutoMod/Responses/Say.cs
Normal file
45
Feature/AutoMod/Responses/Say.cs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a message to the given target.
|
||||||
|
/// Parameters: say (target) (message)
|
||||||
|
/// </summary>
|
||||||
|
class Say : Response
|
||||||
|
{
|
||||||
|
private readonly string _target;
|
||||||
|
private readonly string _payload;
|
||||||
|
|
||||||
|
public Say(Rule rule, string cmdline) : base(rule, cmdline)
|
||||||
|
{
|
||||||
|
var line = cmdline.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length != 3) throw new RuleImportException("Incorrect number of parameters.");
|
||||||
|
|
||||||
|
// Very basic target verification. Could be improved?
|
||||||
|
if (line[1][0] != '@' && line[1][0] != '#')
|
||||||
|
throw new RuleImportException("The given target is not valid.");
|
||||||
|
_target = line[1];
|
||||||
|
|
||||||
|
_payload = line[2];
|
||||||
|
if (string.IsNullOrWhiteSpace(_payload))
|
||||||
|
throw new RuleImportException("Message parameter is blank or missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Invoke(SocketMessage msg)
|
||||||
|
{
|
||||||
|
// CHANGE THE SAY
|
||||||
|
string reply = ProcessText(_payload, msg);
|
||||||
|
|
||||||
|
var target = await GetMessageTargetAsync(_target, msg);
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
await Log("Error: Unable to resolve the given target.");
|
||||||
|
}
|
||||||
|
await target.SendMessageAsync(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
212
Feature/AutoMod/Rule.cs
Normal file
212
Feature/AutoMod/Rule.cs
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Noikoio.RegexBot.ConfigItem;
|
||||||
|
using Noikoio.RegexBot.Feature.AutoMod.Responses;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.Feature.AutoMod
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Representation of a single AutoMod rule.
|
||||||
|
/// Data stored within cannot be edited.
|
||||||
|
/// </summary>
|
||||||
|
class Rule
|
||||||
|
{
|
||||||
|
readonly AutoMod _instance;
|
||||||
|
readonly string _label;
|
||||||
|
readonly IEnumerable<Regex> _regex;
|
||||||
|
readonly ICollection<Response> _responses;
|
||||||
|
readonly FilterList _filter;
|
||||||
|
readonly int _msgMinLength;
|
||||||
|
readonly int _msgMaxLength;
|
||||||
|
readonly bool _modBypass;
|
||||||
|
readonly bool _embedMode;
|
||||||
|
|
||||||
|
public string Label => _label;
|
||||||
|
public IEnumerable<Regex> Regex => _regex;
|
||||||
|
public ICollection<Response> Response => _responses;
|
||||||
|
public FilterList Filter => _filter;
|
||||||
|
public (int?, int?) MatchLengthMinMaxLimit => (_msgMinLength, _msgMaxLength);
|
||||||
|
public bool AllowsModBypass => _modBypass;
|
||||||
|
public bool MatchEmbed => _embedMode;
|
||||||
|
|
||||||
|
public DiscordSocketClient Discord => _instance.Client;
|
||||||
|
public Func<string, Task> Logger => _instance.Log;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new Rule instance to represent the given configuration.
|
||||||
|
/// </summary>
|
||||||
|
public Rule(AutoMod instance, JObject ruleconf)
|
||||||
|
{
|
||||||
|
_instance = instance;
|
||||||
|
|
||||||
|
_label = ruleconf["label"]?.Value<string>();
|
||||||
|
if (string.IsNullOrEmpty(_label))
|
||||||
|
throw new RuleImportException("Label not defined.");
|
||||||
|
|
||||||
|
string errpfx = $" in definition for rule '{_label}'.";
|
||||||
|
|
||||||
|
// regex options
|
||||||
|
RegexOptions opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||||
|
// TODO consider adding an option to specify Singleline and Multiline matching
|
||||||
|
opts |= RegexOptions.Singleline;
|
||||||
|
// case sensitivity must be explicitly defined, else not case sensitive by default
|
||||||
|
bool? regexci = ruleconf["ignorecase"]?.Value<bool>();
|
||||||
|
opts |= RegexOptions.IgnoreCase;
|
||||||
|
if (regexci.HasValue && regexci.Value == false)
|
||||||
|
opts &= ~RegexOptions.IgnoreCase;
|
||||||
|
|
||||||
|
// regex
|
||||||
|
const string NoRegexError = "No regular expression patterns are defined";
|
||||||
|
var regexes = new List<Regex>();
|
||||||
|
var rxconf = ruleconf["regex"];
|
||||||
|
if (rxconf == null) throw new RuleImportException(NoRegexError + errpfx);
|
||||||
|
if (rxconf.Type == JTokenType.Array)
|
||||||
|
{
|
||||||
|
foreach (var input in rxconf.Values<string>())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Regex r = new Regex(input, opts);
|
||||||
|
regexes.Add(r);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(
|
||||||
|
$"Failed to parse regular expression pattern '{input}'{errpfx}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string rxstr = rxconf.Value<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Regex r = new Regex(rxstr, opts);
|
||||||
|
regexes.Add(r);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(
|
||||||
|
$"Failed to parse regular expression pattern '{rxstr}'{errpfx}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (regexes.Count == 0)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(NoRegexError + errpfx);
|
||||||
|
}
|
||||||
|
_regex = regexes.ToArray();
|
||||||
|
|
||||||
|
// min/max length
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_msgMinLength = ruleconf["min"]?.Value<int>() ?? -1;
|
||||||
|
_msgMaxLength = ruleconf["max"]?.Value<int>() ?? -1;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new RuleImportException("Minimum/maximum values must be an integer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// responses
|
||||||
|
const string NoResponseError = "No responses have been defined";
|
||||||
|
var responsestrs = new List<string>();
|
||||||
|
var rsconf = ruleconf["response"];
|
||||||
|
if (rsconf == null) throw new RuleImportException(NoResponseError + errpfx);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (rsconf.Type == JTokenType.Array)
|
||||||
|
{
|
||||||
|
_responses = Responses.Response.ReadConfiguration(this, rsconf.Values<string>());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_responses = Responses.Response.ReadConfiguration(this, new string[] { rsconf.Value<string>() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RuleImportException ex)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(ex.Message + errpfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// whitelist/blacklist filtering
|
||||||
|
_filter = new FilterList(ruleconf);
|
||||||
|
|
||||||
|
// moderator bypass toggle - true by default, must be explicitly set to false
|
||||||
|
bool? bypass = ruleconf["AllowModBypass"]?.Value<bool>();
|
||||||
|
_modBypass = bypass.HasValue ? bypass.Value : true;
|
||||||
|
|
||||||
|
// embed matching mode
|
||||||
|
bool? embed = ruleconf["MatchEmbeds"]?.Value<bool>();
|
||||||
|
_embedMode = (embed.HasValue && embed == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks given message to see if it matches this rule's constraints.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>If true, the rule's response(s) should be executed.</returns>
|
||||||
|
public bool Match(SocketMessage m, bool isMod)
|
||||||
|
{
|
||||||
|
// Regular or embed mode?
|
||||||
|
string msgcontent;
|
||||||
|
if (MatchEmbed) msgcontent = SerializeEmbed(m.Embeds);
|
||||||
|
else msgcontent = m.Content;
|
||||||
|
|
||||||
|
// Min/max length check
|
||||||
|
if (_msgMinLength != -1 && msgcontent.Length <= _msgMinLength) return false;
|
||||||
|
if (_msgMaxLength != -1 && msgcontent.Length >= _msgMaxLength) return false;
|
||||||
|
|
||||||
|
// Filter check
|
||||||
|
if (Filter.IsFiltered(m)) return false;
|
||||||
|
// Mod bypass check
|
||||||
|
if (AllowsModBypass && isMod) return false;
|
||||||
|
|
||||||
|
// Finally, regex checks
|
||||||
|
foreach (var regex in Regex)
|
||||||
|
{
|
||||||
|
if (regex.IsMatch(msgcontent)) 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"Rule '{Label}'";
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,7 +55,8 @@ namespace Noikoio.RegexBot
|
||||||
arg.Message));
|
arg.Message));
|
||||||
|
|
||||||
// With features initialized, finish loading configuration
|
// With features initialized, finish loading configuration
|
||||||
if (!_config.ReloadServerConfig().GetAwaiter().GetResult())
|
var conf = _config.ReloadServerConfig().Result;
|
||||||
|
if (conf == false)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Failed to load server configuration.");
|
Console.WriteLine("Failed to load server configuration.");
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
Loading…
Reference in a new issue