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.
This commit is contained in:
Noi 2022-08-16 12:37:06 -07:00
parent a419dd2554
commit 4f896e8311
7 changed files with 264 additions and 16 deletions

View file

@ -34,6 +34,11 @@ public class BotDatabaseContext : DbContext {
/// </summary> /// </summary>
public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!; public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!;
/// <summary>
/// Retrieves the <seealso cref="ModLogEntry">moderator logs</seealso>.
/// </summary>
public DbSet<ModLogEntry> ModLogs { get; set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder => optionsBuilder
@ -43,10 +48,12 @@ public class BotDatabaseContext : DbContext {
/// <inheritdoc /> /// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) { protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength()); modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
modelBuilder.Entity<CachedGuildUser>(entity => { modelBuilder.Entity<CachedGuildUser>(e => {
entity.HasKey(e => new { e.UserId, e.GuildId }); e.HasKey(p => new { p.UserId, p.GuildId });
entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()"); e.Property(p => p.FirstSeenTime).HasDefaultValueSql("now()");
}); });
modelBuilder.Entity<CachedGuildMessage>(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()")); modelBuilder.Entity<CachedGuildMessage>(e => e.Property(p => p.CreatedAt).HasDefaultValueSql("now()"));
modelBuilder.HasPostgresEnum<ModLogType>();
modelBuilder.Entity<ModLogEntry>(e => e.Property(p => p.Timestamp).HasDefaultValueSql("now()"));
} }
} }

43
Data/ModLogEntry.cs Normal file
View file

@ -0,0 +1,43 @@
using RegexBot.Common;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
/// <summary>
/// Represents a moderation log entry.
/// </summary>
[Table("modlogs")]
public class ModLogEntry {
/// <summary>
/// Gets the ID number for this entry.
/// </summary>
[Key]
public int LogId { get; set; }
/// <summary>
/// Gets the date and time when this entry was logged.
/// </summary>
public DateTimeOffset Timestamp { get; set; }
/// <inheritdoc cref="CachedGuildUser.GuildId"/>
public long GuildId { get; set; }
/// <inheritdoc cref="CachedGuildUser.UserId"/>
public long UserId { get; set; }
/// <summary>
/// Gets the type of log message this represents.
/// </summary>
public ModLogType LogType { get; set; }
/// <summary>
/// Gets the the entity which issued this log item.
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
/// </summary>
public string IssuedBy { get; set; } = null!;
/// <summary>
/// Gets any additional message associated with this log entry.
/// </summary>
public string? Message { get; set; }
}

View file

@ -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 {
/// <summary>
/// Appends a note to the moderation log regarding the given user, containing the given message.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="guild">The guild which the target user is associated.</param>
/// <param name="targetUser">The snowflake ID of the target user.</param>
/// <param name="source">
/// The the entity which issued this log item.
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
/// </param>
/// <param name="message">The message to add to this entry.</param>
/// <returns>
/// The resulting <see cref="ModLogEntry"/> from the creation of this note.
/// </returns>
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;
}
/// <summary>
/// Warns a user, adding an entry to the moderation log and also attempting to notify the user.
/// </summary>
/// <param name="guild">The guild which the target user is associated.</param>
/// <param name="targetUser">The snowflake ID of the target user.</param>
/// <param name="source">
/// The the entity which issued this log item.
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
/// </param>
/// <param name="message">The message to add to this entry.</param>
/// <returns>
/// A tuple containing the resulting <see cref="ModLogEntry"/> and <see cref="LogAppendResult"/>.
/// </returns>
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));
}
}

View file

@ -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<HttpException?> 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;
}
}

View file

@ -1,16 +1,11 @@
#pragma warning disable CA1822 // "Mark members as static" - will not make static to encourage better structure
using Discord.Net; using Discord.Net;
namespace RegexBot.Services.CommonFunctions; namespace RegexBot.Services.CommonFunctions;
internal partial class CommonFunctionsService : Service { internal partial class CommonFunctionsService : Service {
// Hooked (indirectly) // Hooked (indirectly)
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] internal async Task<BanKickResult> BanOrKickAsync(RemovalType t, SocketGuild guild, string source, ulong target,
internal async Task<BanKickResult> BanOrKickAsync(RemovalType t, int banPurgeDays, string? logReason, bool sendDmToTarget) {
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'."); if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'.");
var dmSuccess = true; var dmSuccess = true;
@ -18,8 +13,6 @@ internal partial class CommonFunctionsService : Service {
// Can't kick without obtaining user object. Quit here. // Can't kick without obtaining user object. Quit here.
if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); 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 // Send DM notification
if (sendDmToTarget) { if (sendDmToTarget) {
if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason); 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(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<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) { private async Task<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) {
const string DMTemplate = "You have been {0} from {1}"; const string DMTemplate = "You have been {0} from {1}";
const string DMTemplateReason = " for the following reason:\n{2}"; const string DMTemplateReason = " for the following reason:\n{2}";

View file

@ -0,0 +1,46 @@
using Discord.Net;
namespace RegexBot;
/// <summary>
/// Contains information on success/failure outcomes for a warn operation.
/// </summary>
public class LogAppendResult {
private readonly int _logId;
private readonly string _rptDisplayName;
/// <summary>
/// Gets the exception thrown, if any, when attempting to send the warning to the target.
/// </summary>
public HttpException? MessageSendError { get; }
/// <summary>
/// Indicates if the operation failed due to being unable to find the user.
/// </summary>
public bool ErrorNotFound => MessageSendError?.HttpCode == System.Net.HttpStatusCode.NotFound;
/// <summary>
/// Indicates if the operation failed due to a permissions issue.
/// </summary>
public bool ErrorForbidden => MessageSendError?.HttpCode == System.Net.HttpStatusCode.Forbidden;
/// <summary>
/// Indicates if the operation completed successfully.
/// </summary>
public bool Success => MessageSendError == null;
internal LogAppendResult(HttpException? error, int logId, string reportDispName) {
_logId = logId;
MessageSendError = error;
_rptDisplayName = reportDispName;
}
/// <summary>
/// Returns a message representative of this result that may be posted as-is
/// within a Discord channel.
/// </summary>
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;
}
}

View file

@ -0,0 +1,31 @@
namespace RegexBot;
/// <summary>
/// Specifies the type of action or event represented by a
/// <see cref="Data.ModLogEntry"/> or <see cref="LogAppendResult"/>.
/// </summary>
public enum ModLogType {
/// <summary>
/// An unspecified logging type.
/// </summary>
Other,
/// <summary>
/// A note appended to a user's log for moderator reference.
/// </summary>
Note,
/// <summary>
/// A warning. Similar to a note, but with higher priority and presented to the user when issued.
/// </summary>
Warn,
/// <summary>
/// A timeout, preventing the user from speaking for some amount of time.
/// </summary>
Timeout,
/// <summary>
/// A forced removal from the server.
/// </summary>
Kick,
/// <summary>
/// A forced removal from the server, with the user additionally getting added to the ban list.
/// </summary>
Ban
}