Add ModLogs module

Very similar in function to legacy's ModLogs, but with far cleaner code.
Additionally fixes some EC issues regarding the message update delegate.
This commit is contained in:
Noi 2022-07-08 12:03:15 -07:00
parent 08afc224ed
commit f8fe48766b
6 changed files with 260 additions and 31 deletions

View file

@ -0,0 +1,62 @@
using System.Text;
namespace RegexBot.Modules.ModLogs;
/// <summary>
/// Logs certain events of note to a database for moderators to keep track of user behavior.
/// Makes use of a helper class, <see cref="MessageCache"/>.
/// </summary>
[RegexbotModule]
public partial class ModLogs : RegexbotModule {
// TODO consider resurrecting 2.x idea of logging actions to db, making it searchable?
public ModLogs(RegexbotClient bot) : base(bot) {
// TODO missing logging features: joins, leaves, bans, kicks, user edits (nick/username/discr)
DiscordClient.MessageDeleted += HandleDelete;
bot.EcOnMessageUpdate += HandleUpdate;
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
if (config == null) return Task.FromResult<object?>(null);
if (config.Type != JTokenType.Object)
throw new ModuleLoadException("Configuration for this section is invalid.");
var newconf = new ModuleConfig((JObject)config);
Log($"Writing logs to {newconf.ReportingChannel}.");
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
}
private static string MakeTimestamp(DateTimeOffset time) {
var result = new StringBuilder();
//result.Append(time.ToString("yyyy-MM-dd hh:mm:ss"));
result.Append($"<t:{time.ToUnixTimeSeconds()}:f>");
var now = DateTimeOffset.UtcNow;
var diff = now - time;
if (diff < new TimeSpan(3, 0, 0, 0)) {
// Difference less than 3 days. Generate relative time format.
result.Append(" - ");
if (diff.TotalSeconds < 60) {
// Under a minute ago. Show only seconds.
result.Append((int)Math.Ceiling(diff.TotalSeconds) + "s ago");
} else {
// over a minute. Show days, hours, minutes, seconds.
var ts = (int)Math.Ceiling(diff.TotalSeconds);
var m = ts % 3600 / 60;
var h = ts % 86400 / 3600;
var d = ts / 86400;
if (d > 0) result.AppendFormat("{0}d{1}h{2}m", d, h, m);
else if (h > 0) result.AppendFormat("{0}h{1}m", h, m);
else result.AppendFormat("{0}m", m);
result.Append(" ago");
}
}
return result.ToString();
}
private static string GetDefaultAvatarUrl(string discriminator) {
var discVal = int.Parse(discriminator);
return $"https://cdn.discordapp.com/embed/avatars/{discVal % 5}.png";
}
}

View file

