From 4f896e83114650a378de2ee5b2e7541655f9898c Mon Sep 17 00:00:00 2001 From: Noi Date: Tue, 16 Aug 2022 12:37:06 -0700 Subject: [PATCH] Implement moderation logging to database Further commits will implement a system to propagate these logs, allowing modules and services to act on them regardless of their origin. Additionally, further commits shall implement these changes within built-in modules to allow for their immediate use. --- Data/BotDatabaseContext.cs | 15 +++- Data/ModLogEntry.cs | 43 +++++++++++ Services/CommonFunctions/CF_ModLogs.Hooks.cs | 80 ++++++++++++++++++++ Services/CommonFunctions/CF_ModLogs.cs | 45 +++++++++++ Services/CommonFunctions/CF_Removals.cs | 20 ++--- Services/CommonFunctions/LogAppendResult.cs | 46 +++++++++++ Services/CommonFunctions/ModLogType.cs | 31 ++++++++ 7 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 Data/ModLogEntry.cs create mode 100644 Services/CommonFunctions/CF_ModLogs.Hooks.cs create mode 100644 Services/CommonFunctions/CF_ModLogs.cs create mode 100644 Services/CommonFunctions/LogAppendResult.cs create mode 100644 Services/CommonFunctions/ModLogType.cs diff --git a/Data/BotDatabaseContext.cs b/Data/BotDatabaseContext.cs index 88ab07b..bb269f9 100644 --- a/Data/BotDatabaseContext.cs +++ b/Data/BotDatabaseContext.cs @@ -34,6 +34,11 @@ public class BotDatabaseContext : DbContext { /// public DbSet GuildMessageCache { get; set; } = null!; + /// + /// Retrieves the moderator logs. + /// + public DbSet ModLogs { get; set; } = null!; + /// protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder @@ -43,10 +48,12 @@ public class BotDatabaseContext : DbContext { /// protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength()); - modelBuilder.Entity(entity => { - entity.HasKey(e => new { e.UserId, e.GuildId }); - entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()"); + modelBuilder.Entity(e => { + e.HasKey(p => new { p.UserId, p.GuildId }); + e.Property(p => p.FirstSeenTime).HasDefaultValueSql("now()"); }); - modelBuilder.Entity(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()")); + modelBuilder.Entity(e => e.Property(p => p.CreatedAt).HasDefaultValueSql("now()")); + modelBuilder.HasPostgresEnum(); + modelBuilder.Entity(e => e.Property(p => p.Timestamp).HasDefaultValueSql("now()")); } } diff --git a/Data/ModLogEntry.cs b/Data/ModLogEntry.cs new file mode 100644 index 0000000..99b1011 --- /dev/null +++ b/Data/ModLogEntry.cs @@ -0,0 +1,43 @@ +using RegexBot.Common; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RegexBot.Data; +/// +/// Represents a moderation log entry. +/// +[Table("modlogs")] +public class ModLogEntry { + /// + /// Gets the ID number for this entry. + /// + [Key] + public int LogId { get; set; } + + /// + /// Gets the date and time when this entry was logged. + /// + public DateTimeOffset Timestamp { get; set; } + + /// + public long GuildId { get; set; } + + /// + public long UserId { get; set; } + + /// + /// Gets the type of log message this represents. + /// + public ModLogType LogType { get; set; } + + /// + /// Gets the the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + public string IssuedBy { get; set; } = null!; + + /// + /// Gets any additional message associated with this log entry. + /// + public string? Message { get; set; } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_ModLogs.Hooks.cs b/Services/CommonFunctions/CF_ModLogs.Hooks.cs new file mode 100644 index 0000000..3eaf29f --- /dev/null +++ b/Services/CommonFunctions/CF_ModLogs.Hooks.cs @@ -0,0 +1,80 @@ +#pragma warning disable CA1822 // "Mark members as static" - will not make static to encourage better structure +using Discord.Net; +using RegexBot.Common; +using RegexBot.Data; + +namespace RegexBot; +partial class RegexbotClient { + /// + /// Appends a note to the moderation log regarding the given user, containing the given message. + /// + /// + /// Unlike warnings, notes are private and intended for moderators only. Users are never notified and may + /// never be aware of notes associated with them. Otherwise, they function as any other entry in the log. + /// + /// The guild which the target user is associated. + /// The snowflake ID of the target user. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The message to add to this entry. + /// + /// The resulting from the creation of this note. + /// + public ModLogEntry AddUserNote(SocketGuild guild, ulong targetUser, string source, string? message) { + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)targetUser, + LogType = ModLogType.Note, + IssuedBy = source, + Message = message + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + db.SaveChanges(); + } + // TODO notify + return entry; + } + + /// + /// Warns a user, adding an entry to the moderation log and also attempting to notify the user. + /// + /// The guild which the target user is associated. + /// The snowflake ID of the target user. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The message to add to this entry. + /// + /// A tuple containing the resulting and . + /// + public async Task<(ModLogEntry, LogAppendResult)> AddUserWarnAsync(SocketGuild guild, ulong targetUser, string source, string? message) { + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)targetUser, + LogType = ModLogType.Warn, + IssuedBy = source, + Message = message + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + await db.SaveChangesAsync(); + } + // TODO notify + + // Attempt warning message + var userSearch = _svcEntityCache.QueryUserCache(targetUser.ToString()); + var userDisp = userSearch != null + ? $" user **{userSearch.Username}#{userSearch.Discriminator}**" + : $" user with ID **{targetUser}**"; + var targetGuildUser = guild.GetUser(targetUser); + if (targetGuildUser == null) return (entry, new LogAppendResult( + new HttpException(System.Net.HttpStatusCode.NotFound, null), entry.LogId, userDisp)); + + var sendStatus = await _svcCommonFunctions.SendUserWarningAsync(targetGuildUser, message); + return (entry, new LogAppendResult(sendStatus, entry.LogId, userDisp)); + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_ModLogs.cs b/Services/CommonFunctions/CF_ModLogs.cs new file mode 100644 index 0000000..8f5bee1 --- /dev/null +++ b/Services/CommonFunctions/CF_ModLogs.cs @@ -0,0 +1,45 @@ +#pragma warning disable CA1822 // "Mark members as static" - will not make static to encourage better structure +using Discord.Net; +using RegexBot.Data; + +namespace RegexBot.Services.CommonFunctions; +internal partial class CommonFunctionsService : Service { + + // things this should do: + // set a note + // set a warn (like note, but spicy) + // -> return with a WarnLogResult? And send it down the chute... + + // Called by EF_Removals, this processes a removal into a log entry. + // A notification for this entry is then propagated. + private void ModLogsProcessRemoval(ulong guildId, ulong targetId, ModLogType remType, string source, string? logReason) { + var entry = new ModLogEntry() { + GuildId = (long)guildId, + UserId = (long)targetId, + LogType = remType, + IssuedBy = source, + Message = logReason + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + db.SaveChanges(); + } + // TODO notify entry + } + + internal async Task SendUserWarningAsync(SocketGuildUser target, string? reason) { + const string DMTemplate = "You were warned in {0}"; + const string DMTemplateReason = " with the following message:\n{1}"; + + var outMessage = string.IsNullOrWhiteSpace(reason) + ? string.Format(DMTemplate + ".", target.Guild.Name) + : string.Format(DMTemplate + DMTemplateReason, target.Guild.Name, reason); + var dch = await target.CreateDMChannelAsync(); + try { + await dch.SendMessageAsync(outMessage); + } catch (HttpException ex) { + return ex; + } + return default; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_Removals.cs b/Services/CommonFunctions/CF_Removals.cs index b2b741a..e1052d4 100644 --- a/Services/CommonFunctions/CF_Removals.cs +++ b/Services/CommonFunctions/CF_Removals.cs @@ -1,16 +1,11 @@ +#pragma warning disable CA1822 // "Mark members as static" - will not make static to encourage better structure using Discord.Net; namespace RegexBot.Services.CommonFunctions; internal partial class CommonFunctionsService : Service { // Hooked (indirectly) - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] - internal async Task BanOrKickAsync(RemovalType t, - SocketGuild guild, - string source, - ulong target, - int banPurgeDays, - string? logReason, - bool sendDmToTarget) { + internal async Task BanOrKickAsync(RemovalType t, SocketGuild guild, string source, ulong target, + int banPurgeDays, string? logReason, bool sendDmToTarget) { if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'."); var dmSuccess = true; @@ -18,8 +13,6 @@ internal partial class CommonFunctionsService : Service { // Can't kick without obtaining user object. Quit here. if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); - // TODO notify services here as soon as we get some who will want to listen to this (use source parameter) - // Send DM notification if (sendDmToTarget) { if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason); @@ -35,10 +28,13 @@ internal partial class CommonFunctionsService : Service { return new BanKickResult(ex, dmSuccess, false, t, target); } - return new BanKickResult(null, dmSuccess, false, t, target); + // Report successful action + var result = new BanKickResult(null, dmSuccess, false, t, target); + ModLogsProcessRemoval(guild.Id, target, t == RemovalType.Ban ? ModLogType.Ban : ModLogType.Kick, source, logReason); + return result; } - private static async Task BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) { + private async Task BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) { const string DMTemplate = "You have been {0} from {1}"; const string DMTemplateReason = " for the following reason:\n{2}"; diff --git a/Services/CommonFunctions/LogAppendResult.cs b/Services/CommonFunctions/LogAppendResult.cs new file mode 100644 index 0000000..102b6d1 --- /dev/null +++ b/Services/CommonFunctions/LogAppendResult.cs @@ -0,0 +1,46 @@ +using Discord.Net; + +namespace RegexBot; +/// +/// Contains information on success/failure outcomes for a warn operation. +/// +public class LogAppendResult { + private readonly int _logId; + private readonly string _rptDisplayName; + + /// + /// Gets the exception thrown, if any, when attempting to send the warning to the target. + /// + public HttpException? MessageSendError { get; } + + /// + /// Indicates if the operation failed due to being unable to find the user. + /// + public bool ErrorNotFound => MessageSendError?.HttpCode == System.Net.HttpStatusCode.NotFound; + + /// + /// Indicates if the operation failed due to a permissions issue. + /// + public bool ErrorForbidden => MessageSendError?.HttpCode == System.Net.HttpStatusCode.Forbidden; + + /// + /// Indicates if the operation completed successfully. + /// + public bool Success => MessageSendError == null; + + internal LogAppendResult(HttpException? error, int logId, string reportDispName) { + _logId = logId; + MessageSendError = error; + _rptDisplayName = reportDispName; + } + + /// + /// Returns a message representative of this result that may be posted as-is + /// within a Discord channel. + /// + public string GetResultString() { + var msg = $":white_check_mark: Warning \\#{_logId} logged for {_rptDisplayName}."; + if (!Success) msg += "\n:warning: **User did not receive warning message.** This must be discussed manually."; + return msg; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/ModLogType.cs b/Services/CommonFunctions/ModLogType.cs new file mode 100644 index 0000000..332214f --- /dev/null +++ b/Services/CommonFunctions/ModLogType.cs @@ -0,0 +1,31 @@ +namespace RegexBot; +/// +/// Specifies the type of action or event represented by a +/// or . +/// +public enum ModLogType { + /// + /// An unspecified logging type. + /// + Other, + /// + /// A note appended to a user's log for moderator reference. + /// + Note, + /// + /// A warning. Similar to a note, but with higher priority and presented to the user when issued. + /// + Warn, + /// + /// A timeout, preventing the user from speaking for some amount of time. + /// + Timeout, + /// + /// A forced removal from the server. + /// + Kick, + /// + /// A forced removal from the server, with the user additionally getting added to the ban list. + /// + Ban +} \ No newline at end of file