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:
Noikoio 2017-06-23 12:31:47 -07:00
commit 3e668a3660
15 changed files with 1509 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
[Bb]in/
[Oo]bj/
.vs/
*.user

87
ConfigItem/EntityList.cs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
// }
//}
}
}

View 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>

View 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
View 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
View 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
View 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
View 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;
}
// 
@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
View 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.
//}
]
}