@ -0,0 +1,133 @@
using Discord;
using Microsoft.EntityFrameworkCore;
using RegexBot.Data;
using System.Text;
namespace RegexBot.Modules.ModLogs;
// Contains handlers and all logic relating to logging message edits and deletions
public partial class ModLogs {
const string PreviewCutoffNotify = "**Message too long to preview; showing first {0} characters.**\n\n";
const string NotCached = "Message not cached.";
private async Task HandleDelete(Cacheable<IMessage, ulong> argMsg, Cacheable<IMessageChannel, ulong> argChannel) {
const int MaxPreviewLength = 750;
if (argChannel.Value is not SocketTextChannel channel) return;
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
if (reportChannel == null) return;
if ((conf?.LogMessageDeletions ?? false) == false) return;
if (reportChannel.Id == channel.Id) {
Log($"[{channel.Guild.Name}] Message deletion detected in the reporting channel. Regular report has been suppressed.");
return;
}
using var db = new BotDatabaseContext();
var cachedMsg = db.GuildMessageCache
.Include(gm => gm.Author)
.Where(gm => gm.MessageId == (long)argMsg.Id)
.SingleOrDefault();
var reportEmbed = new EmbedBuilder()
.WithTitle("Message deleted")
.WithCurrentTimestamp();
if (cachedMsg != null) {
if (cachedMsg.Content.Length > MaxPreviewLength) {
reportEmbed.Description = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
cachedMsg.Content[MaxPreviewLength..];
} else {
reportEmbed.Description = cachedMsg.Content;
}
if (cachedMsg.Author == null) {
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"User ID {cachedMsg.AuthorId}",
IconUrl = GetDefaultAvatarUrl("0")
};
} else {
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"{cachedMsg.Author.Username}#{cachedMsg.Author.Discriminator}",
IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator)
};
}
} else {
reportEmbed.Description = NotCached;
}
var contextStr = new StringBuilder();
contextStr.AppendLine($"User: {(cachedMsg != null ? $"<@!{cachedMsg.AuthorId}>" : "Unknown")}");
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}");
if (cachedMsg?.EditedAt != null) contextStr.AppendLine($"Last edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}");
contextStr.AppendLine($"Message ID: {argMsg.Id}");
reportEmbed.AddField(new EmbedFieldBuilder() {
Name = "Context",
Value = contextStr.ToString()
});
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
}
private async Task HandleUpdate(CachedGuildMessage? oldMsg, SocketMessage newMsg) {
const int MaxPreviewLength = 500;
var channel = (SocketTextChannel)newMsg.Channel;
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
if (reportChannel == null) return;
if ((conf?.LogMessageEdits ?? false) == false) return;
if (reportChannel.Id == channel.Id) {
Log($"[{channel.Guild.Name}] Message edit detected in the reporting channel. Regular report has been suppressed.");
return;
}
var reportEmbed = new EmbedBuilder()
.WithTitle("Message edited")
.WithCurrentTimestamp();
Console.WriteLine(reportEmbed.Build().ToString());
reportEmbed.Author = new EmbedAuthorBuilder() {
Name = $"{newMsg.Author.Username}#{newMsg.Author.Discriminator}",
IconUrl = newMsg.Author.GetAvatarUrl() ?? newMsg.Author.GetDefaultAvatarUrl()
};
Console.WriteLine(reportEmbed.Build().ToString());
var oldField = new EmbedFieldBuilder() { Name = "Old" };
if (oldMsg != null) {
if (oldMsg.Content.Length > MaxPreviewLength) {
oldField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
oldMsg.Content[MaxPreviewLength..];
} else {
oldField.Value = oldMsg.Content;
}
} else {
oldField.Value = NotCached;
}
reportEmbed.AddField(oldField);
Console.WriteLine(reportEmbed.Build().ToString());
// TODO shorten 'new' preview, add clickable? check if this would be good usability-wise
var newField = new EmbedFieldBuilder() { Name = "New" };
if (newMsg.Content.Length > MaxPreviewLength) {
newField.Value = string.Format(PreviewCutoffNotify, MaxPreviewLength) +
newMsg.Content[MaxPreviewLength..];
} else {
newField.Value = newMsg.Content;
}
reportEmbed.AddField(newField);
Console.WriteLine(reportEmbed.Build().ToString());
var contextStr = new StringBuilder();
contextStr.AppendLine($"User: <@!{newMsg.Author.Id}>");
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
if ((oldMsg?.EditedAt) == null) contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}");
else contextStr.AppendLine($"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}");
contextStr.AppendLine($"Message ID: {newMsg.Id}");
var contextField = new EmbedFieldBuilder() {
Name = "Context",
Value = contextStr.ToString()
};
reportEmbed.AddField(contextField);
Console.WriteLine(reportEmbed.Build().ToString());
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
}
}

View file

@ -0,0 +1,21 @@
using RegexBot.Common;
namespace RegexBot.Modules.ModLogs;
class ModuleConfig {
public EntityName ReportingChannel { get; }
public bool LogMessageDeletions { get; }
public bool LogMessageEdits { get; }
public ModuleConfig(JObject config) {
const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name.";
var rptch = config[nameof(ReportingChannel)]?.Value<string>();
if (string.IsNullOrWhiteSpace(rptch)) throw new ModuleLoadException(RptChError);
ReportingChannel = new EntityName(rptch);
if (ReportingChannel.Type != EntityType.Channel) throw new ModuleLoadException(RptChError);
// Individual logging settings - all default to false
LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value<bool>() ?? false;
LogMessageEdits = config[nameof(LogMessageEdits)]?.Value<bool>() ?? false;
}
}

View file

