Add message caching subservice

Partially implements legacy's ModLogs module on the bot side, with the remainder to be implemented as a proper module.
This commit is contained in:
Noi 2022-05-11 20:26:28 -07:00
parent 4f773e2573
commit 5671b7b48c
13 changed files with 175 additions and 42 deletions

View file

@ -20,6 +20,7 @@ public class BotDatabaseContext : DbContext {
public DbSet<GuildLogLine> GuildLog { get; set; } = null!;
public DbSet<CachedUser> UserCache { get; set; } = null!;
public DbSet<CachedGuildUser> GuildUserCache { get; set; } = null!;
public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
@ -27,9 +28,9 @@ public class BotDatabaseContext : DbContext {
.UseSnakeCaseNamingConvention();
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<GuildLogLine>(entity =>
entity.Property(e => e.Timestamp).HasDefaultValueSql("now()"));
modelBuilder.Entity<GuildLogLine>(entity => entity.Property(e => e.Timestamp).HasDefaultValueSql("now()"));
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
modelBuilder.Entity<CachedGuildUser>(entity => entity.Navigation(e => e.User).AutoInclude());
modelBuilder.Entity<CachedGuildMessage>(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()"));
}
}

View file

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
[Table("cache_messages")]
public class CachedGuildMessage {
[Key]
public long MessageId { get; set; }
public long AuthorId { get; set; }
public long GuildId { get; set; }
public long ChannelId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? EditedAt { get; set; }
public List<string> AttachmentNames { get; set; } = null!;
public string Content { get; set; } = null!;
/// <summary>Gets the timestamp when the message was last updated.</summary>
/// <remarks>
/// This is equivalent to coalescing the value of <see cref="EditedAt"/> and <see cref="CreatedAt"/>.
/// </remarks>
[NotMapped]
public DateTimeOffset LastUpdatedAt => EditedAt ?? CreatedAt;
[ForeignKey(nameof(CachedUser.UserId))]
[InverseProperty(nameof(CachedUser.GuildMessages))]
public CachedUser Author { get; set; } = null!;
internal new CachedGuildMessage MemberwiseClone() => (CachedGuildMessage)base.MemberwiseClone();
}

View file

@ -14,4 +14,7 @@ public class CachedUser {
[InverseProperty(nameof(CachedGuildUser.User))]
public ICollection<CachedGuildUser> Guilds { get; set; } = null!;
[InverseProperty(nameof(CachedGuildMessage.Author))]
public ICollection<CachedGuildMessage> GuildMessages { get; set; } = null!;
}

View file

@ -55,20 +55,18 @@ class InstanceConfig {
throw new Exception(pfx + ex.Message, ex);
}
#pragma warning disable CS8601 // Possible null reference assignment.
// Input validation - throw exception on errors. Exception messages printed as-is.
BotToken = conf[nameof(BotToken)]?.Value<string>();
BotToken = conf[nameof(BotToken)]?.Value<string>()!;
if (string.IsNullOrEmpty(BotToken))
throw new Exception($"'{nameof(BotToken)}' is not properly specified in configuration.");
PostgresConnString = conf[nameof(PostgresConnString)]?.Value<string>();
PostgresConnString = conf[nameof(PostgresConnString)]?.Value<string>()!;
if (string.IsNullOrEmpty(PostgresConnString))
throw new Exception($"'{nameof(PostgresConnString)}' is not properly specified in configuration.");
InstanceLogTarget = conf[nameof(InstanceLogTarget)]?.Value<string>();
InstanceLogTarget = conf[nameof(InstanceLogTarget)]?.Value<string>()!;
if (string.IsNullOrEmpty(InstanceLogTarget))
throw new Exception($"'{nameof(InstanceLogTarget)}' is not properly specified in configuration.");
#pragma warning restore CS8601
var asmList = conf[nameof(Assemblies)];
if (asmList == null || asmList.Type != JTokenType.Array) {

View file

@ -2,7 +2,6 @@
using Discord.WebSocket;
namespace RegexBot;
class Program {
/// <summary>
/// Timestamp specifying the date and time that the program began running.
@ -51,7 +50,7 @@ class Program {
private static void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e) {
e.Cancel = true;
_main.InstanceLogAsync(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal.");
_main._svcLogging.DoInstanceLog(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal.");
// 5 seconds of leeway - any currently running tasks will need time to finish executing
var closeWait = Task.Delay(5000);
@ -62,8 +61,8 @@ class Program {
closeWait.Wait();
bool success = _main.DiscordClient.StopAsync().Wait(1000);
if (!success) _main.InstanceLogAsync(false, nameof(RegexBot),
"Failed to disconnect cleanly from Discord. Will force shut down.").Wait();
if (!success) _main._svcLogging.DoInstanceLog(false, nameof(RegexBot),
"Failed to disconnect cleanly from Discord. Will force shut down.");
Environment.Exit(0);
}
}

View file

@ -25,15 +25,15 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.4.1" />
<PackageReference Include="Discord.Net" Version="3.6.1" />
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Npgsql" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
<PackageReference Include="Npgsql" Version="6.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
</ItemGroup>

View file

@ -4,7 +4,6 @@ using static RegexBot.RegexbotClient;
// Instances of this class are created by CommonFunctionService and are meant to be sent to modules,
// therefore we put this in the root RegexBot namespace despite being specific to this service.
namespace RegexBot;
/// <summary>
/// Contains information on various success/failure outcomes for a ban or kick operation.
/// </summary>
@ -75,7 +74,7 @@ public class BanKickResult {
/// Returns a message representative of the ban/kick result that may be posted as-is
/// within the a Discord channel.
/// </summary>
public string GetResultString(RegexbotClient bot, ulong guildId) {
public string GetResultString(RegexbotClient bot) {
string msg;
if (OperationSuccess) msg = ":white_check_mark: ";
@ -92,10 +91,12 @@ public class BanKickResult {
}
if (_rptTargetId != 0) {
var user = bot.EcQueryUser(guildId, _rptTargetId.ToString()).GetAwaiter().GetResult();
var user = bot.EcQueryUser(_rptTargetId.ToString());
if (user != null) {
// TODO sanitize possible formatting characters in display name
msg += $" user **{user.Username}#{user.Discriminator}**";
} else {
msg += $" user with ID **{_rptTargetId}**";
}
}

View file

@ -20,6 +20,7 @@ internal class CommonFunctionsService : Service {
/// Common processing for kicks and bans. Called by DoKickAsync and DoBanAsync.
/// </summary>
/// <param name="logReason">The reason to insert into the Audit Log.</param>
[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) {
@ -31,7 +32,7 @@ internal 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
// 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) {

View file

@ -2,9 +2,8 @@
using RegexBot.Services.CommonFunctions;
namespace RegexBot;
partial class RegexbotClient {
private CommonFunctionsService _svcCommonFunctions;
private readonly CommonFunctionsService _svcCommonFunctions;
public enum RemovalType { None, Ban, Kick }
@ -31,9 +30,9 @@ partial class RegexbotClient {
/// <param name="targetSearch">The EntityCache search string.</param>
public async Task<BanKickResult> BanAsync(SocketGuild guild, string source, string targetSearch,
int purgeDays, string reason, bool sendDMToTarget) {
var result = await EcQueryUser(guild.Id, targetSearch);
var result = EcQueryGuildUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Ban, 0);
return await BanAsync(guild, source, result.UserID, purgeDays, reason, sendDMToTarget);
return await BanAsync(guild, source, (ulong)result.UserId, purgeDays, reason, sendDMToTarget);
}
/// <summary>
@ -44,9 +43,15 @@ partial class RegexbotClient {
/// <returns>A structure containing results of the ban operation.</returns>
/// <param name="guild">The guild in which to attempt the kick.</param>
/// <param name="source">The user, module, or service which is requesting this action to be taken.</param>
/// <param name="targetUser">The user which to perform the action to.</param>
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
/// <param name="sendDMToTarget">Specify whether to send a direct message to the target user informing them of the action being taken.</param>
/// <param name="targetUser">The user which to perform the action towards.</param>
/// <param name="reason">
/// Reason for the action. Sent to the guild's audit log and, if
/// <paramref name="sendDMToTarget"/> is <see langword="true"/>, the target.
/// </param>
/// <param name="sendDMToTarget">
/// Specify whether to send a direct message to the target user informing them of the action
/// (that is, a ban/kick message).
/// </param>
public Task<BanKickResult> KickAsync(SocketGuild guild, string source, ulong targetUser, string reason, bool sendDMToTarget)
=> _svcCommonFunctions.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, 0, reason, sendDMToTarget);
@ -56,8 +61,8 @@ partial class RegexbotClient {
/// </summary>
/// <param name="targetSearch">The EntityCache search string.</param>
public async Task<BanKickResult> KickAsync(SocketGuild guild, string source, string targetSearch, string reason, bool sendDMToTarget) {
var result = await EcQueryUser(guild.Id, targetSearch);
var result = EcQueryGuildUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0);
return await KickAsync(guild, source, result.UserID, reason, sendDMToTarget);
return await KickAsync(guild, source, (ulong)result.UserId, reason, sendDMToTarget);
}
}

View file

@ -1,7 +1,6 @@
using RegexBot.Data;
namespace RegexBot.Services.EntityCache;
/// <summary>
/// Provides and maintains a database-backed cache of entities. Portions of information collected by this
/// service may be used by modules, while other portions are useful only for external applications which may
@ -9,17 +8,25 @@ namespace RegexBot.Services.EntityCache;
/// </summary>
class EntityCacheService : Service {
private readonly UserCachingSubservice _uc;
private readonly MessageCachingSubservice _mc;
internal EntityCacheService(RegexbotClient bot) : base(bot) {
// Currently we only have UserCache. May add Channel and Server caches later.
_uc = new UserCachingSubservice(bot);
_mc = new MessageCachingSubservice(bot, Log);
}
// Hooked
internal static CachedUser? QueryUserCache(string search)
=> UserCachingSubservice.DoUserQuery(search);
internal CachedUser? QueryUserCache(string search)
=> _uc.DoUserQuery(search);
// Hooked
internal static CachedGuildUser? QueryGuildUserCache(ulong guildId, string search)
=> UserCachingSubservice.DoGuildUserQuery(guildId, search);
internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string search)
=> _uc.DoGuildUserQuery(guildId, search);
// Hooked
internal event RegexbotClient.CachePreUpdateHandler? OnCachePreUpdate {
add { lock (_mc) _mc.OnCachePreUpdate += value; }
remove { lock (_mc) _mc.OnCachePreUpdate -= value; }
}
}

View file

@ -1,11 +1,9 @@
#pragma warning disable CA1822
using RegexBot.Data;
using RegexBot.Data;
using RegexBot.Services.EntityCache;
namespace RegexBot;
partial class RegexbotClient {
private EntityCacheService _svcEntityCache;
private readonly EntityCacheService _svcEntityCache;
/// <summary>
/// Queries the entity cache for user information. The given search string may contain a user ID
@ -14,7 +12,7 @@ partial class RegexbotClient {
/// </summary>
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
/// <returns>A <see cref="CachedUser"/> instance containing cached information, or null if no result.</returns>
public CachedUser? EcQueryUser(string search) => EntityCacheService.QueryUserCache(search);
public CachedUser? EcQueryUser(string search) => _svcEntityCache.QueryUserCache(search);
/// <summary>
/// Queries the entity cache for guild-specific user information. The given search string may contain a user ID,
@ -24,5 +22,20 @@ partial class RegexbotClient {
/// <param name="guildId">ID of the corresponding guild in which to search.</param>
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
/// <returns>A <see cref="CachedGuildUser"/> instance containing cached information, or null if no result.</returns>
public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => EntityCacheService.QueryGuildUserCache(guildId, search);
public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => _svcEntityCache.QueryGuildUserCache(guildId, search);
/// <summary>
/// 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.
/// </remarks>
public event CachePreUpdateHandler? OnCachePreUpdate {
add { _svcEntityCache.OnCachePreUpdate += value; }
remove { _svcEntityCache.OnCachePreUpdate -= value; }
}
public delegate Task CachePreUpdateHandler(CachedGuildMessage cachedMsg);
}

View file

@ -0,0 +1,69 @@
using Discord;
using Discord.WebSocket;
using RegexBot.Data;
using static RegexBot.RegexbotClient;
namespace RegexBot.Services.EntityCache;
class MessageCachingSubservice {
// Hooked
public event CachePreUpdateHandler? OnCachePreUpdate;
private readonly Action<string, bool> _log;
internal MessageCachingSubservice(RegexbotClient bot, Action<string, bool> logMethod) {
_log = logMethod;
bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
}
private Task DiscordClient_MessageReceived(SocketMessage arg) {
if (arg.Channel is IDMChannel || arg is not SocketSystemMessage) return Task.CompletedTask;
return AddOrUpdateCacheItemAsync(arg);
}
private Task DiscordClient_MessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3) {
if (arg2.Channel is IDMChannel || arg2 is not SocketSystemMessage) return Task.CompletedTask;
return AddOrUpdateCacheItemAsync(arg2);
}
private async Task AddOrUpdateCacheItemAsync(SocketMessage arg) {
using var db = new BotDatabaseContext();
CachedGuildMessage? msg = db.GuildMessageCache.Where(m => m.MessageId == (long)arg.Id).SingleOrDefault();
if (msg == null) {
msg = new() {
MessageId = (long)arg.Id,
AuthorId = (long)arg.Author.Id,
GuildId = (long)((SocketGuildUser)arg.Author).Guild.Id,
ChannelId = (long)arg.Channel.Id,
AttachmentNames = arg.Attachments.Select(a => a.Filename).ToList(),
Content = arg.Content
};
db.GuildMessageCache.Add(msg);
} 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);
}
await db.SaveChangesAsync();
}
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage msg) {
CachePreUpdateHandler? eventList;
lock (this) eventList = OnCachePreUpdate;
if (eventList == null) return;
foreach (var handler in eventList.GetInvocationList()) {
try {
await (Task)handler.DynamicInvoke(msg)!;
} catch (Exception ex) {
_log($"Unhandled exception in {nameof(RegexbotClient.OnCachePreUpdate)} handler '{handler.Method.Name}':", false);
_log(ex.ToString(), false);
}
}
}
}

View file

@ -3,12 +3,12 @@ using RegexBot.Data;
using System.Text.RegularExpressions;
namespace RegexBot.Services.EntityCache;
/// <summary>
/// Provides and maintains a database-backed cache of users.
/// It is meant to work as a supplement to Discord.Net's own user caching capabilities. Its purpose is to
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")]
class UserCachingSubservice {
private static Regex DiscriminatorSearch { get; } = new(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
@ -58,7 +58,7 @@ class UserCachingSubservice {
}
// Hooked
internal static CachedUser? DoUserQuery(string search) {
internal CachedUser? DoUserQuery(string search) {
static CachedUser? innerQuery(ulong? sID, (string name, string? disc)? nameSearch) {
var db = new BotDatabaseContext();
@ -87,7 +87,7 @@ class UserCachingSubservice {
}
// Hooked
internal static CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) {
internal CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) {
static CachedGuildUser? innerQuery(ulong guildId, ulong? sID, (string name, string? disc)? nameSearch) {
var db = new BotDatabaseContext();