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:
parent
a419dd2554
commit
4f896e8311
7 changed files with 264 additions and 16 deletions
|
@ -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
43
Data/ModLogEntry.cs
Normal 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; }
|
||||||
|
}
|
80
Services/CommonFunctions/CF_ModLogs.Hooks.cs
Normal file
80
Services/CommonFunctions/CF_ModLogs.Hooks.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
45
Services/CommonFunctions/CF_ModLogs.cs
Normal file
45
Services/CommonFunctions/CF_ModLogs.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}";
|
||||||
|
|
||||||
|
|
46
Services/CommonFunctions/LogAppendResult.cs
Normal file
46
Services/CommonFunctions/LogAppendResult.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
31
Services/CommonFunctions/ModLogType.cs
Normal file
31
Services/CommonFunctions/ModLogType.cs
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue