RegexBot/Modules-PublicInstance/RegexModerator/ResponseExecutor.cs

300 lines
12 KiB
C#
Raw Normal View History

using Discord;
using Discord.WebSocket;
using Kerobot.Common;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using static Kerobot.Kerobot;
namespace Kerobot.Modules.RegexModerator
{
/// <summary>
/// Helper class to RegexModerator that executes the appropriate actions associated with a triggered rule.
/// </summary>
class ResponseExecutor
{
private readonly ConfDefinition _rule;
private readonly Kerobot _bot;
private List<(ResponseAction, ResponseExecutionResult)> _results;
public ResponseExecutor(ConfDefinition rule, Kerobot kb)
{
_rule = rule;
_bot = kb;
}
public async Task Execute(SocketMessage msg)
{
var g = ((SocketGuildUser)msg.Author).Guild;
_results = new List<(ResponseAction, ResponseExecutionResult)>();
var tasks = new List<Task>
{
ExecuteAction(DoReplyToChannel, g, msg),
ExecuteAction(DoReplyToInvokerDM, g, msg),
ExecuteAction(DoRoleAdd, g, msg),
ExecuteAction(DoRoleRemove, g, msg),
ExecuteAction(DoBan, g, msg),
ExecuteAction(DoKick, g, msg),
ExecuteAction(DoDelete, g, msg)
// TODO role add/remove: add persistence option
// TODO add note to user log
// TODO add warning to user log
};
await Task.WhenAll(tasks);
// Report can only run after all previous actions have been performed.
await ExecuteAction(DoReport, g, msg);
// TODO pass any local error messages to guild log
}
#region Response actions
/*
* For the sake of creating reports and notifying the user of any issues,
* every response method should have a signature that conforms to that of the
* ResponseAction delegate defined here.
* Methods here should attempt to handle their own expected exceptions, and leave the
* extraordinary exceptions for the wrapper to deal with.
*
* Methods may return null, but MUST only do so if they took no action (meaning, they were
* not meant to take any action per the input configuration). Data within each
* ResponseExecutionResult is then used to build a report (if requested) and/or place
* error messages into the guild log.
*/
delegate Task<ResponseExecutionResult> ResponseAction(SocketGuild g, SocketMessage msg);
const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue.";
private Task<ResponseExecutionResult> DoBan(SocketGuild g, SocketMessage msg)
{
if (_rule.RemovalAction != RemovalType.Ban) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemovalAction);
}
private Task<ResponseExecutionResult> DoKick(SocketGuild g, SocketMessage msg)
{
if (_rule.RemovalAction != RemovalType.Kick) return Task.FromResult<ResponseExecutionResult>(null);
return DoBanOrKick(g, msg, _rule.RemovalAction);
}
private async Task<ResponseExecutionResult> DoBanOrKick(SocketGuild g, SocketMessage msg, RemovalType t)
{
var result = await _bot.BanOrKickAsync(t, g, $"Rule '{_rule.Label}'",
msg.Author.Id, _rule.BanPurgeDays, _rule.RemovalReason, _rule.RemovalSendUserNotification);
if (result.ErrorForbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else if (result.ErrorNotFound)
{
return new ResponseExecutionResult(false, "The target user is no longer in the server.");
}
else return new ResponseExecutionResult(true, null);
// TODO option to announce ban/kick result in the trigger channel
// ^ implementation: take result, reply to channel. don't alter BanOrKickAsync.
}
private async Task<ResponseExecutionResult> DoDelete(SocketGuild g, SocketMessage msg)
{
if (!_rule.DeleteMessage) return null;
try
{
await msg.DeleteAsync();
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
{
return new ResponseExecutionResult(false, "The message has already been deleted.");
}
else throw;
}
}
private async Task<ResponseExecutionResult> DoReplyToChannel(SocketGuild g, SocketMessage msg)
{
if (string.IsNullOrWhiteSpace(_rule.ReplyInChannel)) return null;
try
{
await msg.Channel.SendMessageAsync(_rule.ReplyInChannel);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
private async Task<ResponseExecutionResult> DoReplyToInvokerDM(SocketGuild g, SocketMessage msg)
{
if (string.IsNullOrWhiteSpace(_rule.ReplyInDM)) return null;
var target = await msg.Author.GetOrCreateDMChannelAsync(); // can this throw an exception?
try
{
await target.SendMessageAsync(_rule.ReplyInDM);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, "The target user is not accepting DMs.");
}
else throw;
}
}
private Task<ResponseExecutionResult> DoRoleAdd(SocketGuild g, SocketMessage msg)
=> RoleManipulationResponse(g, msg, true);
private Task<ResponseExecutionResult> DoRoleRemove(SocketGuild g, SocketMessage msg)
=> RoleManipulationResponse(g, msg, false);
private async Task<ResponseExecutionResult> RoleManipulationResponse(SocketGuild g, SocketMessage msg, bool add)
{
EntityName ck;
if (add)
{
if (_rule.RoleAdd == null) return null;
ck = _rule.RoleAdd;
}
else
{
if (_rule.RoleRemove == null) return null;
ck = _rule.RoleRemove;
}
SocketRole target = ck.FindRoleIn(g, false);
if (target == null)
{
return new ResponseExecutionResult(false,
$"Unable to determine the role to be {(add ? "added" : "removed")}. Does it still exist?");
}
try
{
if (add) await ((SocketGuildUser)msg.Author).AddRoleAsync(target);
else await ((SocketGuildUser)msg.Author).RemoveRoleAsync(target);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
#endregion
#region Reporting
private class ResponseExecutionResult
{
public bool Success { get; }
public string Notice { get; }
public ResponseExecutionResult(bool success, string log)
{
Success = success;
Notice = log;
}
}
private async Task ExecuteAction(ResponseAction action, SocketGuild g, SocketMessage arg)
{
ResponseExecutionResult result;
try { result = await action(g, arg); }
catch (Exception ex)
{
result = new ResponseExecutionResult(false,
"An unknown error occurred. The bot maintainer has been notified.");
await _bot.InstanceLogAsync(true, nameof(RegexModerator),
"An unexpected error occurred while executing a response. "
+ $"Guild: {g.Id} - Rule: '{_rule.Label}' - Exception detail:\n"
+ ex.ToString());
}
if (result != null)
{
lock (_results) _results.Add((action, result));
}
}
private async Task<ResponseExecutionResult> DoReport(SocketGuild g, SocketMessage msg)
{
if (_rule.ReportingChannel == null) return null;
// Determine channel before anything else
var ch = _rule.ReportingChannel.FindChannelIn(g, true);
if (ch == null) return new ResponseExecutionResult(false, "Unable to find reporting channel.");
var rptOutput = new StringBuilder();
foreach (var (action, result) in _results) // Locking of _results not necessary at this point
{
if (result == null) continue;
rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:");
rptOutput.Append(" " + action.Method.Name);
if (result.Notice != null)
rptOutput.Append(" - " + result.Notice);
rptOutput.AppendLine();
}
// Report status goes last. It is presumed to succeed. If it fails, the message won't make it anyway.
rptOutput.Append($":white_check_mark: {nameof(DoReport)}");
// We can only afford to show a preview of the message being reported, due to embeds
// being constrained to the same 2000 character limit.
const string TruncateWarning = "**Notice: Full message has been truncated.**\n";
const int TruncateMaxLength = 990;
var invokingLine = msg.Content;
if (invokingLine.Length > TruncateMaxLength)
{
invokingLine = TruncateWarning + invokingLine.Substring(0, TruncateMaxLength - TruncateWarning.Length);
}
var resultEm = new EmbedBuilder()
{
Color = new Color(0xEDCE00), // TODO configurable later?
Author = new EmbedAuthorBuilder()
{
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
IconUrl = msg.Author.GetAvatarUrl()
},
Description = invokingLine,
Footer = new EmbedFooterBuilder() { Text = $"Rule: {_rule.Label}" },
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
}.AddField(new EmbedFieldBuilder()
{
Name = "Actions taken:",
Value = rptOutput.ToString()
}).Build();
try
{
await ch.SendMessageAsync(embed: resultEm);
return new ResponseExecutionResult(true, null);
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
return new ResponseExecutionResult(false, ForbiddenGenericError);
}
else throw;
}
}
#endregion
}
}