@ -25,7 +25,7 @@ class EntityCacheService : Service {
=> _uc.DoGuildUserQuery(guildId, search);
// Hooked
internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate {
internal event RegexbotClient.EcMessageUpdateHandler? OnCachePreUpdate {
add { lock (_mc) _mc.OnCachePreUpdate += value; }
remove { lock (_mc) _mc.OnCachePreUpdate -= value; }
}

View file

@ -1,4 +1,5 @@
using RegexBot.Data;
using Discord.WebSocket;
using RegexBot.Data;
using RegexBot.Services.EntityCache;
namespace RegexBot;
@ -28,14 +29,20 @@ partial class RegexbotClient {
/// Fired after a message edit, when the message cache is about to be updated with the edited message.
/// </summary>
/// <remarks>
/// An alternative to <seealso cref="Discord.WebSocket.BaseSocketClient.MessageUpdated"/>.<br />
/// This event is fired in response to a guild message being edited and provides handlers with existing
/// cached contents before it is updated and the previous contents permanently lost.
/// This event serves as an alternative to <seealso cref="BaseSocketClient.MessageUpdated"/>,
/// pulling the previous state of the message from the entity cache instead of the library's cache.
/// </remarks>
public event CachePreUpdateHandler? OnCachePreUpdate {
public event EcMessageUpdateHandler? EcOnMessageUpdate {
add { _svcEntityCache.OnCachePreUpdate += value; }
remove { _svcEntityCache.OnCachePreUpdate -= value; }
}
public delegate Task CachePreUpdateHandler(CachedGuildMessage cachedMsg);
/// <summary>
/// Delegate used for the <seealso cref="EcOnMessageUpdate"/> event.
/// </summary>
/// <params>
/// <param name="oldMsg">The previous state of the message prior to being updated, as known by the entity cache.</param>
/// <param name="newMsg">The new, updated incoming message.</param>
/// </params>
public delegate Task EcMessageUpdateHandler(CachedGuildMessage? oldMsg, SocketMessage newMsg);
}

View file

@ -6,7 +6,7 @@ using static RegexBot.RegexbotClient;
namespace RegexBot.Services.EntityCache;
class MessageCachingSubservice {
// Hooked
public event CachePreUpdateHandler? OnCachePreUpdate;
public event EcMessageUpdateHandler? OnCachePreUpdate;
private readonly Action<string, bool> _log;
@ -17,17 +17,25 @@ class MessageCachingSubservice {
}
private Task DiscordClient_MessageReceived(SocketMessage arg)
=> AddOrUpdateCacheItemAsync(arg);
=> AddOrUpdateCacheItemAsync(arg, false);
private Task DiscordClient_MessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
=> AddOrUpdateCacheItemAsync(arg2);
=> AddOrUpdateCacheItemAsync(arg2, true);
private async Task AddOrUpdateCacheItemAsync(SocketMessage arg) {
private async Task AddOrUpdateCacheItemAsync(SocketMessage arg, bool isUpdate) {
if (!Common.Misc.IsValidUserMessage(arg, out _)) return;
using var db = new BotDatabaseContext();
CachedGuildMessage? msg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault();
if (msg == null) {
msg = new() {
using var db = new BotDatabaseContext();
CachedGuildMessage? cachedMsg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault();
if (isUpdate) {
// Alternative for Discord.Net's MessageUpdated handler:
// Notify subscribers of message update using EC entry for the previous message state
var oldMsg = cachedMsg?.MemberwiseClone();
await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg, arg));
}
if (cachedMsg == null) {
cachedMsg = new() {
MessageId = (long)arg.Id,
AuthorId = (long)arg.Author.Id,
GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id,
@ -35,30 +43,28 @@ class MessageCachingSubservice {
AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(),
Content = arg.Content
};
db.GuildMessageCache.Add(msg);
db.GuildMessageCache.Add(cachedMsg);
} else {
// Notify any listeners of cache update before it happens
var oldMsg = msg.MemberwiseClone();
await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg));
msg.EditedAt = DateTimeOffset.UtcNow;
msg.Content = arg.Content;
msg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList();
db.GuildMessageCache.Update(msg);
cachedMsg.EditedAt = DateTimeOffset.UtcNow;
cachedMsg.Content = arg.Content;
cachedMsg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList();
db.GuildMessageCache.Update(cachedMsg);
}
await db.SaveChangesAsync();
}
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage msg) {
CachePreUpdateHandler? eventList;
lock (this) eventList = OnCachePreUpdate;
if (eventList == null) return;
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage? oldMsg, SocketMessage newMsg) {
Delegate[]? subscribers;
lock (this) {
subscribers = OnCachePreUpdate?.GetInvocationList();
if (subscribers == null || subscribers.Length == 0) return;
}
foreach (var handler in eventList.GetInvocationList()) {
foreach (var handler in subscribers) {
try {
await (Task)handler.DynamicInvoke(msg)!;
await (Task)handler.DynamicInvoke(oldMsg, newMsg)!;
} catch (Exception ex) {
_log($"Unhandled exception in {nameof(RegexbotClient.OnCachePreUpdate)} handler '{handler.Method.Name}':", false);
_log($"Unhandled exception in {nameof(RegexbotClient.EcOnMessageUpdate)} handler '{handler.Method.Name}':", false);
_log(ex.ToString(), false);
}
}