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>
|
||||
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 />
|
||||
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
=> optionsBuilder
|
||||
|
@ -43,10 +48,12 @@ public class BotDatabaseContext : DbContext {
|
|||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
|
||||
modelBuilder.Entity<CachedGuildUser>(entity => {
|
||||
entity.HasKey(e => new { e.UserId, e.GuildId });
|
||||
entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()");
|
||||
modelBuilder.Entity<CachedGuildUser>(e => {
|
||||
e.HasKey(p => new { p.UserId, p.GuildId });
|
||||
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;
|
||||
|
||||
namespace RegexBot.Services.CommonFunctions;
|
||||
internal partial class CommonFunctionsService : Service {
|
||||
// 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,
|
||||
int banPurgeDays,
|
||||
string? logReason,
|
||||
bool sendDmToTarget) {
|
||||
internal async Task<BanKickResult> 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<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 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