27a25d90fc
BotFeature is a new base class that all new individual bot features will derive from. At least one new feature is planned for this bot, and in time it may be opened up so external assemblies can be loaded. Full list of changes: -Added BotFeature and ConfigSectionAttribute classes -Renamed ConfigLoader to Configuration -Removed RegexResponder specific configuration data -Added per-feature configuration data storage -LoadInitialConfig() no longer loads all configuration -ReloadServerConfig() now loads remaining configuration, and allows for actual configuration reloading. Live configuration reloading is not an exposed feature yet. -Can now delegate feature-specific configuration loading to feature classes that make use of ConfigSectionAttribute -RegexResponder fully implements BotFeature -Rule configuration loading moved to RegexResponder -Logging output has changed slightly in regards to rule triggering and execution -Changed configuration load behavior on startup -Version pushed up to 1.0.0
401 lines
14 KiB
C#
401 lines
14 KiB
C#
using Discord;
|
|
using Discord.WebSocket;
|
|
using Noikoio.RegexBot.ConfigItem;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Noikoio.RegexBot.Feature.RegexResponder
|
|
{
|
|
/// <summary>
|
|
/// Implements per-message regex matching and executes customizable responses.
|
|
/// Namesake of this project.
|
|
/// </summary>
|
|
partial class EventProcessor : BotFeature
|
|
{
|
|
private readonly DiscordSocketClient _client;
|
|
|
|
public override string Name => "RegexResponder";
|
|
|
|
public EventProcessor(DiscordSocketClient client) : base(client)
|
|
{
|
|
_client = client;
|
|
|
|
_client.MessageReceived += OnMessageReceived;
|
|
_client.MessageUpdated += OnMessageUpdated;
|
|
|
|
_commands = new ReadOnlyDictionary<string, ResponseProcessor>(
|
|
new Dictionary<string, ResponseProcessor>() {
|
|
#if DEBUG
|
|
{ "crash", RP_Crash },
|
|
{ "dumpid", RP_DumpID },
|
|
#endif
|
|
{ "report", RP_Report },
|
|
{ "say", RP_Say },
|
|
{ "remove", RP_Remove },
|
|
{ "delete", RP_Remove },
|
|
{ "erase", RP_Remove },
|
|
{ "exec", RP_Exec },
|
|
{ "ban", RP_Ban },
|
|
{ "grantrole", RP_GrantRevokeRole },
|
|
{ "revokerole", RP_GrantRevokeRole }
|
|
}
|
|
);
|
|
}
|
|
|
|
#region Event handlers
|
|
private async Task OnMessageReceived(SocketMessage arg)
|
|
=> await ReceiveMessage(arg);
|
|
private async Task OnMessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
|
=> await ReceiveMessage(arg2);
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Receives incoming messages and creates tasks to handle them if necessary.
|
|
/// </summary>
|
|
private async Task ReceiveMessage(SocketMessage arg)
|
|
{
|
|
// Determine channel type - if not a guild channel, stop.
|
|
var ch = arg.Channel as SocketGuildChannel;
|
|
if (ch == null) return;
|
|
|
|
if (arg.Author == _client.CurrentUser) return; // Don't ever self-trigger
|
|
|
|
// Looking up server information and extracting settings
|
|
SocketGuild g = ch.Guild;
|
|
ServerConfig sd = null;
|
|
foreach (var item in RegexBot.Config.Servers)
|
|
{
|
|
if (item.Id.HasValue)
|
|
{
|
|
// Finding server by ID
|
|
if (g.Id == item.Id)
|
|
{
|
|
sd = item;
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Finding server by name and caching ID
|
|
if (string.Equals(item.Name, g.Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item.Id = g.Id;
|
|
sd = item;
|
|
await Logger.GetLogger(Configuration.LogPrefix)
|
|
($"Suggestion: Server \"{item.Name}\" can be defined as \"{item.Id}::{item.Name}\"");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sd == null) return; // No server configuration found
|
|
var rules = GetConfig(ch.Guild.Id) as IEnumerable<RuleConfig>;
|
|
if (rules == null) return;
|
|
|
|
// Further processing is sent to the thread pool
|
|
foreach (var rule in rules)
|
|
await Task.Run(async () => await ProcessMessage(sd, rule, arg));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Uses information from a single rule and checks if the incoming message is a match.
|
|
/// If it matches, the rule's responses are executed. To be run in the thread pool.
|
|
/// </summary>
|
|
private async Task ProcessMessage(ServerConfig srv, RuleConfig rule, SocketMessage msg)
|
|
{
|
|
string msgcontent;
|
|
|
|
// Embed mode?
|
|
if (rule.MatchEmbeds)
|
|
{
|
|
var embeds = new StringBuilder();
|
|
foreach (var e in msg.Embeds) embeds.AppendLine(EmbedToString(e));
|
|
msgcontent = embeds.ToString();
|
|
}
|
|
else
|
|
{
|
|
msgcontent = msg.Content;
|
|
}
|
|
|
|
// Min/max message length check
|
|
if (rule.MinLength.HasValue && msgcontent.Length <= rule.MinLength.Value) return;
|
|
if (rule.MaxLength.HasValue && msgcontent.Length >= rule.MaxLength.Value) return;
|
|
|
|
// Moderator bypass check
|
|
if (rule.AllowModBypass == true && IsInList(srv.Moderators, msg)) return;
|
|
// Individual rule filtering check
|
|
if (IsFiltered(rule, msg)) return;
|
|
|
|
// And finally, pattern matching checks
|
|
bool success = false;
|
|
foreach (var regex in rule.Regex)
|
|
{
|
|
success = regex.Match(msgcontent).Success;
|
|
if (success) break;
|
|
}
|
|
if (!success) return;
|
|
|
|
// Prepare to execute responses
|
|
await Log($"\"{rule.DisplayName}\" triggered in {srv.Name}/#{msg.Channel} by {msg.Author.ToString()}");
|
|
|
|
foreach (string rcmd in rule.Responses)
|
|
{
|
|
string cmd = rcmd.TrimStart(' ').Split(' ')[0].ToLower();
|
|
try
|
|
{
|
|
ResponseProcessor response;
|
|
if (!_commands.TryGetValue(cmd, out response))
|
|
{
|
|
await Log($"Unknown command defined in response: \"{cmd}\"");
|
|
continue;
|
|
}
|
|
await response.Invoke(rcmd, rule, msg);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await Log($"Encountered an error while processing \"{cmd}\". Details follow:");
|
|
await Log(ex.ToString());
|
|
}
|
|
}
|
|
}
|
|
|
|
[ConfigSection("rules")]
|
|
public override async Task<object> ProcessConfiguration(JToken configSection)
|
|
{
|
|
List<RuleConfig> rules = new List<RuleConfig>();
|
|
foreach (JObject ruleconf in configSection)
|
|
{
|
|
// Try and get at least the name before passing it to RuleItem
|
|
string name = ruleconf["name"]?.Value<string>();
|
|
if (name == null)
|
|
{
|
|
await Log("Display name not defined within a rule section.");
|
|
return false;
|
|
}
|
|
await Log($"Adding rule \"{name}\"");
|
|
|
|
RuleConfig rule;
|
|
try
|
|
{
|
|
rule = new RuleConfig(ruleconf);
|
|
}
|
|
catch (RuleImportException ex)
|
|
{
|
|
await Log("-> Error: " + ex.Message);
|
|
return false;
|
|
}
|
|
rules.Add(rule);
|
|
}
|
|
|
|
return rules.AsReadOnly();
|
|
}
|
|
|
|
// -------------------------------------
|
|
|
|
/// <summary>
|
|
/// Turns an embed into a single string for regex matching purposes
|
|
/// </summary>
|
|
private string EmbedToString(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();
|
|
}
|
|
|
|
private bool IsFiltered(RuleConfig r, SocketMessage m)
|
|
{
|
|
if (r.FilterMode == FilterType.None) return false;
|
|
|
|
bool inFilter = IsInList(r.FilterList, m);
|
|
|
|
if (r.FilterMode == FilterType.Whitelist)
|
|
{
|
|
if (!inFilter) return true;
|
|
return IsInList(r.FilterExemptions, m);
|
|
}
|
|
else if (r.FilterMode == FilterType.Blacklist)
|
|
{
|
|
if (!inFilter) return false;
|
|
return !IsInList(r.FilterExemptions, m);
|
|
}
|
|
|
|
return false; // this shouldn't happen™
|
|
}
|
|
|
|
private bool IsInList(EntityList ignorelist, SocketMessage m)
|
|
{
|
|
if (ignorelist == null)
|
|
{
|
|
// This happens when getting a message from a server not defined in config.
|
|
return false;
|
|
}
|
|
|
|
var author = m.Author as SocketGuildUser;
|
|
foreach (var item in ignorelist.Users)
|
|
{
|
|
if (!item.Id.HasValue)
|
|
{
|
|
// Attempt to update ID if given nick matches
|
|
if (string.Equals(item.Name, author.Nickname, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(item.Name, author.Username, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item.UpdateId(author.Id);
|
|
return true;
|
|
}
|
|
} else
|
|
{
|
|
if (item.Id.Value == author.Id) return true;
|
|
}
|
|
}
|
|
|
|
foreach (var item in ignorelist.Roles)
|
|
{
|
|
if (!item.Id.HasValue)
|
|
{
|
|
// Try to update ID if none exists
|
|
foreach (var role in author.Roles)
|
|
{
|
|
if (string.Equals(item.Name, role.Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item.UpdateId(role.Id);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (author.Roles.Any(r => r.Id == item.Id)) return true;
|
|
}
|
|
}
|
|
|
|
foreach (var item in ignorelist.Channels)
|
|
{
|
|
if (!item.Id.HasValue)
|
|
{
|
|
// Try get ID
|
|
if (string.Equals(item.Name, m.Channel.Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
item.UpdateId(m.Channel.Id);
|
|
return true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (item.Id == m.Channel.Id) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private string[] SplitParams(string cmd, int? limit = null)
|
|
{
|
|
if (limit.HasValue)
|
|
{
|
|
return cmd.Split(new char[] { ' ' }, limit.Value, StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
else
|
|
{
|
|
return cmd.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
}
|
|
|
|
private 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("@\\_", "@_");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receives a string (beginning with @ or #) and returns an object
|
|
/// suitable for sending out messages
|
|
/// </summary>
|
|
private 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))
|
|
{
|
|
ei.UpdateId(ch.Id); // Unnecessary, serves only to trigger the suggestion log message
|
|
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))
|
|
{
|
|
ei.UpdateId(u.Id); // As mentioned above, serves only to trigger the suggestion log
|
|
return await u.GetOrCreateDMChannelAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|