about summary refs log tree commit diff
path: root/MatrixContentFilter
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixContentFilter')
-rw-r--r--MatrixContentFilter/Abstractions/IContentFilter.cs27
-rw-r--r--MatrixContentFilter/Commands/CheckHistoryCommand.cs74
-rw-r--r--MatrixContentFilter/Commands/ConfigureCommand.cs38
-rw-r--r--MatrixContentFilter/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs18
-rw-r--r--MatrixContentFilter/Commands/DumpEventCommand.cs31
-rw-r--r--MatrixContentFilter/Commands/GetConfigCommand.cs56
-rw-r--r--MatrixContentFilter/Commands/NewRoomCommand.cs24
-rw-r--r--MatrixContentFilter/Commands/RedactCommand.cs99
-rw-r--r--MatrixContentFilter/EventTypes/BotEnvironmentConfiguration.cs17
-rw-r--r--MatrixContentFilter/EventTypes/FilterConfiguration.cs43
-rw-r--r--MatrixContentFilter/Handlers/CommandResultHandler.cs40
-rw-r--r--MatrixContentFilter/Handlers/Filters/ImageFilter.cs92
-rw-r--r--MatrixContentFilter/Handlers/InviteHandler.cs29
-rw-r--r--MatrixContentFilter/MatrixContentFilter.csproj39
-rw-r--r--MatrixContentFilter/MatrixContentFilterConfiguration.cs18
-rw-r--r--MatrixContentFilter/Program.cs70
-rw-r--r--MatrixContentFilter/Properties/launchSettings.json26
m---------MatrixContentFilter/Resources0
-rw-r--r--MatrixContentFilter/Services/AsyncActionQueues/AbstractionAsyncActionQueue.cs38
-rw-r--r--MatrixContentFilter/Services/AsyncActionQueues/FiFoAsyncActionQueue.cs60
-rw-r--r--MatrixContentFilter/Services/AsyncActionQueues/LiFoAsyncActionQueue.cs67
-rw-r--r--MatrixContentFilter/Services/AsyncMessageQueue.cs43
-rw-r--r--MatrixContentFilter/Services/BotModeSanityCheckService.cs56
-rw-r--r--MatrixContentFilter/Services/ConfigurationService.cs198
-rw-r--r--MatrixContentFilter/Services/InfoCacheService.cs29
-rw-r--r--MatrixContentFilter/Services/MatrixContentFilterBot.cs143
-rw-r--r--MatrixContentFilter/appsettings.Development.json24
-rw-r--r--MatrixContentFilter/appsettings.json9
-rw-r--r--MatrixContentFilter/deps.nix35
29 files changed, 1443 insertions, 0 deletions
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"; })
+]