Merge branch 'dev/modtools'
This commit is contained in:
commit
cb967b7383
4 changed files with 259 additions and 44 deletions
|
@ -11,6 +11,10 @@ using System.Threading.Tasks;
|
|||
|
||||
namespace Noikoio.RegexBot.Module.ModTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for ModTools command.
|
||||
/// We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{Label}-type command")]
|
||||
abstract class CommandBase
|
||||
{
|
||||
|
@ -18,6 +22,7 @@ namespace Noikoio.RegexBot.Module.ModTools
|
|||
private readonly string _label;
|
||||
private readonly string _command;
|
||||
|
||||
protected ModTools Mt => _modtools;
|
||||
public string Label => _label;
|
||||
public string Command => _command;
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ using System.Threading.Tasks;
|
|||
|
||||
namespace Noikoio.RegexBot.Module.ModTools.Commands
|
||||
{
|
||||
|
||||
class BanKick : CommandBase
|
||||
{
|
||||
// Ban and kick commands are highly similar in implementation, and thus are handled in a single class.
|
||||
|
@ -20,6 +19,11 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
|
|||
private readonly string _successMsg;
|
||||
private readonly string _notifyMsg;
|
||||
|
||||
const string DefaultMsg = "You have been {0} from $s for the following reason:\n$r";
|
||||
const string DefaultMsgBanAppend = "\n\nIf the moderators have allowed it, you may petition your ban by" +
|
||||
" submitting **one** message to the moderation team. To do so, reply to this message with" +
|
||||
" `!petition [Your message here]`.";
|
||||
|
||||
// Configuration:
|
||||
// "forcereason" - boolean; Force a reason to be given. Defaults to false.
|
||||
// "purgedays" - integer; Number of days of target's post history to delete, if banning.
|
||||
|
@ -41,8 +45,8 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
|
|||
if (conf["notifymsg"] == null)
|
||||
{
|
||||
// Message not specified - use default
|
||||
string act = _mode == CommandMode.Ban ? "banned" : "kicked";
|
||||
_notifyMsg = $"You have been {act} from $s for the following reason:\n$r";
|
||||
_notifyMsg = string.Format(DefaultMsg, mode == CommandMode.Ban ? "banned" : "kicked");
|
||||
if (_mode == CommandMode.Ban) _notifyMsg += DefaultMsgBanAppend;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -127,6 +131,9 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
|
|||
}
|
||||
else notifyfail = true;
|
||||
|
||||
// Give target user ability to petition
|
||||
if (_mode == CommandMode.Ban) Mt.AddPetition(g.Id, targetuid);
|
||||
|
||||
// Do the action
|
||||
try
|
||||
{
|
||||
|
@ -134,8 +141,12 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
|
|||
if (reason != null) reasonlog += $" Reason: {reason}";
|
||||
reasonlog = Uri.EscapeDataString(reasonlog);
|
||||
#warning Remove EscapeDataString call on next Discord.Net update
|
||||
#if !DEBUG
|
||||
if (_mode == CommandMode.Ban) await g.AddBanAsync(targetuid, _purgeDays, reasonlog);
|
||||
else await targetobj.KickAsync(reason);
|
||||
#else
|
||||
#warning "Actual kick/ban action is DISABLED during debug."
|
||||
#endif
|
||||
string resultmsg = BuildSuccessMessage(targetdisp);
|
||||
if (notifyfail)
|
||||
{
|
||||
|
|
75
Module/ModTools/ConfigItem.cs
Normal file
75
Module/ModTools/ConfigItem.cs
Normal file
|
@ -0,0 +1,75 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Noikoio.RegexBot.Module.ModTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents ModTools configuration within one server.
|
||||
/// </summary>
|
||||
class ConfigItem
|
||||
{
|
||||
private EntityName? _petitionReportCh;
|
||||
private readonly ReadOnlyDictionary<string, CommandBase> _cmdInstances;
|
||||
|
||||
public EntityName? PetitionReportingChannel => _petitionReportCh;
|
||||
public ReadOnlyDictionary<string, CommandBase> Commands => _cmdInstances;
|
||||
|
||||
public ConfigItem(ModTools instance, JToken inconf)
|
||||
{
|
||||
if (inconf.Type != JTokenType.Object)
|
||||
{
|
||||
throw new RuleImportException("Configuration for this section is invalid.");
|
||||
}
|
||||
var config = (JObject)inconf;
|
||||
|
||||
// Ban petition reporting channel
|
||||
var petitionstr = config["PetitionRelay"]?.Value<string>();
|
||||
if (string.IsNullOrEmpty(petitionstr)) _petitionReportCh = null;
|
||||
else if (petitionstr.Length > 1 && petitionstr[0] != '#')
|
||||
{
|
||||
// Not a channel.
|
||||
throw new RuleImportException("PetitionRelay value must be set to a channel.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_petitionReportCh = new EntityName(petitionstr.Substring(1), EntityType.Channel);
|
||||
}
|
||||
|
||||
// Command instances
|
||||
var commands = new Dictionary<string, CommandBase>(StringComparer.OrdinalIgnoreCase);
|
||||
var commandconf = config["Commands"];
|
||||
if (commandconf != null)
|
||||
{
|
||||
if (commandconf.Type != JTokenType.Object)
|
||||
{
|
||||
throw new RuleImportException("CommandDefs is not properly defined.");
|
||||
}
|
||||
|
||||
foreach (var def in commandconf.Children<JProperty>())
|
||||
{
|
||||
string label = def.Name;
|
||||
var cmd = CommandBase.CreateInstance(instance, def);
|
||||
if (commands.ContainsKey(cmd.Command))
|
||||
throw new RuleImportException(
|
||||
$"{label}: 'command' value must not be equal to that of another definition. " +
|
||||
$"Given value is being used for {commands[cmd.Command].Label}.");
|
||||
|
||||
commands.Add(cmd.Command, cmd);
|
||||
}
|
||||
}
|
||||
_cmdInstances = new ReadOnlyDictionary<string, CommandBase>(commands);
|
||||
}
|
||||
|
||||
public void UpdatePetitionChannel(ulong id)
|
||||
{
|
||||
if (!PetitionReportingChannel.HasValue) return;
|
||||
if (PetitionReportingChannel.Value.Id.HasValue) return; // nothing to update
|
||||
|
||||
// For lack of a better option - create a new EntityName with ID already provided
|
||||
_petitionReportCh = new EntityName($"{id}::{PetitionReportingChannel.Value.Name}", EntityType.Channel);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,19 +1,18 @@
|
|||
using Discord.WebSocket;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Module.ModTools
|
||||
{
|
||||
/// <summary>
|
||||
/// ModTools module object.
|
||||
/// Implements moderation commands that are individually defined and enabled in configuration.
|
||||
/// ModTools module.
|
||||
/// This class manages reading configuration and creating instances based on it.
|
||||
/// </summary>
|
||||
// We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime.
|
||||
class ModTools : BotModule
|
||||
{
|
||||
public override string Name => "ModTools";
|
||||
|
@ -25,12 +24,35 @@ namespace Noikoio.RegexBot.Module.ModTools
|
|||
|
||||
private async Task Client_MessageReceived(SocketMessage arg)
|
||||
{
|
||||
// Ignore bots
|
||||
// Always ignore bots
|
||||
if (arg.Author.IsBot) return;
|
||||
|
||||
// Disregard if not in a guild
|
||||
SocketGuild g = (arg.Author as SocketGuildUser)?.Guild;
|
||||
if (g == null) return;
|
||||
if (arg.Channel is IDMChannel) await PetitionRelayCheck(arg);
|
||||
else if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg);
|
||||
}
|
||||
|
||||
[ConfigSection("modtools")]
|
||||
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||
{
|
||||
// Constructor throws exception on config errors
|
||||
var conf = new ConfigItem(this, configSection);
|
||||
|
||||
// Log results
|
||||
if (conf.Commands.Count > 0)
|
||||
await Log(conf.Commands.Count + " command definition(s) loaded.");
|
||||
if (conf.PetitionReportingChannel.HasValue)
|
||||
await Log("Ban petitioning has been enabled.");
|
||||
|
||||
return conf;
|
||||
}
|
||||
|
||||
private new ConfigItem GetConfig(ulong guildId) => (ConfigItem)base.GetConfig(guildId);
|
||||
|
||||
public new Task Log(string text) => base.Log(text);
|
||||
|
||||
private async Task CommandCheckInvoke(SocketMessage arg)
|
||||
{
|
||||
SocketGuild g = ((SocketGuildUser)arg.Author).Guild;
|
||||
|
||||
// Get guild config
|
||||
ServerConfig sc = RegexBot.Config.Servers.FirstOrDefault(s => s.Id == g.Id);
|
||||
|
@ -42,15 +64,12 @@ namespace Noikoio.RegexBot.Module.ModTools
|
|||
// Disregard if the message contains a newline character
|
||||
if (arg.Content.Contains("\n")) return;
|
||||
|
||||
// Check for and invoke command...
|
||||
// Check for and invoke command
|
||||
string cmdchk;
|
||||
int spc = arg.Content.IndexOf(' ');
|
||||
if (spc != -1) cmdchk = arg.Content.Substring(0, spc);
|
||||
else cmdchk = arg.Content;
|
||||
if (((IDictionary<string, CommandBase>)GetConfig(g.Id)).TryGetValue(cmdchk, out var c))
|
||||
{
|
||||
// ...on the thread pool.
|
||||
await Task.Run(async () =>
|
||||
if (GetConfig(g.Id).Commands.TryGetValue(cmdchk, out var c))
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -62,30 +81,135 @@ namespace Noikoio.RegexBot.Module.ModTools
|
|||
await Log($"Encountered an error for the command '{c.Label}'. Details follow:");
|
||||
await Log(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Ban petitions
|
||||
/// <summary>
|
||||
/// List of available appeals. Key is user (for quick lookup). Value is guild (for quick config resolution).
|
||||
/// TODO expiration?
|
||||
/// </summary>
|
||||
private Dictionary<ulong, ulong> _openPetitions = new Dictionary<ulong, ulong>();
|
||||
public void AddPetition(ulong guild, ulong user)
|
||||
{
|
||||
// Do nothing if disabled
|
||||
if (!GetConfig(guild).PetitionReportingChannel.HasValue) return;
|
||||
lock (_openPetitions) _openPetitions[user] = guild;
|
||||
}
|
||||
private async Task PetitionRelayCheck(SocketMessage msg)
|
||||
{
|
||||
const string PetitionAccepted = "Your petition has been forwarded to the moderators for review.";
|
||||
const string PetitionDenied = "You may not submit a ban petition.";
|
||||
|
||||
// It's possible the sender may still block messages sent to them,
|
||||
// hence the empty catch blocks you'll see up ahead.
|
||||
|
||||
if (!msg.Content.StartsWith("!petition ", StringComparison.InvariantCultureIgnoreCase)) return;
|
||||
|
||||
// Input validation
|
||||
string ptext = msg.Content.Substring(10);
|
||||
if (string.IsNullOrWhiteSpace(ptext))
|
||||
{
|
||||
// Just ignore.
|
||||
return;
|
||||
}
|
||||
if (ptext.Length > 1000)
|
||||
{
|
||||
// Enforce petition length limit.
|
||||
try { await msg.Author.SendMessageAsync("Your petition message is too long. Try again with a shorter message."); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
return;
|
||||
}
|
||||
|
||||
ulong targetGuild = 0;
|
||||
lock (_openPetitions)
|
||||
{
|
||||
if (_openPetitions.TryGetValue(msg.Author.Id, out targetGuild))
|
||||
{
|
||||
_openPetitions.Remove(msg.Author.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetGuild == 0)
|
||||
{
|
||||
// Not in the list. Nothing to do.
|
||||
try { await msg.Author.SendMessageAsync(PetitionDenied); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
return;
|
||||
}
|
||||
var gObj = Client.GetGuild(targetGuild);
|
||||
if (gObj == null)
|
||||
{
|
||||
// Guild is missing. No longer in guild?
|
||||
try { await msg.Author.SendMessageAsync(PetitionDenied); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// Get petition reporting target
|
||||
var pcv = GetConfig(targetGuild).PetitionReportingChannel;
|
||||
if (!pcv.HasValue) return; // No target. This should be logically impossible, but... just in case.
|
||||
var rch = pcv.Value;
|
||||
ISocketMessageChannel rchObj;
|
||||
if (!rch.Id.HasValue)
|
||||
{
|
||||
rchObj = gObj.TextChannels
|
||||
.Where(c => c.Name.Equals(rch.Name, StringComparison.InvariantCultureIgnoreCase))
|
||||
.FirstOrDefault();
|
||||
// Update value if found
|
||||
if (rchObj != null)
|
||||
{
|
||||
GetConfig(targetGuild).UpdatePetitionChannel(rchObj.Id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
rchObj = gObj.GetChannel(rch.Id.Value) as ISocketMessageChannel;
|
||||
}
|
||||
|
||||
if (rchObj == null)
|
||||
{
|
||||
// Channel not found.
|
||||
await Log("Petition reporting channel could not be resolved.");
|
||||
try { await msg.Author.SendMessageAsync(PetitionDenied); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
return;
|
||||
}
|
||||
|
||||
// Ready to relay
|
||||
try
|
||||
{
|
||||
await rchObj.SendMessageAsync("", embed: new EmbedBuilder()
|
||||
{
|
||||
Color = new Color(0x00FFD9),
|
||||
|
||||
Author = new EmbedAuthorBuilder()
|
||||
{
|
||||
Name = $"{msg.Author.ToString()} - Ban petition:",
|
||||
IconUrl = msg.Author.GetAvatarUrl()
|
||||
},
|
||||
Description = ptext,
|
||||
Timestamp = msg.Timestamp,
|
||||
|
||||
Footer = new EmbedFooterBuilder()
|
||||
{
|
||||
Text = "User ID: " + msg.Author.Id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[ConfigSection("modtools")]
|
||||
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||
catch (Discord.Net.HttpException ex)
|
||||
{
|
||||
var commands = new Dictionary<string, CommandBase>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var def in configSection.Children<JProperty>())
|
||||
{
|
||||
string label = def.Name;
|
||||
var cmd = CommandBase.CreateInstance(this, def);
|
||||
if (commands.ContainsKey(cmd.Command))
|
||||
throw new RuleImportException(
|
||||
$"{label}: 'command' value must not be equal to that of another definition. " +
|
||||
$"Given value is being used for {commands[cmd.Command].Label}.");
|
||||
|
||||
commands.Add(cmd.Command, cmd);
|
||||
}
|
||||
await Log($"Loaded {commands.Count} command definition(s).");
|
||||
return new ReadOnlyDictionary<string, CommandBase>(commands);
|
||||
await Log("Failed to relay petition message by " + msg.Author.ToString());
|
||||
await Log(ex.Message);
|
||||
// For the user's point of view, fail silently.
|
||||
try { await msg.Author.SendMessageAsync(PetitionDenied); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
}
|
||||
|
||||
public new Task Log(string text) => base.Log(text);
|
||||
// Success. Notify user.
|
||||
try { await msg.Author.SendMessageAsync(PetitionAccepted); }
|
||||
catch (Discord.Net.HttpException) { }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue