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);
|
||||
|
||||
// Hooked
|
||||
internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate {
|
||||
internal event RegexbotClient.EcMessageUpdateHandler? OnCachePreUpdate {
|
||||
add { 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue