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:
parent
08afc224ed
commit
f8fe48766b
6 changed files with 260 additions and 31 deletions
62
RegexBot-Modules/ModLogs/ModLogs.cs
Normal file
62
RegexBot-Modules/ModLogs/ModLogs.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
133
RegexBot-Modules/ModLogs/ModLogs_Messages.cs
Normal file
133
RegexBot-Modules/ModLogs/ModLogs_Messages.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
21
RegexBot-Modules/ModLogs/ModuleConfig.cs
Normal file
21
RegexBot-Modules/ModLogs/ModuleConfig.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ class EntityCacheService : Service {
|
||||||
=> _uc.DoGuildUserQuery(guildId, search);
|
=> _uc.DoGuildUserQuery(guildId, search);
|
||||||
|
|
||||||
// Hooked
|
// Hooked
|
||||||
internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate {
|
internal event RegexbotClient.EcMessageUpdateHandler? OnCachePreUpdate {
|
||||||
add { lock (_mc) _mc.OnCachePreUpdate += value; }
|
add { lock (_mc) _mc.OnCachePreUpdate += value; }
|
||||||
remove { lock (_mc) _mc.OnCachePreUpdate -= value; }
|
remove { lock (_mc) _mc.OnCachePreUpdate -= value; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using RegexBot.Data;
|
using Discord.WebSocket;
|
||||||
|
using RegexBot.Data;
|
||||||
using RegexBot.Services.EntityCache;
|
using RegexBot.Services.EntityCache;
|
||||||
|
|
||||||
namespace RegexBot;
|
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.
|
/// Fired after a message edit, when the message cache is about to be updated with the edited message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An alternative to <seealso cref="Discord.WebSocket.BaseSocketClient.MessageUpdated"/>.<br />
|
/// This event serves as an alternative to <seealso cref="BaseSocketClient.MessageUpdated"/>,
|
||||||
/// This event is fired in response to a guild message being edited and provides handlers with existing
|
/// pulling the previous state of the message from the entity cache instead of the library's cache.
|
||||||
/// cached contents before it is updated and the previous contents permanently lost.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event CachePreUpdateHandler? OnCachePreUpdate {
|
public event EcMessageUpdateHandler? EcOnMessageUpdate {
|
||||||
add { _svcEntityCache.OnCachePreUpdate += value; }
|
add { _svcEntityCache.OnCachePreUpdate += value; }
|
||||||
remove { _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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ using static RegexBot.RegexbotClient;
|
||||||
namespace RegexBot.Services.EntityCache;
|
namespace RegexBot.Services.EntityCache;
|
||||||
class MessageCachingSubservice {
|
class MessageCachingSubservice {
|
||||||
// Hooked
|
// Hooked
|
||||||
public event CachePreUpdateHandler? OnCachePreUpdate;
|
public event EcMessageUpdateHandler? OnCachePreUpdate;
|
||||||
|
|
||||||
private readonly Action<string, bool> _log;
|
private readonly Action<string, bool> _log;
|
||||||
|
|
||||||
|
@ -17,17 +17,25 @@ class MessageCachingSubservice {
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task DiscordClient_MessageReceived(SocketMessage arg)
|
private Task DiscordClient_MessageReceived(SocketMessage arg)
|
||||||
=> AddOrUpdateCacheItemAsync(arg);
|
=> AddOrUpdateCacheItemAsync(arg, false);
|
||||||
private Task DiscordClient_MessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
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;
|
if (!Common.Misc.IsValidUserMessage(arg, out _)) return;
|
||||||
using var db = new BotDatabaseContext();
|
|
||||||
|
|
||||||
CachedGuildMessage? msg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault();
|
using var db = new BotDatabaseContext();
|
||||||
if (msg == null) {
|
CachedGuildMessage? cachedMsg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault();
|
||||||
msg = new() {
|
|
||||||
|
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,
|
MessageId = (long)arg.Id,
|
||||||
AuthorId = (long)arg.Author.Id,
|
AuthorId = (long)arg.Author.Id,
|
||||||
GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id,
|
GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id,
|
||||||
|
@ -35,30 +43,28 @@ class MessageCachingSubservice {
|
||||||
AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(),
|
AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(),
|
||||||
Content = arg.Content
|
Content = arg.Content
|
||||||
};
|
};
|
||||||
db.GuildMessageCache.Add(msg);
|
db.GuildMessageCache.Add(cachedMsg);
|
||||||
} else {
|
} else {
|
||||||
// Notify any listeners of cache update before it happens
|
cachedMsg.EditedAt = DateTimeOffset.UtcNow;
|
||||||
var oldMsg = msg.MemberwiseClone();
|
cachedMsg.Content = arg.Content;
|
||||||
await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg));
|
cachedMsg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList();
|
||||||
|
db.GuildMessageCache.Update(cachedMsg);
|
||||||
msg.EditedAt = DateTimeOffset.UtcNow;
|
|
||||||
msg.Content = arg.Content;
|
|
||||||
msg.AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList();
|
|
||||||
db.GuildMessageCache.Update(msg);
|
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage msg) {
|
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage? oldMsg, SocketMessage newMsg) {
|
||||||
CachePreUpdateHandler? eventList;
|
Delegate[]? subscribers;
|
||||||
lock (this) eventList = OnCachePreUpdate;
|
lock (this) {
|
||||||
if (eventList == null) return;
|
subscribers = OnCachePreUpdate?.GetInvocationList();
|
||||||
|
if (subscribers == null || subscribers.Length == 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var handler in eventList.GetInvocationList()) {
|
foreach (var handler in subscribers) {
|
||||||
try {
|
try {
|
||||||
await (Task)handler.DynamicInvoke(msg)!;
|
await (Task)handler.DynamicInvoke(oldMsg, newMsg)!;
|
||||||
} catch (Exception ex) {
|
} 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);
|
_log(ex.ToString(), false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue