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.
This commit is contained in:
commit
3e668a3660
15 changed files with 1509 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
.vs/
|
||||||
|
*.user
|
87
ConfigItem/EntityList.cs
Normal file
87
ConfigItem/EntityList.cs
Normal file
|
@ -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 }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a structure in bot configuration that contains a list of
|
||||||
|
/// channels, roles, and users.
|
||||||
|
/// </summary>
|
||||||
|
class EntityList
|
||||||
|
{
|
||||||
|
private readonly Dictionary<EntityType, EntityName[]> _innerList;
|
||||||
|
|
||||||
|
public IEnumerable<EntityName> Channels => _innerList[EntityType.Channel];
|
||||||
|
public IEnumerable<EntityName> Roles => _innerList[EntityType.Role];
|
||||||
|
public IEnumerable<EntityName> Users => _innerList[EntityType.User];
|
||||||
|
|
||||||
|
public EntityList() : this(null) { }
|
||||||
|
public EntityList(JToken config)
|
||||||
|
{
|
||||||
|
_innerList = new Dictionary<EntityType, EntityName[]>();
|
||||||
|
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<EntityName> items = new List<EntityName>();
|
||||||
|
|
||||||
|
JToken array = config[aname];
|
||||||
|
if (array != null)
|
||||||
|
{
|
||||||
|
foreach (var item in array) {
|
||||||
|
string input = item.Value<string>();
|
||||||
|
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)";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method for reading whitelist and blacklist filtering lists
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
101
ConfigItem/EntityName.cs
Normal file
101
ConfigItem/EntityName.cs
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.ConfigItem
|
||||||
|
{
|
||||||
|
enum EntityType { Channel, Role, User }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new EntityItem instance
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">Input text WITHOUT the leading prefix. It must be stripped beforehand.</param>
|
||||||
|
/// <param name="t">Type of this entity. Should be determined by the input prefix.</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates this entity's ID value only if it was previously unknown.
|
||||||
|
/// Additionally logs a message suggesting to insert the ID into configuration.
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
160
ConfigItem/Rule.cs
Normal file
160
ConfigItem/Rule.cs
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.ConfigItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents configuration for a single rule.
|
||||||
|
/// </summary>
|
||||||
|
[System.Diagnostics.DebuggerDisplay("Rule: {DisplayName}")]
|
||||||
|
internal struct Rule
|
||||||
|
{
|
||||||
|
private string _displayName;
|
||||||
|
private Server _server;
|
||||||
|
private IEnumerable<Regex> _regex;
|
||||||
|
private IEnumerable<string> _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 => _regex;
|
||||||
|
public IEnumerable<string> 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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the JObject for a single rule and retrieves all data for use as a struct.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ruleconf">Rule configuration input</param>
|
||||||
|
/// <exception cref="RuleImportException>">
|
||||||
|
/// Thrown when encountering a missing or invalid value.
|
||||||
|
/// </exception>
|
||||||
|
public Rule(Server serverref, JObject ruleconf)
|
||||||
|
{
|
||||||
|
_server = serverref;
|
||||||
|
|
||||||
|
// display name - validation should've been done outside this constructor already
|
||||||
|
_displayName = ruleconf["name"]?.Value<string>();
|
||||||
|
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<bool>();
|
||||||
|
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<Regex>();
|
||||||
|
var rxconf = ruleconf["regex"];
|
||||||
|
if (rxconf == null)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(RegexError);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string rxstr = rxconf.Value<string>();
|
||||||
|
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<int>();
|
||||||
|
_maxLength = ruleconf["max"]?.Value<int>();
|
||||||
|
}
|
||||||
|
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<string>();
|
||||||
|
var rsconf = ruleconf["response"];
|
||||||
|
if (rsconf == null)
|
||||||
|
{
|
||||||
|
throw new RuleImportException(ResponseError);
|
||||||
|
}
|
||||||
|
if (rsconf.Type == JTokenType.Array)
|
||||||
|
{
|
||||||
|
foreach (var input in rsconf.Values<string>()) responses.Add(input);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
responses.Add(rsconf.Value<string>());
|
||||||
|
}
|
||||||
|
// 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<bool>();
|
||||||
|
_modBypass = modoverride.HasValue ? modoverride.Value : true;
|
||||||
|
|
||||||
|
// embed matching mode
|
||||||
|
bool? embedmode = ruleconf["MatchEmbeds"]?.Value<bool>();
|
||||||
|
_matchEmbeds = (embedmode.HasValue && embedmode == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown during an attempt to read rule configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class RuleImportException : Exception
|
||||||
|
{
|
||||||
|
public RuleImportException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
32
ConfigItem/Server.cs
Normal file
32
ConfigItem/Server.cs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot.ConfigItem
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents known information about a Discord guild (server) and other associated data
|
||||||
|
/// </summary>
|
||||||
|
class Server
|
||||||
|
{
|
||||||
|
private readonly string _name;
|
||||||
|
private ulong? _id;
|
||||||
|
private IEnumerable<Rule> _rules;
|
||||||
|
private EntityList _moderators;
|
||||||
|
|
||||||
|
public string Name => _name;
|
||||||
|
public ulong? Id {
|
||||||
|
get => _id; set { if (!_id.HasValue) _id = value; }
|
||||||
|
}
|
||||||
|
public IEnumerable<Rule> MatchResponseRules => _rules;
|
||||||
|
public EntityList Moderators => _moderators;
|
||||||
|
|
||||||
|
public Server(string name, ulong? id, IEnumerable<Rule> rules, EntityList moderators)
|
||||||
|
{
|
||||||
|
_name = name;
|
||||||
|
_id = id;
|
||||||
|
_rules = rules;
|
||||||
|
_moderators = moderators;
|
||||||
|
Debug.Assert(_name != null && _rules != null && _moderators != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
ConfigLoader.cs
Normal file
187
ConfigLoader.cs
Normal file
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration loader
|
||||||
|
/// </summary>
|
||||||
|
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<JObject> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called only on bot startup. Returns false on failure.
|
||||||
|
/// </summary>
|
||||||
|
public bool LoadInitialConfig()
|
||||||
|
{
|
||||||
|
var lt = LoadFile();
|
||||||
|
lt.Wait();
|
||||||
|
JObject conf = lt.Result;
|
||||||
|
if (conf == null) return false;
|
||||||
|
|
||||||
|
_botToken = conf["bot-token"]?.Value<string>();
|
||||||
|
if (String.IsNullOrWhiteSpace(_botToken))
|
||||||
|
{
|
||||||
|
Logger.GetLogger(LogPrefix)("Error: Bot token not defined. Cannot continue.").Wait();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_currentGame = conf["playing"]?.Value<string>();
|
||||||
|
|
||||||
|
return ProcessServerConfig(conf).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads the server portion of the configuration file.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>False on failure. Specific reasons will have been sent to log.</returns>
|
||||||
|
public async Task<bool> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> ProcessServerConfig(JObject conf)
|
||||||
|
{
|
||||||
|
var Log = Logger.GetLogger(LogPrefix);
|
||||||
|
if (!conf["servers"].HasValues)
|
||||||
|
{
|
||||||
|
await Log("Error: No server configurations are defined.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Server> newservers = new List<Server>();
|
||||||
|
await Log("Reading server configurations...");
|
||||||
|
foreach (JObject sconf in conf["servers"].Children<JObject>())
|
||||||
|
{
|
||||||
|
// Server name
|
||||||
|
if (sconf["name"] == null || string.IsNullOrWhiteSpace(sconf["name"].Value<string>()))
|
||||||
|
{
|
||||||
|
await Log("Error: Server definition is missing a name.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
string snamestr = sconf["name"].Value<string>();
|
||||||
|
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<Rule> rules = new List<Rule>();
|
||||||
|
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<string>();
|
||||||
|
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<Tuple<Regex, string[]>> rulesfinal = new List<Tuple<Regex, string[]>>();
|
||||||
|
newservers.Add(newserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
_servers = newservers.ToArray();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
83
Logger.cs
Normal file
83
Logger.cs
Normal file
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logging helper class. Receives logging messages and handles them accordingly.
|
||||||
|
/// </summary>
|
||||||
|
class Logger
|
||||||
|
{
|
||||||
|
private static Logger _instance;
|
||||||
|
private readonly string _logBasePath;
|
||||||
|
private bool _fileLogEnabled;
|
||||||
|
private static readonly object FileLogLock = new object();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets if the instance is logging all messages to a file.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests a delegate to be used for sending log messages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="prefix">String used to prefix log messages sent using the given delegate.</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
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<string> result = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
43
Program.cs
Normal file
43
Program.cs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Program entry point. Sets up handling of certain events and does initial
|
||||||
|
/// configuration loading before starting the Discord client.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
13
Properties/PublishProfiles/DebugProfile.pubxml
Normal file
13
Properties/PublishProfiles/DebugProfile.pubxml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
This file is used by the publish/package process of your Web project. You can customize the behavior of this process
|
||||||
|
by editing this MSBuild file. In order to learn more about this please visit https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<Configuration>Debug</Configuration>
|
||||||
|
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||||
|
<PublishDir>bin\Debug\PublishOutput</PublishDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
13
Properties/PublishProfiles/StableProfile.pubxml
Normal file
13
Properties/PublishProfiles/StableProfile.pubxml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
This file is used by the publish/package process of your Web project. You can customize the behavior of this process
|
||||||
|
by editing this MSBuild file. In order to learn more about this please visit https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||||
|
<PublishDir>bin\Release\PublishOutput</PublishDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
63
RegexBot.cs
Normal file
63
RegexBot.cs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Noikoio.RegexBot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Main class. On start, initializes bot features and passes the DiscordSocketClient to them
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
RegexBot.csproj
Normal file
27
RegexBot.csproj
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>netcoreapp1.1</TargetFramework>
|
||||||
|
<RootNamespace>Noikoio.RegexBot</RootNamespace>
|
||||||
|
<AssemblyVersion>0.14.0.0</AssemblyVersion>
|
||||||
|
<Description>Highly configurable Discord moderation bot</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DocumentationFile>bin\Release\netcoreapp1.1\RegexBot.xml</DocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Discord.Net" Version="1.0.0-rc2" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="10.0.2" />
|
||||||
|
<PackageReference Include="System.ValueTuple" Version="4.3.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="settings.example.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
361
RuleResponder.cs
Normal file
361
RuleResponder.cs
Normal file
|
@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bot subsystem that implements regex matching and response processing.
|
||||||
|
/// </summary>
|
||||||
|
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<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)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(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("@\\_", "@_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
267
RuleResponder_Responses.cs
Normal file
267
RuleResponder_Responses.cs
Normal file
|
@ -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<string, ResponseProcessor> _commands;
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// <summary>
|
||||||
|
/// Throws an exception. Meant to be a quick error handling test.
|
||||||
|
/// No parameters.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a message to a specified channel.
|
||||||
|
/// Parameters: say (channel) (message)
|
||||||
|
/// </summary>
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the incoming message to a given channel.
|
||||||
|
/// Parameters: report (channel)
|
||||||
|
/// </summary>
|
||||||
|
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()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the incoming message.
|
||||||
|
/// No parameters.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RP_Remove(AsyncLogger l, string cmd, Rule r, SocketMessage m)
|
||||||
|
{
|
||||||
|
// Parameters are not checked
|
||||||
|
await m.DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes an external program and sends standard output to the given channel.
|
||||||
|
/// Parameters: exec (channel) (command line)
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bans the sender of the incoming message.
|
||||||
|
/// No parameters.
|
||||||
|
/// </summary>
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grants or revokes a specified role to/from a given user.
|
||||||
|
/// Parameters: grantrole/revokerole (user ID or @_) (role ID)
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
68
settings.example.json
Normal file
68
settings.example.json
Normal file
|
@ -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.
|
||||||
|
//}
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in a new issue