From 3e668a36608d88af9f1f712a6893cc1dc2ebed43 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Fri, 23 Jun 2017 12:31:47 -0700 Subject: [PATCH] First commit in new repository Previous commits had quite a bit of personal information in them. More than I would have liked to share. Unfortunately, making this public means losing all that prior commit history. --- .gitignore | 4 + ConfigItem/EntityList.cs | 87 +++++ ConfigItem/EntityName.cs | 101 +++++ ConfigItem/Rule.cs | 160 ++++++++ ConfigItem/Server.cs | 32 ++ ConfigLoader.cs | 187 +++++++++ Logger.cs | 83 ++++ Program.cs | 43 +++ .../PublishProfiles/DebugProfile.pubxml | 13 + .../PublishProfiles/StableProfile.pubxml | 13 + RegexBot.cs | 63 +++ RegexBot.csproj | 27 ++ RuleResponder.cs | 361 ++++++++++++++++++ RuleResponder_Responses.cs | 267 +++++++++++++ settings.example.json | 68 ++++ 15 files changed, 1509 insertions(+) create mode 100644 .gitignore create mode 100644 ConfigItem/EntityList.cs create mode 100644 ConfigItem/EntityName.cs create mode 100644 ConfigItem/Rule.cs create mode 100644 ConfigItem/Server.cs create mode 100644 ConfigLoader.cs create mode 100644 Logger.cs create mode 100644 Program.cs create mode 100644 Properties/PublishProfiles/DebugProfile.pubxml create mode 100644 Properties/PublishProfiles/StableProfile.pubxml create mode 100644 RegexBot.cs create mode 100644 RegexBot.csproj create mode 100644 RuleResponder.cs create mode 100644 RuleResponder_Responses.cs create mode 100644 settings.example.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65261fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +[Bb]in/ +[Oo]bj/ +.vs/ +*.user diff --git a/ConfigItem/EntityList.cs b/ConfigItem/EntityList.cs new file mode 100644 index 0000000..8cdf6e5 --- /dev/null +++ b/ConfigItem/EntityList.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Noikoio.RegexBot.ConfigItem +{ + enum FilterType { None, Whitelist, Blacklist } + + /// + /// Represents a structure in bot configuration that contains a list of + /// channels, roles, and users. + /// + class EntityList + { + private readonly Dictionary _innerList; + + public IEnumerable Channels => _innerList[EntityType.Channel]; + public IEnumerable Roles => _innerList[EntityType.Role]; + public IEnumerable Users => _innerList[EntityType.User]; + + public EntityList() : this(null) { } + public EntityList(JToken config) + { + _innerList = new Dictionary(); + if (config == null) + { + foreach (EntityType t in Enum.GetValues(typeof(EntityType))) + { + _innerList.Add(t, new EntityName[0]); + } + } + else + { + foreach (EntityType t in Enum.GetValues(typeof(EntityType))) + { + string aname = Enum.GetName(typeof(EntityType), t).ToLower() + "s"; + List items = new List(); + + JToken array = config[aname]; + if (array != null) + { + foreach (var item in array) { + string input = item.Value(); + if (t == EntityType.User && input.StartsWith("@")) input = input.Substring(1); + if (t == EntityType.Channel && input.StartsWith("#")) input = input.Substring(1); + if (input.Length > 0) items.Add(new EntityName(input, t)); + } + } + + _innerList.Add(t, items.ToArray()); + } + } + Debug.Assert(Channels != null && Roles != null && Users != null); + } + + public override string ToString() + { + return $"List contains: " + + $"{Channels.Count()} channel(s), " + + $"{Roles.Count()} role(s), " + + $"{Users.Count()} user(s)"; + } + + + /// + /// Helper method for reading whitelist and blacklist filtering lists + /// + public static (FilterType, EntityList) GetFilterList(JObject section) + { + var mode = FilterType.None; + EntityList list; + if (section["whitelist"] != null) mode = FilterType.Whitelist; + if (section["blacklist"] != null) + { + if (mode == FilterType.Whitelist) + throw new Rule.RuleImportException("Cannot have whitelist AND blacklist defined."); + mode = FilterType.Blacklist; + } + if (mode == FilterType.None) list = new EntityList(); // might even be fine to keep it null? + else list = new EntityList(section[mode == FilterType.Whitelist ? "whitelist" : "blacklist"]); + + return (mode, list); + } + } +} diff --git a/ConfigItem/EntityName.cs b/ConfigItem/EntityName.cs new file mode 100644 index 0000000..9b796b1 --- /dev/null +++ b/ConfigItem/EntityName.cs @@ -0,0 +1,101 @@ +using System; + +namespace Noikoio.RegexBot.ConfigItem +{ + enum EntityType { Channel, Role, User } + + /// + /// Used to join together an entity ID and its name, particularly when read from configuration. + /// In the event of an unknown ID, the ID is found and cached. The ID should preferably be used + /// over the entity's string-based name, as it can change at any time. + /// In configuration, entities are fully specified with a prefix (if necessary), an ID, two colons, and a name. + /// + internal class EntityName + { + private ulong? _id; + private string _name; + private readonly EntityType _type; + + public ulong? Id => _id; + public string Name => _name; + public EntityType Type => _type; + + /// + /// Creates a new EntityItem instance + /// + /// Input text WITHOUT the leading prefix. It must be stripped beforehand. + /// Type of this entity. Should be determined by the input prefix. + public EntityName(string input, EntityType t) + { + _type = t; + + // Check if input contains both ID and label + int separator = input.IndexOf("::"); + if (separator != -1) + { + _name = input.Substring(separator + 2, input.Length - (separator + 2)); + if (ulong.TryParse(input.Substring(0, separator), out var id)) + { + _id = id; + } + else + { + // Failed to parse ID. Assuming the actual name includes our separator. + _id = null; + _name = input; + } + } + else + { + // Input is either only an ID or only a name + if (ulong.TryParse(input, out var id)) + { + _id = id; + _name = null; + } + else + { + _name = input; + _id = null; + } + } + } + + /// + /// Updates this entity's ID value only if it was previously unknown. + /// Additionally logs a message suggesting to insert the ID into configuration. + /// + public void UpdateId(ulong id) + { + // TODO not in here, but references to this have a lot of boilerplate around it. how to fix? + if (_id.HasValue) return; + _id = id; + + var log = Logger.GetLogger(ConfigLoader.LogPrefix); + var thisstr = this.ToString(); + log(String.Format( + "Suggestion: \"{0}\" may be written in configuration as \"{1}\"", + (Type == EntityType.Role ? "" : thisstr.Substring(0, 1)) + Name, thisstr)); + } + + public override string ToString() + { + string prefix; + if (_type == EntityType.Channel) prefix = "#"; + else if (_type == EntityType.User) prefix = "@"; + else prefix = ""; + + if (_id.HasValue && _name != null) + { + return $"{prefix}{Id}::{Name}"; + } + + if (_id.HasValue) + { + return $"{prefix}{Id}"; + } + + return $"{prefix}{Name}"; + } + } +} diff --git a/ConfigItem/Rule.cs b/ConfigItem/Rule.cs new file mode 100644 index 0000000..2b2d2ec --- /dev/null +++ b/ConfigItem/Rule.cs @@ -0,0 +1,160 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Noikoio.RegexBot.ConfigItem +{ + /// + /// Represents configuration for a single rule. + /// + [System.Diagnostics.DebuggerDisplay("Rule: {DisplayName}")] + internal struct Rule + { + private string _displayName; + private Server _server; + private IEnumerable _regex; + private IEnumerable _responses; + private FilterType _filtermode; + private EntityList _filterlist; + private EntityList _filterexempt; + private int? _minLength; + private int? _maxLength; + private bool _modBypass; + private bool _matchEmbeds; + + public string DisplayName => _displayName; + public Server Server => _server; + public IEnumerable Regex => _regex; + public IEnumerable Responses => _responses; + public FilterType FilterMode => _filtermode; + public EntityList FilterList => _filterlist; + public EntityList FilterExemptions => _filterexempt; + public int? MinLength => _minLength; + public int? MaxLength => _maxLength; + public bool AllowModBypass => _modBypass; + public bool MatchEmbeds => _matchEmbeds; + + /// + /// Takes the JObject for a single rule and retrieves all data for use as a struct. + /// + /// Rule configuration input + /// + /// Thrown when encountering a missing or invalid value. + /// + public Rule(Server serverref, JObject ruleconf) + { + _server = serverref; + + // display name - validation should've been done outside this constructor already + _displayName = ruleconf["name"]?.Value(); + if (_displayName == null) + throw new RuleImportException("Display name not defined."); + + // 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(); + opts |= RegexOptions.IgnoreCase; + if (regexci.HasValue && regexci.Value == false) + opts &= ~RegexOptions.IgnoreCase; + + // regex + const string RegexError = "No regular expression patterns are defined."; + var regexes = new List(); + var rxconf = ruleconf["regex"]; + if (rxconf == null) + { + throw new RuleImportException(RegexError); + } + if (rxconf.Type == JTokenType.Array) + { + foreach (var input in rxconf.Values()) + { + try + { + Regex r = new Regex(input, opts); + regexes.Add(r); + } + catch (ArgumentException) + { + throw new RuleImportException("Failed to parse regular expression pattern: " + input); + } + } + } + else + { + string rxstr = rxconf.Value(); + try + { + var rxx = new Regex(rxstr, opts); + regexes.Add(rxx); + } + catch (ArgumentException) + { + throw new RuleImportException("Failed to parse regular expression pattern: " + rxstr); + } + } + if (regexes.Count == 0) + { + throw new RuleImportException(RegexError); + } + _regex = regexes.ToArray(); + + // min/max length + try + { + _minLength = ruleconf["min"]?.Value(); + _maxLength = ruleconf["max"]?.Value(); + } + catch (FormatException) + { + throw new RuleImportException("Minimum/maximum values must be an integer."); + } + + // responses + const string ResponseError = "No responses have been defined for this rule."; + var responses = new List(); + var rsconf = ruleconf["response"]; + if (rsconf == null) + { + throw new RuleImportException(ResponseError); + } + if (rsconf.Type == JTokenType.Array) + { + foreach (var input in rsconf.Values()) responses.Add(input); + } + else + { + responses.Add(rsconf.Value()); + } + // TODO a bit of response validation here. at least check for blanks or something. + _responses = responses.ToArray(); + + // (white|black)list filtering + (_filtermode, _filterlist) = EntityList.GetFilterList(ruleconf); + // filtering exemptions + _filterexempt = new EntityList(ruleconf["exempt"]); + + // moderator bypass toggle - true by default, must be explicitly set to false + bool? modoverride = ruleconf["AllowModBypass"]?.Value(); + _modBypass = modoverride.HasValue ? modoverride.Value : true; + + // embed matching mode + bool? embedmode = ruleconf["MatchEmbeds"]?.Value(); + _matchEmbeds = (embedmode.HasValue && embedmode == true); + } + + /// + /// Exception thrown during an attempt to read rule configuration. + /// + public class RuleImportException : Exception + { + public RuleImportException(string message) : base(message) { } + } + } + + +} diff --git a/ConfigItem/Server.cs b/ConfigItem/Server.cs new file mode 100644 index 0000000..5c3e44d --- /dev/null +++ b/ConfigItem/Server.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Noikoio.RegexBot.ConfigItem +{ + /// + /// Represents known information about a Discord guild (server) and other associated data + /// + class Server + { + private readonly string _name; + private ulong? _id; + private IEnumerable _rules; + private EntityList _moderators; + + public string Name => _name; + public ulong? Id { + get => _id; set { if (!_id.HasValue) _id = value; } + } + public IEnumerable MatchResponseRules => _rules; + public EntityList Moderators => _moderators; + + public Server(string name, ulong? id, IEnumerable rules, EntityList moderators) + { + _name = name; + _id = id; + _rules = rules; + _moderators = moderators; + Debug.Assert(_name != null && _rules != null && _moderators != null); + } + } +} diff --git a/ConfigLoader.cs b/ConfigLoader.cs new file mode 100644 index 0000000..e4b93c1 --- /dev/null +++ b/ConfigLoader.cs @@ -0,0 +1,187 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot +{ + /// + /// Configuration loader + /// + class ConfigLoader + { + public const string LogPrefix = "Config"; + + private readonly string _configPath; + private Server[] _servers; + + private string _botToken; + private string _currentGame; + + public string BotUserToken => _botToken; + public string CurrentGame => _currentGame; + public Server[] Servers => _servers; + + public ConfigLoader() + { + var dsc = Path.DirectorySeparatorChar; + _configPath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + + dsc + "settings.json"; + } + + private async Task LoadFile() + { + var Log = Logger.GetLogger(LogPrefix); + JObject pcfg; + try + { + var ctxt = File.ReadAllText(_configPath); + pcfg = JObject.Parse(ctxt); + return pcfg; + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + await Log("Config file not found! Check bot directory for settings.json file."); + return null; + } + catch (UnauthorizedAccessException) + { + await Log("Could not access config file. Check file permissions."); + return null; + } + catch (JsonReaderException jex) + { + await Log("Failed to parse JSON."); + await Log(jex.GetType().Name + " " + jex.Message); + return null; + } + } + + /// + /// Called only on bot startup. Returns false on failure. + /// + public bool LoadInitialConfig() + { + var lt = LoadFile(); + lt.Wait(); + JObject conf = lt.Result; + if (conf == null) return false; + + _botToken = conf["bot-token"]?.Value(); + if (String.IsNullOrWhiteSpace(_botToken)) + { + Logger.GetLogger(LogPrefix)("Error: Bot token not defined. Cannot continue.").Wait(); + return false; + } + _currentGame = conf["playing"]?.Value(); + + return ProcessServerConfig(conf).GetAwaiter().GetResult(); + } + + /// + /// Reloads the server portion of the configuration file. + /// + /// False on failure. Specific reasons will have been sent to log. + public async Task ReloadServerConfig() + { + await Logger.GetLogger(LogPrefix)("Configuration reload currently not supported."); + return false; + // TODO actually implement this + var lt = LoadFile(); + lt.Wait(); + JObject conf = lt.Result; + if (conf == null) return false; + + return await ProcessServerConfig(conf); + } + + /// + /// Converts a json object containing bot configuration into data usable by this program. + /// On success, updates the Servers values and returns true. Returns false on failure. + /// + private async Task ProcessServerConfig(JObject conf) + { + var Log = Logger.GetLogger(LogPrefix); + if (!conf["servers"].HasValues) + { + await Log("Error: No server configurations are defined."); + return false; + } + + List newservers = new List(); + await Log("Reading server configurations..."); + foreach (JObject sconf in conf["servers"].Children()) + { + // Server name + if (sconf["name"] == null || string.IsNullOrWhiteSpace(sconf["name"].Value())) + { + await Log("Error: Server definition is missing a name."); + return false; + } + string snamestr = sconf["name"].Value(); + string sname; + ulong? sid; + + int snseparator = snamestr.IndexOf("::"); + if (ulong.TryParse(snamestr.Substring(0, snseparator), out var id)) + { + sid = id; + sname = snamestr.Substring(snseparator + 2); + } + else + { + sid = null; + sname = snamestr; + } + + var SLog = Logger.GetLogger(LogPrefix + "/" + sname); + + // Load server moderator list + EntityList mods = new EntityList(sconf["moderators"]); + if (sconf["moderators"] != null) await SLog("Moderator " + mods.ToString()); + + // Read rules + // Also, parsed rules require a server reference. Creating it here. + List rules = new List(); + Server newserver = new Server(sname, sid, rules, mods); + + foreach (JObject ruleconf in sconf["rules"]) + { + // Try and get at least the name before passing it to RuleItem + string name = ruleconf["name"]?.Value(); + if (name == null) + { + await SLog("Display name not defined within a rule section."); + return false; + } + await SLog($"Adding rule \"{name}\""); + + Rule rule; + try + { + rule = new Rule(newserver, ruleconf); + } catch (Rule.RuleImportException ex) + { + await SLog("-> Error: " + ex.Message); + return false; + } + rules.Add(rule); + } + + // Switch to using new data + List> rulesfinal = new List>(); + newservers.Add(newserver); + } + + _servers = newservers.ToArray(); + return true; + } + } + + +} diff --git a/Logger.cs b/Logger.cs new file mode 100644 index 0000000..a3d65d7 --- /dev/null +++ b/Logger.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot +{ + /// + /// Logging helper class. Receives logging messages and handles them accordingly. + /// + class Logger + { + private static Logger _instance; + private readonly string _logBasePath; + private bool _fileLogEnabled; + private static readonly object FileLogLock = new object(); + + /// + /// Gets if the instance is logging all messages to a file. + /// + public bool FileLoggingEnabled => _fileLogEnabled; + private Logger() + { + // top level - determine path to use for logging and see if it's usable + var dc = Path.DirectorySeparatorChar; + _logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + dc + "logs"; + try + { + if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath); + _fileLogEnabled = true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + Console.Error.WriteLine("Unable to create log directory. File logging disabled."); + _fileLogEnabled = false; + } + } + + /// + /// Requests a delegate to be used for sending log messages. + /// + /// String used to prefix log messages sent using the given delegate. + /// + public static AsyncLogger GetLogger(string prefix) + { + if (_instance == null) _instance = new Logger(); + return (async delegate (string line) { await _instance.ProcessLog(prefix, line); }); + } + + protected Task ProcessLog(string source, string input) + { + var timestamp = DateTime.Now; + string filename = _logBasePath + Path.DirectorySeparatorChar + $"{timestamp:yyyy-MM}.log"; + + List result = new List(); + foreach (var line in Regex.Split(input, "\r\n|\r|\n")) + { + string finalLine = $"{timestamp:u} [{source}] {line}"; + result.Add(finalLine); + Console.WriteLine(finalLine); + } + + if (FileLoggingEnabled) + { + try + { + lock (FileLogLock) File.AppendAllLines(filename, result); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + Console.Error.WriteLine("Unable to write to log file. File logging disabled."); + _fileLogEnabled = false; + } + } + + return Task.CompletedTask; + } + } + + public delegate Task AsyncLogger(string prefix); +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..ddb3a20 --- /dev/null +++ b/Program.cs @@ -0,0 +1,43 @@ +using System; + +namespace Noikoio.RegexBot +{ + /// + /// Program entry point. Sets up handling of certain events and does initial + /// configuration loading before starting the Discord client. + /// + class Program + { + static void Main(string[] args) + { + // Attempt to load basic configuration before setting up the client + var config = new ConfigLoader(); + if (!config.LoadInitialConfig()) + { +#if DEBUG + Console.WriteLine("Press any key to exit."); + Console.ReadKey(); +#endif + Environment.Exit(1); + } + + RegexBot rb = new RegexBot(config); + + Console.CancelKeyPress += rb.Console_CancelKeyPress; + //AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + + rb.Start().GetAwaiter().GetResult(); + } + + // TODO Re-implement this once the framework allows for it again. + //private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + //{ + // var l = _logger.SetPrefix("Runtime"); + // string[] lines = Regex.Split(e.ExceptionObject.ToString(), "\r\n|\r|\n"); + // foreach (string line in lines) + // { + // l.Log(line).Wait(); + // } + //} + } +} \ No newline at end of file diff --git a/Properties/PublishProfiles/DebugProfile.pubxml b/Properties/PublishProfiles/DebugProfile.pubxml new file mode 100644 index 0000000..e2bc34c --- /dev/null +++ b/Properties/PublishProfiles/DebugProfile.pubxml @@ -0,0 +1,13 @@ + + + + + FileSystem + Debug + netcoreapp1.1 + bin\Debug\PublishOutput + + \ No newline at end of file diff --git a/Properties/PublishProfiles/StableProfile.pubxml b/Properties/PublishProfiles/StableProfile.pubxml new file mode 100644 index 0000000..593d520 --- /dev/null +++ b/Properties/PublishProfiles/StableProfile.pubxml @@ -0,0 +1,13 @@ + + + + + FileSystem + Release + netcoreapp1.1 + bin\Release\PublishOutput + + \ No newline at end of file diff --git a/RegexBot.cs b/RegexBot.cs new file mode 100644 index 0000000..ce6e4e2 --- /dev/null +++ b/RegexBot.cs @@ -0,0 +1,63 @@ +using Discord; +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot +{ + /// + /// Main class. On start, initializes bot features and passes the DiscordSocketClient to them + /// + class RegexBot + { + private readonly ConfigLoader _config; + private readonly DiscordSocketClient _client; + private readonly RuleResponder _responder; + + internal RegexBot(ConfigLoader conf) + { + _client = new DiscordSocketClient(new DiscordSocketConfig() + { + LogLevel = LogSeverity.Info, + DefaultRetryMode = RetryMode.AlwaysRetry, + MessageCacheSize = 50 + }); + _config = conf; + + _client.Connected += _client_Connected; + _responder = new RuleResponder(_client, _config); + } + + internal async Task Start() + { + var dlog = Logger.GetLogger("Discord"); + _client.Log += async (arg) => + await dlog(String.Format("{0}: {1}{2}", + arg.Source, ((int)arg.Severity < 3 ? arg.Severity + ": " : ""), arg.Message)); + await _client.LoginAsync(TokenType.Bot, _config.BotUserToken); + await _client.StartAsync(); + + await Task.Delay(-1); + } + + private async Task _client_Connected() + { + await _client.SetGameAsync(_config.CurrentGame); + // TODO add support for making use of server invites somewhere around here + } + + // Defined within this class because a reference to the client is required + public void Console_CancelKeyPress(object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + Logger.GetLogger("Runtime")("Caught cancel key. Will attempt to disconnect...").Wait(); + _client.LogoutAsync().Wait(); + _client.Dispose(); +#if DEBUG + Console.WriteLine("Press enter to exit."); + Console.ReadLine(); +#endif + Environment.Exit(0); + } + } +} diff --git a/RegexBot.csproj b/RegexBot.csproj new file mode 100644 index 0000000..277d42c --- /dev/null +++ b/RegexBot.csproj @@ -0,0 +1,27 @@ + + + + Exe + netcoreapp1.1 + Noikoio.RegexBot + 0.14.0.0 + Highly configurable Discord moderation bot + + + + bin\Release\netcoreapp1.1\RegexBot.xml + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/RuleResponder.cs b/RuleResponder.cs new file mode 100644 index 0000000..bb8cf1e --- /dev/null +++ b/RuleResponder.cs @@ -0,0 +1,361 @@ +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; + +namespace Noikoio.RegexBot +{ + /// + /// Bot subsystem that implements regex matching and response processing. + /// + partial class RuleResponder + { + private readonly DiscordSocketClient _client; + private readonly ConfigLoader _conf; + + public RuleResponder(DiscordSocketClient client, ConfigLoader conf) + { + _client = client; + _conf = conf; + + _client.MessageReceived += OnMessageReceived; + _client.MessageUpdated += OnMessageUpdated; + + _commands = new ReadOnlyDictionary( + new Dictionary() { +#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 arg1, SocketMessage arg2, ISocketMessageChannel arg3) + => await ReceiveMessage(arg2); + #endregion + + /// + /// Receives incoming messages and creates tasks to handle them if necessary. + /// + private async Task ReceiveMessage(SocketMessage arg) + { + if (arg.Author == _client.CurrentUser) return; + + // Looking up server information and extracting settings + SocketGuild g = ((SocketGuildUser)arg.Author).Guild; + Server sd = null; + foreach (var item in _conf.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(ConfigLoader.LogPrefix) + ($"Suggestion: Server \"{item.Name}\" can be defined as \"{item.Id}::{item.Name}\""); + break; + } + } + } + + if (sd == null) return; // No server configuration found + + // Further processing is sent to the thread pool + foreach (var rule in sd.MatchResponseRules) + await Task.Run(async () => await ProcessMessage(sd, rule, arg)); + } + + /// + /// 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. + /// + private async Task ProcessMessage(Server srv, Rule 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 + var log = Logger.GetLogger(rule.DisplayName); + await log($"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 \"{cmd}\""); + continue; + } + await response.Invoke(log, rcmd, rule, msg); + } + catch (Exception ex) + { + await log($"Encountered an error while processing \"{cmd}\""); + await log(ex.ToString()); + } + } + } + + /// + /// Turns an embed into a single string for regex matching purposes + /// + 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(Rule 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("@\\_", "@_"); + } + + /// + /// Receives a string (beginning with @ or #) and returns an object + /// suitable for sending out messages + /// + private async Task 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.CreateDMChannelAsync(); + } + + 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).CreateDMChannelAsync(); + } + + // 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.CreateDMChannelAsync(); + } + } + } + + return null; + } + } +} diff --git a/RuleResponder_Responses.cs b/RuleResponder_Responses.cs new file mode 100644 index 0000000..59cb3ed --- /dev/null +++ b/RuleResponder_Responses.cs @@ -0,0 +1,267 @@ +using Discord; +using Discord.WebSocket; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot +{ + // Contains code for handling each response in a rule. + partial class RuleResponder + { + private delegate Task ResponseProcessor(AsyncLogger l, string cmd, Rule r, SocketMessage m); + private readonly ReadOnlyDictionary _commands; + +#if DEBUG + /// + /// Throws an exception. Meant to be a quick error handling test. + /// No parameters. + /// + private async Task RP_Crash(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + await l("Will throw an exception."); + throw new Exception("Requested in response."); + } + + /// + /// Prints all guild values (IDs for users, channels, roles) to console. + /// The guild info displayed is the one in which the command is invoked. + /// No parameters. + /// + private Task RP_DumpID(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + var g = ((SocketGuildUser)m.Author).Guild; + var result = new StringBuilder(); + + result.AppendLine("Users:"); + foreach (var item in g.Users) + result.AppendLine($"{item.Id} {item.Username}#{item.Discriminator}"); + result.AppendLine(); + + result.AppendLine("Channels:"); + foreach (var item in g.Channels) result.AppendLine($"{item.Id} #{item.Name}"); + result.AppendLine(); + result.AppendLine("Roles:"); + foreach (var item in g.Roles) result.AppendLine($"{item.Id} {item.Name}"); + result.AppendLine(); + + Console.WriteLine(result.ToString()); + return Task.CompletedTask; + } +#endif + /// + /// Sends a message to a specified channel. + /// Parameters: say (channel) (message) + /// + private async Task RP_Say(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + string[] @in = SplitParams(cmd, 3); + if (@in.Length != 3) + { + await l("Error: say: Incorrect number of parameters."); + return; + } + + var target = await GetMessageTargetAsync(@in[1], m); + if (target == null) + { + await l("Error: say: Unable to resolve given target."); + return; + } + + // CHANGE THE SAY + @in[2] = ProcessText(@in[2], m); + await target.SendMessageAsync(@in[2]); + } + + /// + /// Reports the incoming message to a given channel. + /// Parameters: report (channel) + /// + private async Task RP_Report(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + string[] @in = SplitParams(cmd); + if (@in.Length != 2) + { + await l("Error: report: Incorrect number of parameters."); + return; + } + + var target = await GetMessageTargetAsync(@in[1], m); + if (target == null) + { + await l("Error: report: Unable to resolve given target."); + return; + } + + + var responsefield = new StringBuilder(); + responsefield.AppendLine("```"); + foreach (var line in r.Responses) + responsefield.AppendLine(line.Replace("\r", "").Replace("\n", "\\n")); + responsefield.Append("```"); + await target.SendMessageAsync("", embed: new EmbedBuilder() + { + Color = new Color(0xEDCE00), // configurable later? + + Author = new EmbedAuthorBuilder() + { + Name = $"{m.Author.Username}#{m.Author.Discriminator} said:", + IconUrl = m.Author.GetAvatarUrl() + }, + Description = m.Content, + + Footer = new EmbedFooterBuilder() + { + Text = $"Rule '{r.DisplayName}'", + IconUrl = _client.CurrentUser.GetAvatarUrl() + }, + Timestamp = m.Timestamp + }.AddField(new EmbedFieldBuilder() + { + Name = "Additional info", + Value = $"Channel: <#{m.Channel.Id}>\n" // NOTE: manually mentioning channel here + + $"Username: {m.Author.Mention}\n" + + $"Message ID: {m.Id}" + }).AddField(new EmbedFieldBuilder() + { + Name = "Executing response:", + Value = responsefield.ToString() + })); + } + + /// + /// Deletes the incoming message. + /// No parameters. + /// + private async Task RP_Remove(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + // Parameters are not checked + await m.DeleteAsync(); + } + + /// + /// Executes an external program and sends standard output to the given channel. + /// Parameters: exec (channel) (command line) + /// + private async Task RP_Exec(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + var @in = SplitParams(cmd, 4); + if (@in.Length < 3) + { + await l("exec: Incorrect number of parameters."); + } + + string result; + var target = await GetMessageTargetAsync(@in[1], m); + if (target == null) + { + await l("Error: exec: Unable to resolve given channel."); + return; + } + + ProcessStartInfo ps = new ProcessStartInfo() + { + FileName = @in[2], + Arguments = (@in.Length > 3 ? @in[3] : ""), + UseShellExecute = false, + RedirectStandardOutput = true + }; + using (Process p = Process.Start(ps)) + { + p.WaitForExit(5000); // waiting at most 5 seconds + if (p.HasExited) + { + if (p.ExitCode != 0) await l("exec: Process returned exit code " + p.ExitCode); + using (var stdout = p.StandardOutput) + { + result = await stdout.ReadToEndAsync(); + } + } + else + { + await l("exec: Process is taking too long to exit. Killing process."); + p.Kill(); + return; + } + } + + result = ProcessText(result.Trim(), m); + await target.SendMessageAsync(result); + } + + /// + /// Bans the sender of the incoming message. + /// No parameters. + /// + // TODO add parameter for message auto-deleting + private async Task RP_Ban(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + SocketGuild g = ((SocketGuildUser)m.Author).Guild; + await g.AddBanAsync(m.Author); + } + + /// + /// Grants or revokes a specified role to/from a given user. + /// Parameters: grantrole/revokerole (user ID or @_) (role ID) + /// + private async Task RP_GrantRevokeRole(AsyncLogger l, string cmd, Rule r, SocketMessage m) + { + string[] @in = SplitParams(cmd); + if (@in.Length != 3) + { + await l($"Error: {@in[0]}: incorrect number of parameters."); + return; + } + if (!ulong.TryParse(@in[2], out var roleID)) + { + await l($"Error: {@in[0]}: Invalid role ID specified."); + return; + } + + // Finding role + var gu = (SocketGuildUser)m.Author; + SocketRole rl = gu.Guild.GetRole(roleID); + if (rl == null) + { + await l($"Error: {@in[0]}: Specified role not found."); + return; + } + + // Finding user + SocketGuildUser target; + if (@in[1] == "@_") + { + target = gu; + } + else + { + if (!ulong.TryParse(@in[1], out var userID)) + { + await l($"Error: {@in[0]}: Invalid user ID specified."); + return; + } + target = gu.Guild.GetUser(userID); + if (target == null) + { + await l($"Error: {@in[0]}: Given user ID does not exist in this server."); + return; + } + } + + if (@in[0].ToLower() == "grantrole") + { + await target.AddRoleAsync(rl); + } + else + { + await target.RemoveRoleAsync(rl); + } + } + + + } +} diff --git a/settings.example.json b/settings.example.json new file mode 100644 index 0000000..fe8a0f7 --- /dev/null +++ b/settings.example.json @@ -0,0 +1,68 @@ +// (this is now really outdated. I'll update it eventually) +{ + "bot-token": "your bot token goes here", // this value is required + "playing": "add some extra flair here", // optional + + // Server values are defined in the "servers" array. Multiple servers are supported. + // Unless stated otherwise, all given values are not case sensitive. + "servers": [ + { + "name": "RegexBot testing area", // Server name, as exists on Discord. + "ignore": { + // Server-wide ignore list. This entire section and its subsections are optional. + // For the moment, only names are checked and it is assumed they will never change. + // The user ignore list in particular should be considered highly unreliable. This will be fixed later. + "users": [], + "roles": [ "Bots" ], + "channels": [] + }, + "rules": [ + // Response rules are defined within this array. See the examples below. + { + "name": "Greeter", // Name of the rule, for logging purposes. Required. + "regex": "^hello", // Regex string that will trigger this rule's response. Required. + "response": [ // Array of responses. Required. + "say #_ Hi @_." + // "say" sends a message to a channel. + // The first parameter (#_) is a reference to the channel where the response + // is being triggered. The rest of the parameters are the text to use. The text + // "@_" is replaced by the name of the user that triggered the response. + ] + }, + { + "name": "fishfish spam remover", + "regex": "(fish)+", + + // Regex rules are not case sensitive by default, but can be overridden with this setting. + "ignorecase": "false", + + // The next two statements ensure that the rule won't be enforced unless the message + // is between 10 and 20 characters (inclusive) in length. + "min": 10, + "max": 20, + + "response": [ + "delete", // Deletes the message that triggered the response. + "report #modlog" // Quotes the message to the given channel. + ] + }, + { + "name": "Fun script thing", + "regex": "^!fun", + "ignore": { + // Individual rules may define their own ignore lists. + // It works in exactly the same way as the server-wide ignore list. + "roles": [ "Anti-Fun Brigade" ] + }, + "response": [ + // Executes an external script and sends output to the source channel. + "exec #_ python ../fun.py" + ] + } + ] + } + //, { + // Another server may be defined here with its own set of rules. + //} + ] +} \ No newline at end of file