diff --git a/MatrixContentFilter/Abstractions/IContentFilter.cs b/MatrixContentFilter/Abstractions/IContentFilter.cs
new file mode 100644
index 0000000..108030b
--- /dev/null
+++ b/MatrixContentFilter/Abstractions/IContentFilter.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+using LibMatrix;
+using LibMatrix.Responses;
+using MatrixContentFilter.EventTypes;
+
+namespace MatrixContentFilter.Abstractions;
+
+public abstract class IContentFilter
+{
+ public virtual Task ProcessSyncAsync(SyncResponse syncEvent) {
+ var type = this.GetType().FullName;
+ Console.WriteLine($"WARNING: {type} does not implement ProcessSyncAsync(SyncResponse syncEvent)");
+ if(Debugger.IsAttached)
+ Debugger.Break();
+ return Task.CompletedTask;
+ }
+
+ public virtual Task ProcessEventListAsync(List<StateEventResponse> events) {
+ var type = this.GetType().FullName;
+ Console.WriteLine($"WARNING: {type} does not implement ProcessEventListAsync(List<StateEventResponse> events)");
+ if(Debugger.IsAttached)
+ Debugger.Break();
+ return Task.CompletedTask;
+ }
+
+ public int ActionCount { get; set; } = 0;
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/CheckHistoryCommand.cs b/MatrixContentFilter/Commands/CheckHistoryCommand.cs
new file mode 100644
index 0000000..1e98545
--- /dev/null
+++ b/MatrixContentFilter/Commands/CheckHistoryCommand.cs
@@ -0,0 +1,74 @@
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Services;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Commands;
+
+public class CheckHistoryCommand(
+ ConfigurationService filterConfigService,
+ IEnumerable<IContentFilter> filters,
+ AsyncMessageQueue msgQueue,
+ InfoCacheService infoCache
+) : ICommand {
+ public string Name { get; } = "checkhistory";
+ public string[]? Aliases { get; } = ["check"];
+ public string Description { get; } = "Re-apply filters to last x messages (default: 100)";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var count = 100;
+ if (ctx.Args.Length > 0) {
+ if (!int.TryParse(ctx.Args[0], out count)) {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody($"'{count}' is not a valid number!").Build());
+ return;
+ }
+ }
+
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom,
+ new MessageBuilder("m.notice").WithBody($"Re-applying filters to last {count} messages in ")
+ .WithMention(ctx.Room.RoomId, await infoCache.GetRoomNameAsync(ctx.Room.RoomId)).Build());
+
+ await foreach (var resp in ctx.Room.GetManyMessagesAsync(limit: count, chunkSize: Math.Min(count, 250))) {
+ foreach (var filter in filters) {
+ await filter.ProcessEventListAsync(resp.Chunk);
+ }
+ }
+ }
+
+ // /// <summary>Triggered when the application host is ready to start the service.</summary>
+ // /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+ // protected override async Task ExecuteAsync(CancellationToken cancellationToken) {
+ // while (!cancellationToken.IsCancellationRequested) {
+ // await Task.Delay(10000, cancellationToken);
+ // var rooms = await hs.GetJoinedRooms();
+ // rooms.RemoveAll(x => x.RoomId == filterConfigService.LogRoom.RoomId);
+ // rooms.RemoveAll(x => x.RoomId == filterConfigService.ControlRoom.RoomId);
+ //
+ // var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000);
+ // var timelines = rooms.Select(async x => {
+ // var room = hs.GetRoom(x.RoomId);
+ // // var sync = await room.GetMessagesAsync(null, 1500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode());
+ // var iter = room.GetManyMessagesAsync(null, 5000, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode(), chunkSize: 250);
+ // await foreach (var sync in iter) {
+ // var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => {
+ // try {
+ // Console.WriteLine("Processing filter {0} (sanity check, chunk[s={1}])", filter.GetType().FullName, sync.Chunk.Count);
+ // await filter.ProcessEventListAsync(sync.Chunk);
+ // }
+ // catch (Exception e) {
+ // logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName);
+ // msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice")
+ // .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build());
+ // }
+ // });
+ //
+ // await tasks;
+ // }
+ // }).ToList();
+ // await Task.WhenAll(timelines);
+ // }
+ // }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/ConfigureCommand.cs b/MatrixContentFilter/Commands/ConfigureCommand.cs
new file mode 100644
index 0000000..756fc14
--- /dev/null
+++ b/MatrixContentFilter/Commands/ConfigureCommand.cs
@@ -0,0 +1,38 @@
+using System.Text;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Utilities.Bot.Commands;
+using LibMatrix.Utilities.Bot.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MatrixContentFilter.Commands;
+
+public class ConfigureCommand(IServiceProvider svcs) : ICommandGroup {
+ public string Name { get; } = "configure";
+ public string[]? Aliases { get; } = ["config", "cfg"];
+ public string Description { get; }
+ public bool Unlisted { get; } = true;
+
+ public async Task Invoke(CommandContext ctx) {
+ var commands = svcs.GetServices<ICommand>().Where(x => x.GetType().IsAssignableTo(typeof(ICommand<>).MakeGenericType(GetType()))).ToList();
+
+ if (ctx.Args.Length == 0) {
+ await ctx.Room.SendMessageEventAsync(HelpCommand.GenerateCommandList(commands).Build());
+ }
+ else {
+ var subcommand = ctx.Args[0];
+ var command = commands.FirstOrDefault(x => x.Name == subcommand || x.Aliases?.Contains(subcommand) == true);
+ if (command == null) {
+ await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Unknown subcommand"));
+ return;
+ }
+
+ await command.Invoke(new CommandContext {
+ Room = ctx.Room,
+ MessageEvent = ctx.MessageEvent,
+ CommandName = ctx.CommandName,
+ Args = ctx.Args.Skip(1).ToArray(),
+ Homeserver = ctx.Homeserver
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs b/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs
new file mode 100644
index 0000000..5ff4f9d
--- /dev/null
+++ b/MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs
@@ -0,0 +1,18 @@
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace MatrixContentFilter.Commands.ConfigureSubCommands;
+
+public class ControlRoomConfigureSubCommand : ICommand<ConfigureCommand> {
+ public string Name { get; } = "controlroom";
+ public string[]? Aliases { get; }
+ public string Description { get; } = "Configure the control room";
+ public bool Unlisted { get; }
+
+ public async Task Invoke(CommandContext ctx) {
+ if (ctx.Args.Length == 0) {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody("meow").Build());
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/DumpEventCommand.cs b/MatrixContentFilter/Commands/DumpEventCommand.cs
new file mode 100644
index 0000000..5131e19
--- /dev/null
+++ b/MatrixContentFilter/Commands/DumpEventCommand.cs
@@ -0,0 +1,31 @@
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Services;
+using MatrixContentFilter.Services.AsyncActionQueues;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Commands;
+
+public class DumpEventCommand(
+ ConfigurationService filterConfigService,
+ AsyncMessageQueue msgQueue,
+ InfoCacheService infoCache,
+ ConfigurationService cfgService,
+ AbstractAsyncActionQueue actionQueue
+) : ICommand {
+ public string Name { get; } = "dump";
+ public string[]? Aliases { get; } = [];
+ public string Description { get; } = "Dump event by ID";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var evt = await ctx.Room.GetEventAsync(ctx.Args[0]);
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody(evt.ToJson(ignoreNull: true)).Build());
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/GetConfigCommand.cs b/MatrixContentFilter/Commands/GetConfigCommand.cs
new file mode 100644
index 0000000..bac00ca
--- /dev/null
+++ b/MatrixContentFilter/Commands/GetConfigCommand.cs
@@ -0,0 +1,56 @@
+using System.Text;
+using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Commands;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MatrixContentFilter.EventTypes;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MatrixContentFilter.Commands;
+
+public class GetConfigCommand(IServiceProvider svcs) : ICommand {
+ public string Name { get; } = "getconfig";
+ public string[]? Aliases { get; } = [];
+ public string Description { get; } = "Get the current configuration, optionally takes a room ID";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var room = ctx.Room;
+ if (ctx.Args.Length > 0) {
+ try {
+ room = ctx.Homeserver.GetRoom(ctx.Args[0]);
+ }
+ catch {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody("Invalid room ID").Build());
+ return;
+ }
+ }
+
+ var defaults = await ctx.Homeserver.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId);
+ var config = await room.GetRoomAccountDataOrNullAsync<FilterConfiguration>(FilterConfiguration.EventId);
+ var msb = new MessageBuilder("m.notice").WithColoredBody("#FFCC00", "Default configuration:")
+ .WithTable(tb => {
+ foreach (var prop in defaults.GetType().GetProperties()) {
+ var key = prop.GetFriendlyName();
+ var val = prop.GetValue(defaults);
+
+ tb = tb.WithRow(rb => {
+ rb.WithCell(key);
+ rb.WithCell(val?.ToJson() ?? "null");
+ });
+ }
+ });
+
+ if (config == null) {
+ msb = msb.WithBody("No configuration set for this room, using defaults");
+ }
+ else {
+ msb = msb.WithBody("Room overrides (additive):")
+ .WithCodeBlock(config.ToJson(ignoreNull: true), "json");
+ }
+
+ await room.SendMessageEventAsync(msb.Build());
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/NewRoomCommand.cs b/MatrixContentFilter/Commands/NewRoomCommand.cs
new file mode 100644
index 0000000..b8becd4
--- /dev/null
+++ b/MatrixContentFilter/Commands/NewRoomCommand.cs
@@ -0,0 +1,24 @@
+using System.Text;
+using ArcaneLibs.Attributes;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Commands;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MatrixContentFilter.EventTypes;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MatrixContentFilter.Commands;
+
+public class NewRoomCommand(IServiceProvider svcs) : ICommand {
+ public string Name { get; } = "newroom";
+ public string[]? Aliases { get; } = ["nr"];
+ public string Description { get; } = "Create a new room";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ await ctx.Homeserver.CreateRoom(new() {
+ Invite = [ctx.MessageEvent.Sender!]
+ });
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Commands/RedactCommand.cs b/MatrixContentFilter/Commands/RedactCommand.cs
new file mode 100644
index 0000000..6b2f8b6
--- /dev/null
+++ b/MatrixContentFilter/Commands/RedactCommand.cs
@@ -0,0 +1,99 @@
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Services;
+using MatrixContentFilter.Services.AsyncActionQueues;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Commands;
+
+public class RedactCommand(
+ ConfigurationService filterConfigService,
+ AsyncMessageQueue msgQueue,
+ InfoCacheService infoCache,
+ ConfigurationService cfgService,
+ AbstractAsyncActionQueue actionQueue
+) : ICommand {
+ public string Name { get; } = "redact";
+ public string[]? Aliases { get; } = [];
+ public string Description { get; } = "Redact last x messages from user (default: 500)";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var count = 500;
+
+ if (ctx.Args.Length == 0) {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice")
+ .WithBody("Please provide a user ID to redact messages from. (Make sure that it isn't formatted! Do not autocomplete!)").Build());
+ return;
+ }
+
+ var mxid = ctx.Args[0];
+ if (ctx.Args.Length > 1) {
+ if (!int.TryParse(ctx.Args[1], out count)) {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody($"'{count}' is not a valid number!").Build());
+ return;
+ }
+ }
+
+ var displayName = await infoCache.GetDisplayNameAsync(ctx.Room.RoomId, ctx.MessageEvent.Sender);
+ var roomName = await infoCache.GetRoomNameAsync(ctx.Room.RoomId);
+
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom,
+ new MessageBuilder("m.notice").WithBody($"Removing last {count} messages from ").WithMention(mxid)
+ .WithBody(" in ").WithMention(ctx.Room.RoomId, await infoCache.GetRoomNameAsync(ctx.Room.RoomId)).Build());
+ var hourglassReaction = await ctx.Room.SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() {
+ RelatesTo = new() {
+ EventId = ctx.MessageEvent.EventId,
+ RelationType = "m.annotation",
+ Key = "\u23f3" //hour glass emoji
+ }
+ });
+
+ await foreach (var resp in ctx.Room.GetManyMessagesAsync(limit: count, chunkSize: Math.Min(count, 250)
+ ,filter: new SyncFilter.RoomFilter.StateFilter(types: [RoomMemberEventContent.EventId, RoomMessageEventContent.EventId], senders: [mxid])
+ .ToJson(indent: false, ignoreNull: true).UrlEncode())
+ ) {
+ foreach (var msg in resp.Chunk) {
+ if (msg.Sender != mxid) continue;
+ if (msg is not { Type: RoomMemberEventContent.EventId or RoomMessageEventContent.EventId }) continue;
+ if (msg.RawContent is not { Count: > 0 }) continue;
+
+ await actionQueue.EqueueActionAsync(msg.EventId, async () => {
+ while (true) {
+ try {
+ await ctx.Room.RedactEventAsync(msg.EventId ?? throw new ArgumentException("Event ID is null?"), "Message removed by moderator.");
+ break;
+ }
+ catch (Exception e) {
+ msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Error redacting message in {ctx.Room.RoomId}!")
+ .WithCollapsibleSection("Error data", msb => msb.WithCodeBlock(e.ToString(), "csharp"))
+ .Build());
+ }
+ }
+
+ msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Message sent by ").WithMention(msg.Sender, displayName).WithBody(" in ").WithMention(ctx.Room.RoomId, roomName)
+ .WithBody(" was removed in request by ").WithMention(ctx.Room.RoomId, roomName).WithBody("!").WithNewline()
+ .WithCollapsibleSection("Message data", msb => msb.WithCodeBlock(msg.RawContent.ToJson(ignoreNull: true), "json"))
+ .Build());
+ });
+ }
+
+ await ctx.Room.RedactEventAsync(hourglassReaction.EventId);
+ await ctx.Room.SendTimelineEventAsync("m.reaction", new RoomMessageReactionEventContent() {
+ RelatesTo = new() {
+ EventId = ctx.MessageEvent.EventId,
+ RelationType = "m.annotation",
+ Key = "\u2714\ufe0f" //check mark emoji
+ }
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs b/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs
new file mode 100644
index 0000000..7599132
--- /dev/null
+++ b/MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes;
+
+namespace MatrixContentFilter.EventTypes;
+
+
+[MatrixEvent(EventName = EventId)]
+public class BotEnvironmentConfiguration : EventContent {
+ public const string EventId = "gay.rory.MatrixContentFilterBot.environment";
+
+ [JsonPropertyName("log_room_id")]
+ public string? LogRoomId { get; set; }
+
+ [JsonPropertyName("control_room_id")]
+ public string? ControlRoomId { get; set; }
+
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/EventTypes/FilterConfiguration.cs b/MatrixContentFilter/EventTypes/FilterConfiguration.cs
new file mode 100644
index 0000000..8e3b900
--- /dev/null
+++ b/MatrixContentFilter/EventTypes/FilterConfiguration.cs
@@ -0,0 +1,43 @@
+using System.Text.Json.Serialization;
+using ArcaneLibs.Attributes;
+using LibMatrix.EventTypes;
+
+namespace MatrixContentFilter.EventTypes;
+
+[MatrixEvent(EventName = EventId)]
+public class FilterConfiguration : EventContent {
+ public const string EventId = "gay.rory.MatrixContentFilterBot.filter_configuration";
+
+ [JsonPropertyName("image_filter")]
+ [FriendlyName(Name = "Images")]
+ public BasicFilterConfiguration? ImageFilter { get; set; }
+
+ [JsonPropertyName("video_filter")]
+ [FriendlyName(Name = "Videos")]
+ public BasicFilterConfiguration? VideoFilter { get; set; }
+
+ [JsonPropertyName("audio_filter")]
+ [FriendlyName(Name = "Audio")]
+ public BasicFilterConfiguration? AudioFilter { get; set; }
+
+ [JsonPropertyName("file_filter")]
+ [FriendlyName(Name = "Files")]
+ public BasicFilterConfiguration? FileFilter { get; set; }
+
+ [JsonPropertyName("url_filter")]
+ [FriendlyName(Name = "Links")]
+ public BasicFilterConfiguration? UrlFilter { get; set; }
+
+ [JsonPropertyName("ignored_users")]
+ [FriendlyName(Name = "Ignored Users")]
+ public List<string>? IgnoredUsers { get; set; }
+
+
+ public class BasicFilterConfiguration {
+ [JsonPropertyName("allowed")]
+ public bool? Allowed { get; set; }
+
+ [JsonPropertyName("ignored_users")]
+ public List<string>? IgnoredUsers { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Handlers/CommandResultHandler.cs b/MatrixContentFilter/Handlers/CommandResultHandler.cs
new file mode 100644
index 0000000..f05d2bb
--- /dev/null
+++ b/MatrixContentFilter/Handlers/CommandResultHandler.cs
@@ -0,0 +1,40 @@
+using ArcaneLibs;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace MatrixContentFilter.Handlers;
+
+public static class CommandResultHandler {
+ private static string binDir = FileUtils.GetBinDir();
+
+ public static async Task HandleAsync(CommandResult res) {
+ {
+ if (res.Success) return;
+ var room = res.Context.Room;
+ var hs = res.Context.Homeserver;
+ var msb = new MessageBuilder();
+ if (res.Result == CommandResult.CommandResultType.Failure_Exception) {
+ var angryEmojiPath = Path.Combine(binDir, "Resources", "Stickers", "JennyAngryPink.webp");
+ var hash = await FileUtils.GetFileSha384Async(angryEmojiPath);
+ var angryEmoji = await hs.NamedCaches.FileCache.GetOrSetValueAsync(hash, async () => {
+ await using var fs = File.OpenRead(angryEmojiPath);
+ return await hs.UploadFile("JennyAngryPink.webp", fs, "image/webp");
+ });
+ msb.WithCustomEmoji(angryEmoji, "JennyAngryPink")
+ .WithColoredBody("#EE4444", "An error occurred during the execution of this command")
+ .WithCodeBlock(res.Exception!.ToString(), "csharp");
+ }
+ // else if(res.Result == CommandResult.CommandResultType.) {
+ // msb.AddMessage(new RoomMessageEventContent("m.notice", "An error occurred during the execution of this command"));
+ // }
+ // var msg = res.Result switch {
+ // CommandResult.CommandResultType.Failure_Exception => MessageFormatter.FormatException("An error occurred during the execution of this command", res.Exception!)
+ // CommandResult.CommandResultType.Failure_NoPermission => new RoomMessageEventContent("m.notice", "You do not have permission to run this command!"),
+ // CommandResult.CommandResultType.Failure_InvalidCommand => new RoomMessageEventContent("m.notice", $"Command \"{res.Context.CommandName}\" not found!"),
+ // _ => throw new ArgumentOutOfRangeException()
+ // };
+
+ await room.SendMessageEventAsync(msb.Build());
+ }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Handlers/Filters/ImageFilter.cs b/MatrixContentFilter/Handlers/Filters/ImageFilter.cs
new file mode 100644
index 0000000..13a68f9
--- /dev/null
+++ b/MatrixContentFilter/Handlers/Filters/ImageFilter.cs
@@ -0,0 +1,92 @@
+using System.Runtime.Loader;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Collections;
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.EventTypes;
+using MatrixContentFilter.Services;
+using MatrixContentFilter.Services.AsyncActionQueues;
+
+namespace MatrixContentFilter.Handlers.Filters;
+
+public class ImageFilter(
+ ConfigurationService cfgService,
+ AuthenticatedHomeserverGeneric hs,
+ AsyncMessageQueue msgQueue,
+ InfoCacheService infoCache,
+ AbstractAsyncActionQueue actionQueue)
+ : IContentFilter {
+ public override async Task ProcessSyncAsync(SyncResponse syncResponse) {
+ Console.WriteLine("Processing image filter");
+ if (syncResponse.Rooms?.Join is null) return;
+ var tasks = syncResponse.Rooms.Join.Select(ProcessRoomAsync);
+ await Task.WhenAll(tasks);
+ }
+
+ // private SemaphoreSlim semaphore = new(8, 8);
+
+ private async Task ProcessRoomAsync(KeyValuePair<string, SyncResponse.RoomsDataStructure.JoinedRoomDataStructure> syncRoom) {
+ var (roomId, roomData) = syncRoom;
+ if (roomId == cfgService.LogRoom.RoomId || roomId == cfgService.ControlRoom.RoomId) return;
+ if (roomData.Timeline?.Events is null) return;
+ var config = cfgService.RoomConfigurationOverrides.GetValueOrDefault(roomId)?.ImageFilter;
+
+ var room = hs.GetRoom(roomId);
+
+ var tasks = roomData.Timeline.Events.Select(msg => ProcessEventAsync(room, msg, config));
+ await Task.WhenAll(tasks);
+ }
+
+ public override async Task ProcessEventListAsync(List<StateEventResponse> events) {
+ var tasks = events.GroupBy(x => x.RoomId).Select(async x => {
+ var room = hs.GetRoom(x.Key);
+ var config = cfgService.RoomConfigurationOverrides.GetValueOrDefault(x.Key)?.ImageFilter;
+ var tasks = x.Select(msg => ProcessEventAsync(room, msg, config));
+ await Task.WhenAll(tasks);
+ });
+
+ await Task.WhenAll(tasks);
+ }
+
+ private async Task ProcessEventAsync(GenericRoom room, StateEventResponse msg, FilterConfiguration.BasicFilterConfiguration roomConfiguration) {
+ if (msg.Type != "m.room.message") return;
+ var content = msg.TypedContent as RoomMessageEventContent;
+ if (content?.MessageType != "m.image") return;
+
+ // await semaphore.WaitAsync();
+
+ await actionQueue.EqueueActionAsync(msg.EventId, async () => {
+ while (true) {
+ try {
+ Console.WriteLine("Redacting image message: {0}", msg.EventId);
+ await room.RedactEventAsync(msg.EventId ?? throw new ArgumentException("Event ID is null?"), "Not allowed to send images in this room!");
+ break;
+ }
+ catch (Exception e) {
+ msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Error redacting image message in {room.RoomId}!")
+ .WithCollapsibleSection("Error data", msb => msb.WithCodeBlock(e.ToString(), "csharp"))
+ .Build());
+ }
+ }
+
+ var displayName = await infoCache.GetDisplayNameAsync(room.RoomId, msg.Sender);
+ var roomName = await infoCache.GetRoomNameAsync(room.RoomId);
+
+ msgQueue.EnqueueMessageAsync(cfgService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Image sent by ").WithMention(msg.Sender, displayName).WithBody(" in ").WithMention(room.RoomId, roomName).WithBody(" was removed!").WithNewline()
+ .WithCollapsibleSection("Message data", msb => msb.WithCodeBlock(content.ToJson(ignoreNull: true), "json"))
+ .Build());
+ });
+ ActionCount++;
+
+ // semaphore.Release();
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Handlers/InviteHandler.cs b/MatrixContentFilter/Handlers/InviteHandler.cs
new file mode 100644
index 0000000..75e5506
--- /dev/null
+++ b/MatrixContentFilter/Handlers/InviteHandler.cs
@@ -0,0 +1,29 @@
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Services;
+
+namespace MatrixContentFilter.Handlers;
+
+public static class InviteHandler {
+ public static async Task HandleAsync(InviteHandlerHostedService.InviteEventArgs invite) {
+ var room = invite.Homeserver.GetRoom(invite.RoomId);
+ if (!invite.MemberEvent.Sender!.EndsWith("rory.gay")) {
+ await room.LeaveAsync($"{invite.MemberEvent.Sender} is not allowed to invite this bot!");
+ return;
+ }
+
+ try {
+ await room.JoinAsync(reason: $"I was invited by {invite.MemberEvent.Sender}");
+ await room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Hello! I've arrived!"));
+ } catch (Exception e) {
+ var newroom = await invite.Homeserver.CreateRoom(new() {
+ Name = $"Join error report",
+ Invite = [invite.MemberEvent.Sender]
+ });
+ var msb = new MessageBuilder();
+ msb.WithColoredBody("#EE4444", $"An error occurred during accepting the invite to {invite.RoomId}")
+ .WithCodeBlock(e.ToString(), "csharp");
+ await newroom.SendMessageEventAsync(msb.Build());
+ }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/MatrixContentFilter.csproj b/MatrixContentFilter/MatrixContentFilter.csproj
new file mode 100644
index 0000000..ea0ddd1
--- /dev/null
+++ b/MatrixContentFilter/MatrixContentFilter.csproj
@@ -0,0 +1,39 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net8.0</TargetFramework>
+ <LangVersion>preview</LangVersion>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <PublishAot>false</PublishAot>
+ <InvariantGlobalization>true</InvariantGlobalization>
+ <!-- <PublishTrimmed>true</PublishTrimmed>-->
+ <!-- <PublishReadyToRun>true</PublishReadyToRun>-->
+ <!-- <PublishSingleFile>true</PublishSingleFile>-->
+ <!-- <PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>-->
+ <!-- <PublishTrimmedShowLinkerSizeComparison>true</PublishTrimmedShowLinkerSizeComparison>-->
+ <!-- <PublishTrimmedShowLinkerSizeComparisonWarnings>true</PublishTrimmedShowLinkerSizeComparisonWarnings>-->
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
+ <ProjectReference Include="..\LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
+ </ItemGroup>
+ <ItemGroup>
+ <Content Include="appsettings*.json">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </Content>
+ <Content Include="Resources\**\*">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
+ <Content Remove="Resources\.git\**\*"/>
+ </ItemGroup>
+ <ItemGroup>
+ <Folder Include="Utilities\" />
+ </ItemGroup>
+</Project>
diff --git a/MatrixContentFilter/MatrixContentFilterConfiguration.cs b/MatrixContentFilter/MatrixContentFilterConfiguration.cs
new file mode 100644
index 0000000..57537f0
--- /dev/null
+++ b/MatrixContentFilter/MatrixContentFilterConfiguration.cs
@@ -0,0 +1,18 @@
+using Microsoft.Extensions.Configuration;
+
+namespace MatrixContentFilter;
+
+public class MatrixContentFilterConfiguration {
+ public MatrixContentFilterConfiguration(IConfiguration config) => config.GetRequiredSection("MatrixContentFilter").Bind(this);
+
+ public List<string> Admins { get; set; } = new();
+ public ConcurrencyLimitsConfiguration ConcurrencyLimits { get; set; } = new();
+
+ public string AppMode { get; set; } = "bot";
+ public string AsyncQueueImplementation { get; set; } = "lifo";
+
+ public class ConcurrencyLimitsConfiguration {
+ public int Redactions { get; set; } = 1;
+ public int LogMessages { get; set; } = 1;
+ }
+}
diff --git a/MatrixContentFilter/Program.cs b/MatrixContentFilter/Program.cs
new file mode 100644
index 0000000..7eec930
--- /dev/null
+++ b/MatrixContentFilter/Program.cs
@@ -0,0 +1,70 @@
+using MatrixContentFilter;
+using MatrixContentFilter.Handlers;
+using LibMatrix.Services;
+using LibMatrix.Utilities.Bot;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Handlers.Filters;
+using MatrixContentFilter.Services;
+using MatrixContentFilter.Services.AsyncActionQueues;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+var builder = Host.CreateDefaultBuilder(args);
+
+builder.ConfigureHostOptions(host => {
+ host.ServicesStartConcurrently = true;
+ host.ServicesStopConcurrently = true;
+ host.ShutdownTimeout = TimeSpan.FromSeconds(5);
+});
+
+if (Environment.GetEnvironmentVariable("MATRIXCONTENTFILTER_APPSETTINGS_PATH") is string path)
+ builder.ConfigureAppConfiguration(x => x.AddJsonFile(path));
+
+var host = builder.ConfigureServices((ctx, services) => {
+ var config = new MatrixContentFilterConfiguration(ctx.Configuration);
+ services.AddSingleton<MatrixContentFilterConfiguration>(config);
+
+ services.AddRoryLibMatrixServices(new() {
+ AppName = "MatrixContentFilter"
+ });
+
+ services.AddMatrixBot().AddCommandHandler().DiscoverAllCommands()
+ .WithInviteHandler(InviteHandler.HandleAsync)
+ .WithCommandResultHandler(CommandResultHandler.HandleAsync);
+
+ services.AddSingleton<InfoCacheService>();
+
+ services.AddSingleton<IContentFilter, ImageFilter>();
+
+ services.AddSingleton<ConfigurationService>();
+ services.AddSingleton<IHostedService, ConfigurationService>(s => s.GetRequiredService<ConfigurationService>());
+ services.AddSingleton<AsyncMessageQueue>();
+ services.AddSingleton<IHostedService, AsyncMessageQueue>(s => s.GetRequiredService<AsyncMessageQueue>());
+
+ switch (config.AppMode) {
+ case "bot":
+ services.AddHostedService<MatrixContentFilterBot>();
+ // services.AddHostedService<BotModeSanityCheckService>();
+ break;
+ default:
+ throw new NotSupportedException($"Unknown app mode: {config.AppMode}");
+ }
+
+ switch (config.AsyncQueueImplementation) {
+ case "lifo":
+ services.AddSingleton<LiFoAsyncActionQueue>();
+ services.AddSingleton<AbstractAsyncActionQueue, LiFoAsyncActionQueue>(s => s.GetRequiredService<LiFoAsyncActionQueue>());
+ services.AddSingleton<IHostedService, LiFoAsyncActionQueue>(s => s.GetRequiredService<LiFoAsyncActionQueue>());
+ break;
+ case "fifo":
+ services.AddSingleton<FiFoAsyncActionQueue>();
+ services.AddSingleton<AbstractAsyncActionQueue, FiFoAsyncActionQueue>(s => s.GetRequiredService<FiFoAsyncActionQueue>());
+ services.AddSingleton<IHostedService, FiFoAsyncActionQueue>(s => s.GetRequiredService<FiFoAsyncActionQueue>());
+ break;
+ default:
+ throw new NotSupportedException($"Unknown async queue implementation: {config.AsyncQueueImplementation}");
+ }
+}).UseConsoleLifetime().Build();
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/MatrixContentFilter/Properties/launchSettings.json b/MatrixContentFilter/Properties/launchSettings.json
new file mode 100644
index 0000000..997e294
--- /dev/null
+++ b/MatrixContentFilter/Properties/launchSettings.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Default": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+
+ }
+ },
+ "Development": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ },
+ "Local config": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Local"
+ }
+ }
+ }
+}
diff --git a/MatrixContentFilter/Resources b/MatrixContentFilter/Resources
new file mode 160000
+Subproject 46edc2287e1cc9009a9a72447a6603e959a8971
diff --git a/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs
new file mode 100644
index 0000000..f4c559c
--- /dev/null
+++ b/MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs
@@ -0,0 +1,38 @@
+using System.Collections.Concurrent;
+using System.Threading.Channels;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services.AsyncActionQueues;
+
+public abstract class AbstractAsyncActionQueue : BackgroundService {
+ private readonly ConcurrentStack<string> _recentIds = new();
+ private readonly Channel<Func<Task>> _queue = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions() {
+ SingleReader = true
+ });
+ private static CancellationTokenSource _cts = new();
+
+ /// <summary>
+ /// Enqueue an action to be executed asynchronously
+ /// </summary>
+ /// <param name="id">Reproducible ID</param>
+ /// <param name="action">Action to execute</param>
+ /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns>
+ public virtual async Task<bool> EqueueActionAsync(string id, Func<Task> action) {
+ throw new NotImplementedException();
+ }
+
+ private async Task ProcessQueue() {
+ throw new NotImplementedException();
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ while (!stoppingToken.IsCancellationRequested) {
+ await ProcessQueue();
+ Console.WriteLine("AbstractAsyncActionQueue waiting for new actions, this should never happen!");
+ }
+
+ //clear backlog and exit
+ await ProcessQueue();
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs
new file mode 100644
index 0000000..3d7c90d
--- /dev/null
+++ b/MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs
@@ -0,0 +1,60 @@
+using System.Collections.Concurrent;
+using System.Threading.Channels;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services.AsyncActionQueues;
+
+public class FiFoAsyncActionQueue(ILogger<FiFoAsyncActionQueue> logger, MatrixContentFilterConfiguration cfg) : AbstractAsyncActionQueue {
+ // private readonly ConcurrentQueue<(string Id, Func<Task> Action)> _queue = new();
+ private readonly HashSet<string> _recentIds = new();
+ private readonly Channel<Func<Task>> _queue = Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions() {
+ SingleReader = true
+ });
+ private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.Redactions, cfg.ConcurrencyLimits.Redactions);
+
+ /// <summary>
+ /// Enqueue an action to be executed asynchronously
+ /// </summary>
+ /// <param name="id">Reproducible ID</param>
+ /// <param name="action">Action to execute</param>
+ /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns>
+ public override async Task<bool> EqueueActionAsync(string id, Func<Task> action) {
+ if (_recentIds.Contains(id)) {
+ logger.LogWarning("Duplicate action ID detected, ignoring action");
+ return false;
+ }
+ await _queue.Writer.WriteAsync(action);
+ _recentIds.Add(id);
+
+ if (_queue.Reader.Count > 100) {
+ logger.LogWarning("Action Queue is getting full, consider increasing the rate limit or exempting the bot!");
+ }
+
+ return true;
+ }
+
+ private async Task ProcessQueue() {
+ await foreach (var task in _queue.Reader.ReadAllAsync()) {
+ await _semaphore.WaitAsync();
+ _ = Task.Run(async () => {
+ try {
+ await task.Invoke();
+ }
+ finally {
+ _semaphore.Release();
+ }
+ });
+ }
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ while (!stoppingToken.IsCancellationRequested) {
+ await ProcessQueue();
+ logger.LogWarning("Waiting for new actions, ProcessQueue returned early!");
+ }
+
+ //clear backlog and exit
+ await ProcessQueue();
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs b/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs
new file mode 100644
index 0000000..631cc74
--- /dev/null
+++ b/MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs
@@ -0,0 +1,67 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services.AsyncActionQueues;
+
+public class LiFoAsyncActionQueue(ILogger<LiFoAsyncActionQueue> logger, MatrixContentFilterConfiguration cfg) : AbstractAsyncActionQueue {
+ // private readonly ConcurrentQueue<(string Id, Func<Task> Action)> _queue = new();
+ private readonly HashSet<string> _recentIds = new();
+ private readonly ConcurrentStack<(string Id, Func<Task> Action)> _queue = new();
+ private static CancellationTokenSource _cts = new();
+ private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.Redactions, cfg.ConcurrencyLimits.Redactions);
+
+ /// <summary>
+ /// Enqueue an action to be executed asynchronously
+ /// </summary>
+ /// <param name="id">Reproducible ID</param>
+ /// <param name="action">Action to execute</param>
+ /// <returns>`true` if action was appended, `false` if action was not added, eg. due to duplicate ID</returns>
+ public override async Task<bool> EqueueActionAsync(string id, Func<Task> action) {
+ if (_recentIds.Contains(id)) {
+ logger.LogWarning("Duplicate action ID detected, ignoring action");
+ return false;
+ }
+
+ _queue.Push((id, action));
+ _recentIds.Add(id);
+ _cts.Cancel(false);
+
+ return true;
+ }
+
+ private async Task ProcessQueue() {
+ // await foreach (var task in _queue2.Reader.ReadAllAsync()) {
+ while (_queue.TryPop(out var task)) {
+ await _semaphore.WaitAsync();
+ _ = Task.Run(async () => {
+ try {
+ await task.Action.Invoke();
+ _recentIds.Remove(task.Id);
+ }
+ finally {
+ _semaphore.Release();
+ }
+ });
+ }
+
+ _cts.Dispose();
+ _cts = new CancellationTokenSource();
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ while (!stoppingToken.IsCancellationRequested) {
+ await ProcessQueue();
+ Console.WriteLine(GetType().Name + " waiting for new actions");
+ try {
+ await Task.Delay(10000, _cts.Token);
+ }
+ catch (TaskCanceledException) {
+ Console.WriteLine(GetType().Name + " _cts cancelled");
+ // ignore
+ }
+ }
+
+ //clear backlog and exit
+ await ProcessQueue();
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/AsyncMessageQueue.cs b/MatrixContentFilter/Services/AsyncMessageQueue.cs
new file mode 100644
index 0000000..7912cc5
--- /dev/null
+++ b/MatrixContentFilter/Services/AsyncMessageQueue.cs
@@ -0,0 +1,43 @@
+using System.Collections.Concurrent;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.RoomTypes;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services;
+
+public class AsyncMessageQueue(ILogger<AsyncMessageQueue> logger, MatrixContentFilterConfiguration cfg) : BackgroundService {
+ private readonly ConcurrentQueue<(GenericRoom Room, RoomMessageEventContent Content)> _queue = new();
+ private readonly SemaphoreSlim _semaphore = new(cfg.ConcurrencyLimits.LogMessages, cfg.ConcurrencyLimits.LogMessages);
+ public void EnqueueMessageAsync(GenericRoom room, RoomMessageEventContent content) {
+ _queue.Enqueue((room, content));
+
+ if (_queue.Count > 100) {
+ logger.LogWarning($"Message Queue is getting full (c={_queue.Count}), consider increasing the rate limit or exempting the bot!");
+ }
+ }
+
+ private async Task ProcessQueue() {
+ while (_queue.TryDequeue(out var message)) {
+ await _semaphore.WaitAsync();
+ _ = Task.Run(async () => {
+ try {
+ await message.Room.SendMessageEventAsync(message.Content);
+ }
+ finally {
+ _semaphore.Release();
+ }
+ });
+ }
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ while (!stoppingToken.IsCancellationRequested) {
+ await ProcessQueue();
+ await Task.Delay(1000, stoppingToken);
+ }
+
+ //clear backlog and exit
+ await ProcessQueue();
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/BotModeSanityCheckService.cs b/MatrixContentFilter/Services/BotModeSanityCheckService.cs
new file mode 100644
index 0000000..55fe9e8
--- /dev/null
+++ b/MatrixContentFilter/Services/BotModeSanityCheckService.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Handlers.Filters;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services;
+
+public class BotModeSanityCheckService(
+ ILogger<BotModeSanityCheckService> logger,
+ AuthenticatedHomeserverGeneric hs,
+ ConfigurationService filterConfigService,
+ IEnumerable<IContentFilter> filters,
+ AsyncMessageQueue msgQueue
+) : BackgroundService {
+ /// <summary>Triggered when the application host is ready to start the service.</summary>
+ /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+ protected override async Task ExecuteAsync(CancellationToken cancellationToken) {
+ while (!cancellationToken.IsCancellationRequested) {
+ await Task.Delay(10000, cancellationToken);
+ var rooms = await hs.GetJoinedRooms();
+ rooms.RemoveAll(x => x.RoomId == filterConfigService.LogRoom.RoomId);
+ rooms.RemoveAll(x => x.RoomId == filterConfigService.ControlRoom.RoomId);
+
+ var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000);
+ var timelines = rooms.Select(async x => {
+ var room = hs.GetRoom(x.RoomId);
+ // var sync = await room.GetMessagesAsync(null, 1500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode());
+ var iter = room.GetManyMessagesAsync(null, 5000, filter: timelineFilter.ToJson(ignoreNull: true, indent: false).UrlEncode(), chunkSize: 250);
+ await foreach (var sync in iter) {
+ var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => {
+ try {
+ Console.WriteLine("Processing filter {0} (sanity check, chunk[s={1}])", filter.GetType().FullName, sync.Chunk.Count);
+ await filter.ProcessEventListAsync(sync.Chunk);
+ }
+ catch (Exception e) {
+ logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName);
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build());
+ }
+ });
+
+ await tasks;
+ }
+ }).ToList();
+ await Task.WhenAll(timelines);
+ }
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/ConfigurationService.cs b/MatrixContentFilter/Services/ConfigurationService.cs
new file mode 100644
index 0000000..f83c89a
--- /dev/null
+++ b/MatrixContentFilter/Services/ConfigurationService.cs
@@ -0,0 +1,198 @@
+using ArcaneLibs.Extensions;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.RoomTypes;
+using LibMatrix.Utilities;
+using MatrixContentFilter.EventTypes;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services;
+
+public class ConfigurationService(ILogger<ConfigurationService> logger, AuthenticatedHomeserverGeneric hs, AsyncMessageQueue msgQueue) : BackgroundService {
+ public BotEnvironmentConfiguration EnvironmentConfiguration { get; private set; }
+ public FilterConfiguration DefaultConfiguration { get; private set; }
+ public Dictionary<string, FilterConfiguration> RoomConfigurationOverrides { get; } = new();
+ public Dictionary<string, FilterConfiguration> FinalRoomConfigurations { get; } = new();
+
+ public GenericRoom LogRoom { get; private set; } = null!;
+ public GenericRoom ControlRoom { get; private set; } = null!;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ var syncHelper = new SyncHelper(hs, logger) {
+ NamedFilterName = CommonSyncFilters.GetAccountDataWithRooms
+ };
+
+ await foreach (var sync in syncHelper.EnumerateSyncAsync(stoppingToken).WithCancellation(stoppingToken)) {
+ if (sync is { AccountData: null, Rooms: null }) continue;
+ logger.LogInformation("Received configuration update: {syncData}", sync.ToJson(ignoreNull: true));
+ await OnSyncReceived(sync);
+ }
+ }
+
+ public async Task OnSyncReceived(SyncResponse sync) {
+ if (sync.AccountData?.Events?.FirstOrDefault(x => x.Type == BotEnvironmentConfiguration.EventId) is { } envEvent) {
+ EnvironmentConfiguration = envEvent.TypedContent as BotEnvironmentConfiguration;
+ msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice")
+ .WithColoredBody("#FF0088", "Environment configuration updated from sync.").WithNewline()
+ .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(EnvironmentConfiguration.ToJson(), "json"))
+ // .WithCollapsibleSection("Full event JSON", _msb => _msb.WithCodeBlock(envEvent.ToJson(), "json"))
+ .Build());
+ LogRoom = hs.GetRoom(EnvironmentConfiguration.LogRoomId!);
+ ControlRoom = hs.GetRoom(EnvironmentConfiguration.ControlRoomId!);
+ }
+
+ if (sync.AccountData?.Events?.FirstOrDefault(x => x.Type == FilterConfiguration.EventId) is { } filterEvent) {
+ DefaultConfiguration = filterEvent.TypedContent as FilterConfiguration;
+ msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice")
+ .WithColoredBody("#00FF88", "Default filter configuration updated from sync.").WithNewline()
+ .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(DefaultConfiguration.ToJson(), "json"))
+ // .WithCollapsibleSection("Full event JSON", _msb => _msb.WithCodeBlock(filterEvent.ToJson(), "json"))
+ .Build());
+ }
+
+ await Parallel.ForEachAsync(sync.Rooms?.Join ?? [], async (syncRoom, ct) => {
+ var (roomId, roomData) = syncRoom;
+ if (roomId == LogRoom!.RoomId || roomId == ControlRoom!.RoomId) return;
+ var room = hs.GetRoom(roomId);
+
+ if (roomData.AccountData?.Events?.FirstOrDefault(x => x.Type == FilterConfiguration.EventId) is { } roomFilterEvent) {
+ RoomConfigurationOverrides[roomId] = roomFilterEvent.TypedContent as FilterConfiguration;
+ var roomName = await room.GetNameOrFallbackAsync();
+ msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice")
+ .WithColoredBody("#00FF88", msb => msb.WithBody($"Filter configuration updated for ").WithMention(roomId, roomName).WithBody(" from sync.")).WithNewline()
+ .WithCollapsibleSection("JSON data:", msb => msb.WithCodeBlock(RoomConfigurationOverrides[roomId].ToJson(), "json"))
+ .Build());
+ }
+ });
+ }
+
+ public async Task OnStartup(MatrixContentFilterConfiguration configuration) {
+ BotEnvironmentConfiguration _environmentConfiguration;
+ try {
+ _environmentConfiguration = await hs.GetAccountDataAsync<BotEnvironmentConfiguration>(BotEnvironmentConfiguration.EventId);
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) throw;
+ logger.LogWarning("No environment configuration found, creating one");
+ _environmentConfiguration = new BotEnvironmentConfiguration();
+ }
+
+ if (string.IsNullOrWhiteSpace(_environmentConfiguration.ControlRoomId)) {
+ LogRoom = await hs.CreateRoom(new() {
+ Name = "MatrixContentFilter logs",
+ Invite = configuration.Admins,
+ Visibility = "private"
+ });
+ var powerlevels = await LogRoom.GetPowerLevelsAsync();
+ powerlevels.EventsDefault = 20;
+ foreach (var admin in configuration.Admins) {
+ powerlevels.Users[admin] = 100;
+ }
+
+ await LogRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, powerlevels);
+
+ _environmentConfiguration.LogRoomId = LogRoom.RoomId;
+ await hs.SetAccountDataAsync(BotEnvironmentConfiguration.EventId, _environmentConfiguration);
+ }
+ else {
+ LogRoom = hs.GetRoom(_environmentConfiguration.LogRoomId!);
+ }
+
+ if (string.IsNullOrWhiteSpace(_environmentConfiguration.ControlRoomId)) {
+ ControlRoom = await hs.CreateRoom(new() {
+ Name = "MatrixContentFilter control room",
+ Invite = configuration.Admins,
+ Visibility = "private"
+ });
+ var powerlevels = await ControlRoom.GetPowerLevelsAsync();
+ powerlevels.EventsDefault = 20;
+ foreach (var admin in configuration.Admins) {
+ powerlevels.Users[admin] = 100;
+ }
+
+ await ControlRoom.SendStateEventAsync(RoomPowerLevelEventContent.EventId, powerlevels);
+
+ _environmentConfiguration.ControlRoomId = ControlRoom.RoomId;
+ await hs.SetAccountDataAsync(BotEnvironmentConfiguration.EventId, _environmentConfiguration);
+ }
+ else {
+ ControlRoom = hs.GetRoom(_environmentConfiguration.ControlRoomId!);
+ }
+
+ FilterConfiguration _filterConfiguration;
+ try {
+ _filterConfiguration = await hs.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId);
+ }
+ catch (MatrixException e) {
+ if (e is not { ErrorCode: MatrixException.ErrorCodes.M_NOT_FOUND }) throw;
+ logger.LogWarning("No filter configuration found, creating one");
+ msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice").WithColoredBody("#FF0000", "No filter configuration found, creating one").Build());
+ _filterConfiguration = new FilterConfiguration();
+ }
+
+ Dictionary<string, object> changes = new();
+
+ T Log<T>(string key, T value) {
+ changes[key] = value;
+ return value;
+ }
+
+ _filterConfiguration.IgnoredUsers ??= Log("ignored_users", (List<string>) [
+ hs.WhoAmI.UserId,
+ .. configuration.Admins
+ ]);
+
+ _filterConfiguration.FileFilter ??= new();
+ _filterConfiguration.FileFilter.IgnoredUsers ??= Log("file_filter->ignored_users", (List<string>) []);
+ _filterConfiguration.FileFilter.Allowed ??= Log("file_filter->allowed", false);
+
+ _filterConfiguration.ImageFilter ??= new();
+ _filterConfiguration.ImageFilter.IgnoredUsers ??= Log("image_filter->ignored_users", (List<string>) []);
+ _filterConfiguration.ImageFilter.Allowed ??= Log("image_filter->allowed", false);
+
+ _filterConfiguration.VideoFilter ??= new();
+ _filterConfiguration.VideoFilter.IgnoredUsers ??= Log("video_filter->ignored_users", (List<string>) []);
+ _filterConfiguration.VideoFilter.Allowed ??= Log("video_filter->allowed", false);
+
+ _filterConfiguration.AudioFilter ??= new();
+ _filterConfiguration.AudioFilter.IgnoredUsers ??= Log("audio_filter->ignored_users", (List<string>) []);
+ _filterConfiguration.AudioFilter.Allowed ??= Log("audio_filter->allowed", false);
+
+ _filterConfiguration.UrlFilter ??= new();
+ _filterConfiguration.UrlFilter.IgnoredUsers ??= Log("url_filter->ignored_users", (List<string>) []);
+ _filterConfiguration.UrlFilter.Allowed ??= Log("url_filter->allowed", false);
+
+ if (changes.Count > 0) {
+ await hs.SetAccountDataAsync(FilterConfiguration.EventId, _filterConfiguration);
+ msgQueue.EnqueueMessageAsync(LogRoom, new MessageBuilder("m.notice").WithColoredBody("#FF0000", "Default filter configuration updated").WithNewline()
+ .WithTable(msb => {
+ msb = msb.WithTitle("Default configuration changes", 2);
+
+ foreach (var (key, value) in changes) {
+ var formattedValue = value switch {
+ List<string> list => string.Join(", ", list),
+ _ => value.ToString()
+ };
+ msb = msb.WithRow(rb => { rb.WithCell(key).WithCell(formattedValue ?? "formattedValue was null!"); });
+ }
+ }).Build());
+ }
+ }
+
+ private async Task RebuildRoomConfigurations(FilterConfiguration? defaultConfig, Dictionary<string, FilterConfiguration?>? roomConfigurations) {
+ defaultConfig ??= await hs.GetAccountDataAsync<FilterConfiguration>(FilterConfiguration.EventId);
+ }
+
+ public async Task<FilterConfiguration> GetFinalRoomConfiguration(string roomId) {
+ if (FinalRoomConfigurations.TryGetValue(roomId, out var config)) return config;
+ var roomConfig = RoomConfigurationOverrides.GetValueOrDefault(roomId);
+ var defaultConfig = DefaultConfiguration;
+
+ FinalRoomConfigurations[roomId] = config;
+ return config;
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/InfoCacheService.cs b/MatrixContentFilter/Services/InfoCacheService.cs
new file mode 100644
index 0000000..974e873
--- /dev/null
+++ b/MatrixContentFilter/Services/InfoCacheService.cs
@@ -0,0 +1,29 @@
+using ArcaneLibs.Collections;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Homeservers;
+
+namespace MatrixContentFilter.Services;
+
+public class InfoCacheService(AuthenticatedHomeserverGeneric hs) {
+ private static readonly ExpiringSemaphoreCache<string> DisplayNameCache = new();
+ public static readonly ExpiringSemaphoreCache<string> RoomNameCache = new();
+
+ public async Task<string> GetDisplayNameAsync(string roomId, string userId) =>
+ await DisplayNameCache.GetOrAdd($"{roomId}\t{userId}", async () => {
+ var room = hs.GetRoom(roomId);
+ var userState = await room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, userId);
+ if (!string.IsNullOrWhiteSpace(userState?.DisplayName)) return userState.DisplayName;
+
+ var user = await hs.GetProfileAsync(userId);
+ if (!string.IsNullOrWhiteSpace(user?.DisplayName)) return user.DisplayName;
+
+ return userId;
+ }, TimeSpan.FromMinutes(5));
+
+ public async Task<string> GetRoomNameAsync(string roomId) =>
+ await RoomNameCache.GetOrAdd(roomId, async () => {
+ var room = hs.GetRoom(roomId);
+ var name = await room.GetNameOrFallbackAsync();
+ return name;
+ }, TimeSpan.FromMinutes(30));
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/Services/MatrixContentFilterBot.cs b/MatrixContentFilter/Services/MatrixContentFilterBot.cs
new file mode 100644
index 0000000..321cdd4
--- /dev/null
+++ b/MatrixContentFilter/Services/MatrixContentFilterBot.cs
@@ -0,0 +1,143 @@
+using System.Diagnostics;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using MatrixContentFilter.Abstractions;
+using MatrixContentFilter.Handlers.Filters;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixContentFilter.Services;
+
+public class MatrixContentFilterBot(
+ ILogger<MatrixContentFilterBot> logger,
+ AuthenticatedHomeserverGeneric hs,
+ MatrixContentFilterConfiguration configuration,
+ ConfigurationService filterConfigService,
+ IEnumerable<IContentFilter> filters,
+ AsyncMessageQueue msgQueue
+) : BackgroundService {
+ /// <summary>Triggered when the application host is ready to start the service.</summary>
+ /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+ protected override async Task ExecuteAsync(CancellationToken cancellationToken) {
+ try {
+ await filterConfigService.OnStartup(configuration);
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithColoredBody("#00FF00", "Bot startup successful! Listening for events.")
+ .Build());
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithColoredBody("#00FF00", msb => {
+ msb = msb.WithBody("Inserted filters implementations (internal):").WithNewline();
+ foreach (var filter in filters) {
+ msb = msb.WithBody(filter.GetType().FullName).WithNewline();
+ }
+ }).Build());
+ }
+ catch (Exception e) {
+ logger.LogError(e, "Error on startup");
+ Environment.Exit(1); // We don't want to do a graceful shutdown if we can't start up
+ }
+
+ logger.LogInformation("Bot started!");
+ await Run(cancellationToken);
+ }
+
+ private SyncHelper syncHelper;
+
+ private async Task Run(CancellationToken cancellationToken) {
+ var syncFilter = new SyncFilter() {
+ Room = new() {
+ NotRooms = [filterConfigService.LogRoom.RoomId],
+ Timeline = new(notTypes: ["m.room.redaction"])
+ }
+ };
+ syncHelper = new SyncHelper(hs, logger) {
+ Filter = syncFilter
+ };
+ int i = 0;
+ await foreach (var sync in syncHelper.EnumerateSyncAsync(cancellationToken).WithCancellation(cancellationToken)) {
+ // if (i++ >= 100) {
+ // var sw = Stopwatch.StartNew();
+ // for (int gen = 0; gen < GC.MaxGeneration; gen++) {
+ // GC.Collect(gen, GCCollectionMode.Forced, true, true);
+ // }
+ // i = 0;
+ // msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice")
+ // .WithBody($"Garbage collection took {sw.ElapsedMilliseconds}ms")
+ // .Build());
+ // GC.
+ // }
+
+ // GC.TryStartNoGCRegion(1024 * 1024 * 1024);
+ var sw = Stopwatch.StartNew();
+ int actionCount = filters.Sum(x => x.ActionCount);
+ try {
+ await OnSyncReceived(sync);
+ }
+ catch (Exception e) {
+ logger.LogError(e, "Error processing sync");
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice").WithBody($"Error processing sync: {e.Message}").Build());
+ }
+ finally {
+ Console.WriteLine("Processed sync in {0}, executed {1} actions, {2} of memory usage", sw.Elapsed, filters.Sum(x => x.ActionCount) - actionCount, Util.BytesToString(Environment.WorkingSet));
+ // GC.EndNoGCRegion();
+ }
+
+ // update sync filter
+ if (syncFilter.Room.NotRooms[0] != filterConfigService.LogRoom.RoomId) {
+ syncFilter.Room.NotRooms = [filterConfigService.LogRoom.RoomId];
+ syncHelper.Filter = syncFilter;
+ }
+ }
+ }
+
+ private int _syncCount;
+
+ private async Task OnSyncReceived(SyncResponse sync) {
+ if (_syncCount++ == 0) return; // Skip initial sync :/
+
+ if (sync.Rooms?.Join?.ContainsKey(filterConfigService.LogRoom.RoomId) == true) {
+ sync.Rooms?.Join?.Remove(filterConfigService.LogRoom.RoomId);
+ }
+
+ if (sync.Rooms?.Join?.ContainsKey(filterConfigService.ControlRoom.RoomId) == true) {
+ sync.Rooms?.Join?.Remove(filterConfigService.ControlRoom.RoomId);
+ }
+
+ // HACK: Server likes to send partial timelines during elevated activity, so we need to fetch them in order not to miss events
+ var timelineFilter = new SyncFilter.RoomFilter.StateFilter(notTypes: ["m.room.redaction"], limit: 5000);
+ var limitedTimelineRooms = sync.Rooms?.Join?
+ .Where(x => x.Value.Timeline?.Limited ?? false)
+ .Select(async x => {
+ var (roomId, roomData) = x;
+ var room = hs.GetRoom(roomId);
+ if (roomData.Timeline?.Limited == true) {
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice")
+ .WithColoredBody("FF0000", $"Room {roomId} has limited timeline, fetching! The room may be getting spammed?")
+ .Build());
+ roomData.Timeline.Events ??= [];
+ var newEvents = await room.GetMessagesAsync(roomData.Timeline.PrevBatch ?? "", 500, filter: timelineFilter.ToJson(ignoreNull: true, indent: false));
+ roomData.Timeline.Events.MergeBy(newEvents.Chunk, (x, y) => x.EventId == y.EventId, (x, y) => { });
+ }
+ })
+ .ToList();
+
+ if (limitedTimelineRooms?.Count > 0)
+ await Task.WhenAll(limitedTimelineRooms);
+
+ var tasks = Parallel.ForEachAsync(filters, async (filter, ct) => {
+ try {
+ Console.WriteLine("Processing filter {0}", filter.GetType().FullName);
+ await filter.ProcessSyncAsync(sync);
+ }
+ catch (Exception e) {
+ logger.LogError(e, "Error processing sync with filter {filter}", filter.GetType().FullName);
+ msgQueue.EnqueueMessageAsync(filterConfigService.LogRoom, new MessageBuilder("m.notice")
+ .WithBody($"Error processing sync with filter {filter.GetType().FullName}: {e.Message}").Build());
+ }
+ });
+
+ await tasks;
+ }
+}
\ No newline at end of file
diff --git a/MatrixContentFilter/appsettings.Development.json b/MatrixContentFilter/appsettings.Development.json
new file mode 100644
index 0000000..29f9c88
--- /dev/null
+++ b/MatrixContentFilter/appsettings.Development.json
@@ -0,0 +1,24 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ },
+ "LibMatrixBot": {
+ // The homeserver to connect to
+ "Homeserver": "rory.gay",
+ // The access token to use
+ "AccessToken": "syt_xxxxxxxxxxxxxxxxx",
+ // The command prefix
+ "Prefix": "?"
+ },
+ "MatrixContentFilter": {
+ // List of people who should be invited to the control room
+ "Admins": [
+ "@emma:conduit.rory.gay",
+ "@emma:rory.gay"
+ ]
+ }
+}
diff --git a/MatrixContentFilter/appsettings.json b/MatrixContentFilter/appsettings.json
new file mode 100644
index 0000000..6ba02f3
--- /dev/null
+++ b/MatrixContentFilter/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Debug",
+ "System": "Information",
+ "Microsoft": "Information"
+ }
+ }
+}
diff --git a/MatrixContentFilter/deps.nix b/MatrixContentFilter/deps.nix
new file mode 100644
index 0000000..bff58f4
--- /dev/null
+++ b/MatrixContentFilter/deps.nix
@@ -0,0 +1,35 @@
+{ fetchNuGet }: [
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration"; version = "8.0.0"; sha256 = "080kab87qgq2kh0ijry5kfdiq9afyzb8s0k3jqi5zbbi540yq4zl"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Abstractions"; version = "8.0.0"; sha256 = "1jlpa4ggl1gr5fs7fdcw04li3y3iy05w3klr9lrrlc7v8w76kq71"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Binder"; version = "8.0.0"; sha256 = "1m0gawiz8f5hc3li9vd5psddlygwgkiw13d7div87kmkf4idza8r"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.CommandLine"; version = "8.0.0"; sha256 = "026f7f2iv6ph2dc5rnslll0bly8qcx5clmh2nn9hgyqjizzc4qvy"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.EnvironmentVariables"; version = "8.0.0"; sha256 = "13qb8wz3k59ihq0mjcqz1kwrpyzxn5da4dhk2pvcgc42z9kcbf7r"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.FileExtensions"; version = "8.0.0"; sha256 = "1jrmlfzy4h32nzf1nm5q8bhkpx958b0ww9qx1k1zm4pyaf6mqb04"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.Json"; version = "8.0.0"; sha256 = "1n3ss26v1lq6b69fxk1vz3kqv9ppxq8ypgdqpd7415xrq66y4bqn"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Configuration.UserSecrets"; version = "8.0.0"; sha256 = "1br01zhzhnxjzqx63bxd25x48y9xs69hcs71pjni8y9kl50zja7z"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection"; version = "8.0.0"; sha256 = "0i7qziz0iqmbk8zzln7kx9vd0lbx1x3va0yi3j1bgkjir13h78ps"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "8.0.0"; sha256 = "1zw0bpp5742jzx03wvqc8csnvsbgdqi0ls9jfc5i2vd3cl8b74pg"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.DependencyInjection.Abstractions"; version = "8.0.1"; sha256 = "1wyhpamm1nqjfi3r463dhxljdlr6rm2ax4fvbgq2s0j3jhpdhd4p"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Diagnostics"; version = "8.0.0"; sha256 = "0ghwkld91k20hcbmzg2137w81mzzdh8hfaapdwckhza0vipya4kw"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Diagnostics.Abstractions"; version = "8.0.0"; sha256 = "15m4j6w9n8h0mj7hlfzb83hd3wn7aq1s7fxbicm16slsjfwzj82i"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.FileProviders.Abstractions"; version = "8.0.0"; sha256 = "1idq65fxwcn882c06yci7nscy9i0rgw6mqjrl7362prvvsd9f15r"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.FileProviders.Physical"; version = "8.0.0"; sha256 = "05wxjvjbx79ir7vfkri6b28k8zl8fa6bbr0i7gahqrim2ijvkp6v"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.FileSystemGlobbing"; version = "8.0.0"; sha256 = "1igf2bqism22fxv7km5yv028r4rg12a4lki2jh4xg3brjkagiv7q"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Hosting"; version = "8.0.0"; sha256 = "1f2af5m1yny8b43251gsj75hjd9ixni1clcldy8cg91z1vxxm8dh"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Hosting.Abstractions"; version = "8.0.0"; sha256 = "00d5dwmzw76iy8z40ly01hy9gly49a7rpf7k7m99vrid1kxp346h"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging"; version = "8.0.0"; sha256 = "0nppj34nmq25gnrg0wh1q22y4wdqbih4ax493f226azv8mkp9s1i"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "8.0.0"; sha256 = "1klcqhg3hk55hb6vmjiq2wgqidsl81aldw0li2z98lrwx26msrr6"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Abstractions"; version = "8.0.1"; sha256 = "0i9pgmk60b8xlws3q9z890gim1xjq42dhyh6dj4xvbycmgg1x1sd"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Configuration"; version = "8.0.0"; sha256 = "1d9b734vnll935661wqkgl7ry60rlh5p876l2bsa930mvfsaqfcv"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Console"; version = "8.0.0"; sha256 = "1mvp3ipw7k33v2qw2yrvc4vl5yzgpk3yxa94gg0gz7wmcmhzvmkd"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.Debug"; version = "8.0.0"; sha256 = "1h7mg97lj0ss47kq7zwnihh9c6xcrkwrr8ffhc16qcsrh36sg6q0"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.EventLog"; version = "8.0.0"; sha256 = "05vfrxw7mlwlwhsl6r4yrhxk3sd8dv5sl0hdlcpgw62n53incw5x"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Logging.EventSource"; version = "8.0.0"; sha256 = "0gbjll6p03rmw0cf8fp0p8cxzn9awmzv8hvnyqbczrkax5h7p94i"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Options"; version = "8.0.0"; sha256 = "0p50qn6zhinzyhq9sy5svnmqqwhw2jajs2pbjh9sah504wjvhscz"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Options.ConfigurationExtensions"; version = "8.0.0"; sha256 = "04nm8v5a3zp0ill7hjnwnja3s2676b4wffdri8hdk2341p7mp403"; })
+ (fetchNuGet { pname = "Microsoft.Extensions.Primitives"; version = "8.0.0"; sha256 = "0aldaz5aapngchgdr7dax9jw5wy7k7hmjgjpfgfv1wfif27jlkqm"; })
+ (fetchNuGet { pname = "System.Diagnostics.DiagnosticSource"; version = "8.0.0"; sha256 = "0nzra1i0mljvmnj1qqqg37xs7bl71fnpl68nwmdajchh65l878zr"; })
+ (fetchNuGet { pname = "System.Diagnostics.EventLog"; version = "8.0.0"; sha256 = "1xnvcidh2qf6k7w8ij1rvj0viqkq84cq47biw0c98xhxg5rk3pxf"; })
+ (fetchNuGet { pname = "System.Text.Encodings.Web"; version = "8.0.0"; sha256 = "1wbypkx0m8dgpsaqgyywz4z760xblnwalb241d5qv9kx8m128i11"; })
+ (fetchNuGet { pname = "System.Text.Json"; version = "8.0.0"; sha256 = "134savxw0sq7s448jnzw17bxcijsi1v38mirpbb6zfxmqlf04msw"; })
+]
|