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