about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-03-15 18:10:58 +0100
committerRory& <root@rory.gay>2024-03-15 18:11:18 +0100
commit096375344ef87fe53ca009b7a7eaa34c9c9f5407 (patch)
tree76d666cd6961ca04ae9e91e47c43d91eed27a87a
parentFix README (diff)
downloadLibMatrix-096375344ef87fe53ca009b7a7eaa34c9c9f5407.tar.xz
Bot changes, move named filters to subclass
Diffstat (limited to '')
m---------ArcaneLibs0
-rw-r--r--LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs3
-rw-r--r--LibMatrix/EventIdResponse.cs2
-rw-r--r--LibMatrix/Helpers/MessageBuilder.cs24
-rw-r--r--LibMatrix/Helpers/SyncHelper.cs2
-rw-r--r--LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs93
-rw-r--r--LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs37
-rw-r--r--LibMatrix/Homeservers/Extensions/NamedCaches/NamedFileCache.cs3
-rw-r--r--LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs33
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/BotCommandInstaller.cs56
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Commands/HelpCommand.cs21
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Commands/PingCommand.cs2
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs14
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Interfaces/ICommand.cs10
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/LibMatrixBotConfiguration.cs8
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs128
-rw-r--r--Utilities/LibMatrix.Utilities.Bot/Services/InviteListenerHostedService.cs86
17 files changed, 409 insertions, 113 deletions
diff --git a/ArcaneLibs b/ArcaneLibs
-Subproject d74542fb951759ee6abef21c3b68a3867933c0b
+Subproject e94a5b1a6117e9597eca647df64e12dc855b304
diff --git a/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs b/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
index f3e3f4f..f87fa62 100644
--- a/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
+++ b/LibMatrix.EventTypes/Spec/RoomMessageEventContent.cs
@@ -33,6 +33,9 @@ public class RoomMessageEventContent : TimelineEventContent {
 
     [JsonPropertyName("info")]
     public FileInfoStruct? FileInfo { get; set; }
+    
+    [JsonIgnore]
+    public string BodyWithoutReplyFallback => Body.Split('\n').SkipWhile(x => x.StartsWith(">")).SkipWhile(x=>x.Trim().Length == 0).Aggregate((x, y) => $"{x}\n{y}");
 
     public class FileInfoStruct {
         [JsonPropertyName("mimetype")]
diff --git a/LibMatrix/EventIdResponse.cs b/LibMatrix/EventIdResponse.cs
index c2ad273..0a7cfd9 100644
--- a/LibMatrix/EventIdResponse.cs
+++ b/LibMatrix/EventIdResponse.cs
@@ -3,8 +3,6 @@ using System.Text.Json.Serialization;
 namespace LibMatrix;
 
 public class EventIdResponse(string eventId) {
-    public EventIdResponse(StateEventResponse stateEventResponse) : this(stateEventResponse.EventId ?? throw new NullReferenceException("State event ID is null!")) { }
-
     [JsonPropertyName("event_id")]
     public string EventId { get; set; } = eventId;
 }
\ No newline at end of file
diff --git a/LibMatrix/Helpers/MessageBuilder.cs b/LibMatrix/Helpers/MessageBuilder.cs
index 68f6300..07953e3 100644
--- a/LibMatrix/Helpers/MessageBuilder.cs
+++ b/LibMatrix/Helpers/MessageBuilder.cs
@@ -50,11 +50,18 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
         Content.FormattedBody += "</font>";
         return this;
     }
+    
+    public MessageBuilder WithCustomEmoji(string mxcUri, string name) {
+        Content.Body += $"{{{name}}}";
+        Content.FormattedBody += $"<img data-mx-emoticon height=\"32\" src=\"{mxcUri}\" alt=\"{name}\" title=\"{name}\" />";
+        return this;
+    }
+
+    public MessageBuilder WithRainbowString(string text, byte skip = 1, int offset = 0, double lengthFactor = 255.0, bool useLength = true) {
+        if (useLength) {
+            lengthFactor = text.Length;
+        }
 
-    public MessageBuilder WithRainbowString(string text, byte skip = 1, int offset = 0, double lengthFactor = 255.0, bool useLength = true) =>
-        // if (useLength) {
-        //     lengthFactor = text.Length;
-        // }
         // HslaColorInterpolator interpolator = new((0, 255, 128, 255), (255, 255, 128, 255));
         // // RainbowEnumerator enumerator = new(skip, offset, lengthFactor);
         // for (int i = 0; i < text.Length; i++) {
@@ -63,5 +70,12 @@ public class MessageBuilder(string msgType = "m.text", string format = "org.matr
         //     // Console.WriteLine($"RBA: {r} {g} {b} {a}");
         //     // Content.FormattedBody += $"<font color=\"#{r:X2}{g:X2}{b:X2}\">{text[i]}</font>";
         // }
-        this;
+        return this;
+    }
+    
+    public MessageBuilder WithCodeBlock(string code, string language = "plaintext") {
+        Content.Body += code;
+        Content.FormattedBody += $"<pre><code class=\"language-{language}\">{code}</code></pre>";
+        return this;
+    }
 }
\ No newline at end of file
diff --git a/LibMatrix/Helpers/SyncHelper.cs b/LibMatrix/Helpers/SyncHelper.cs
index 9d339e4..e696b70 100644
--- a/LibMatrix/Helpers/SyncHelper.cs
+++ b/LibMatrix/Helpers/SyncHelper.cs
@@ -55,7 +55,7 @@ public class SyncHelper(AuthenticatedHomeserverGeneric homeserver, ILogger? logg
 
     private async Task updateFilterAsync() {
         if (!string.IsNullOrWhiteSpace(NamedFilterName)) {
-            _filterId = await homeserver.GetOrUploadNamedFilterIdAsync(NamedFilterName);
+            _filterId = await homeserver.NamedCaches.FilterCache.GetOrSetValueAsync(NamedFilterName);
             if (_filterId is null)
                 if (logger is null) Console.WriteLine($"Failed to get filter ID for named filter {NamedFilterName}");
                 else logger.LogWarning("Failed to get filter ID for named filter {NamedFilterName}", NamedFilterName);
diff --git a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
index 1c93235..b4c1cc9 100644
--- a/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
+++ b/LibMatrix/Homeservers/AuthenticatedHomeserverGeneric.cs
@@ -1,6 +1,7 @@
 using System.Diagnostics.CodeAnalysis;
 using System.Net.Http.Headers;
 using System.Net.Http.Json;
+using System.Runtime.CompilerServices;
 using System.Text.Json;
 using System.Text.Json.Nodes;
 using System.Text.Json.Serialization;
@@ -10,6 +11,7 @@ using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Extensions;
 using LibMatrix.Filters;
 using LibMatrix.Helpers;
+using LibMatrix.Homeservers.Extensions.NamedCaches;
 using LibMatrix.Responses;
 using LibMatrix.RoomTypes;
 using LibMatrix.Services;
@@ -46,6 +48,7 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
         }
 
         instance.WhoAmI = await instance.ClientHttpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami");
+        instance.NamedCaches = new HsNamedCaches(instance);
 
         return instance;
     }
@@ -57,6 +60,8 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
 
     public string AccessToken { get; set; } = accessToken;
 
+    public HsNamedCaches NamedCaches { get; set; } = null!;
+
     public GenericRoom GetRoom(string roomId) {
         if (roomId is null || !roomId.StartsWith("!")) throw new ArgumentException("Room ID must start with !", nameof(roomId));
         return new GenericRoom(this, roomId);
@@ -294,6 +299,12 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
         WhoAmI = await ClientHttpClient.GetFromJsonAsync<WhoAmIResponse>("/_matrix/client/v3/account/whoami");
     }
 
+    /// <summary>
+    ///   Upload a filter to the homeserver. Substitutes @me with the user's ID.
+    /// </summary>
+    /// <param name="filter"></param>
+    /// <returns></returns>
+    /// <exception cref="Exception"></exception>
     public async Task<FilterIdResponse> UploadFilterAsync(SyncFilter filter) {
         List<List<string>?> senderLists = [
             filter.AccountData?.Senders,
@@ -326,69 +337,21 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
         return _filterCache[filterId] = await resp.Content.ReadFromJsonAsync<SyncFilter>() ?? throw new Exception("Failed to get filter?");
     }
 
-#region Named filters
-
-    private async Task<Dictionary<string, string>?> GetNamedFilterListOrNullAsync(bool cached = true) {
-        if (cached && _namedFilterCache is not null) return _namedFilterCache;
-        try {
-            return _namedFilterCache = await GetAccountDataAsync<Dictionary<string, string>>("gay.rory.libmatrix.named_filters");
-        }
-        catch (MatrixException e) {
-            if (e is not { ErrorCode: "M_NOT_FOUND" }) throw;
-        }
-
-        return null;
-    }
-
-    /// <summary>
-    /// Utility function to allow avoiding serverside duplication
-    /// </summary>
-    /// <param name="filterName">Name of the filter (<i>please</i> properly namespace and possibly version this...)</param>
-    /// <param name="filter">The filter data</param>
-    /// <returns>Filter ID response</returns>
-    /// <exception cref="Exception"></exception>
-    public async Task<FilterIdResponse> UploadNamedFilterAsync(string filterName, SyncFilter filter) {
-        var idResp = await UploadFilterAsync(filter);
-
-        var filterList = await GetNamedFilterListOrNullAsync() ?? new Dictionary<string, string>();
-        filterList[filterName] = idResp.FilterId;
-        await SetAccountDataAsync("gay.rory.libmatrix.named_filters", filterList);
-
-        _namedFilterCache = filterList;
-
-        return idResp;
-    }
-
-    public async Task<string?> GetNamedFilterIdOrNullAsync(string filterName) {
-        var filterList = await GetNamedFilterListOrNullAsync() ?? new Dictionary<string, string>();
-        return filterList.GetValueOrDefault(filterName); //todo: validate that filter exists
-    }
-
-    public async Task<SyncFilter?> GetNamedFilterOrNullAsync(string filterName) {
-        var filterId = await GetNamedFilterIdOrNullAsync(filterName);
-        if (filterId is null) return null;
-        return await GetFilterAsync(filterId);
-    }
-
-    public async Task<string?> GetOrUploadNamedFilterIdAsync(string filterName, SyncFilter? filter = null) {
-        var filterId = await GetNamedFilterIdOrNullAsync(filterName);
-        if (filterId is not null) return filterId;
-        if (filter is null && CommonSyncFilters.FilterMap.TryGetValue(filterName, out var commonFilter)) filter = commonFilter;
-        if (filter is null) throw new ArgumentException($"Filter is null and no common filter was found, filterName={filterName}", nameof(filter));
-        var idResp = await UploadNamedFilterAsync(filterName, filter);
-        return idResp.FilterId;
-    }
-
-#endregion
-
     public class FilterIdResponse {
         [JsonPropertyName("filter_id")]
         public required string FilterId { get; set; }
     }
 
+    /// <summary>
+    ///   Enumerate all account data per room.
+    ///   <b>Warning</b>: This uses /sync!
+    /// </summary>
+    /// <param name="includeGlobal">Include non-room account data</param>
+    /// <returns>Dictionary of room IDs and their account data.</returns>
+    /// <exception cref="Exception"></exception>
     public async Task<Dictionary<string, EventList?>> EnumerateAccountDataPerRoom(bool includeGlobal = false) {
         var syncHelper = new SyncHelper(this);
-        syncHelper.FilterId = await GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetAccountDataWithRooms);
+        syncHelper.FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountDataWithRooms);
         var resp = await syncHelper.SyncAsync();
         if (resp is null) throw new Exception("Sync failed");
         var perRoomAccountData = new Dictionary<string, EventList?>();
@@ -400,9 +363,15 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
         return perRoomAccountData;
     }
 
+    /// <summary>
+    ///   Enumerate all non-room account data.
+    ///   <b>Warning</b>: This uses /sync!
+    /// </summary>
+    /// <returns>All account data.</returns>
+    /// <exception cref="Exception"></exception>
     public async Task<EventList?> EnumerateAccountData() {
         var syncHelper = new SyncHelper(this);
-        syncHelper.FilterId = await GetOrUploadNamedFilterIdAsync(CommonSyncFilters.GetAccountData);
+        syncHelper.FilterId = await NamedCaches.FilterCache.GetOrSetValueAsync(CommonSyncFilters.GetAccountData);
         var resp = await syncHelper.SyncAsync();
         if (resp is null) throw new Exception("Sync failed");
         return resp.AccountData;
@@ -420,4 +389,14 @@ public class AuthenticatedHomeserverGeneric(string serverName, string accessToke
 
         return await res.Content.ReadFromJsonAsync<JsonObject>();
     }
+
+    public class HsNamedCaches {
+        internal HsNamedCaches(AuthenticatedHomeserverGeneric hs) {
+            FileCache = new NamedFileCache(hs);
+            FilterCache = new NamedFilterCache(hs);
+        }
+
+        public NamedFilterCache FilterCache { get; init; }
+        public NamedFileCache FileCache { get; init; }
+    }
 }
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
new file mode 100644
index 0000000..622eef6
--- /dev/null
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedCache.cs
@@ -0,0 +1,37 @@
+namespace LibMatrix.Homeservers.Extensions.NamedCaches;
+
+public class NamedCache<T>(AuthenticatedHomeserverGeneric hs, string name) where T : class {
+    private Dictionary<string, T>? _cache = new();
+    private DateTime _expiry = DateTime.MinValue;
+    
+    public async Task<Dictionary<string, T>> ReadCacheMapAsync() {
+        _cache = await hs.GetAccountDataOrNullAsync<Dictionary<string, T>>(name);
+
+        return _cache ?? new();
+    }
+    
+    public async Task<Dictionary<string,T>> ReadCacheMapCachedAsync() {
+        if (_expiry < DateTime.Now || _cache == null) {
+            _cache = await ReadCacheMapAsync();
+            _expiry = DateTime.Now.AddMinutes(5);
+        }
+
+        return _cache;
+    }
+    
+    public virtual async Task<T?> GetValueAsync(string key) {
+        return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key);
+    }
+    
+    public virtual async Task<T> SetValueAsync(string key, T value) {
+        var cache = await ReadCacheMapCachedAsync();
+        cache[key] = value;
+        await hs.SetAccountDataAsync(name, cache);
+
+        return value;
+    }
+    
+    public virtual async Task<T> GetOrSetValueAsync(string key, Func<Task<T>> value) {
+        return (await ReadCacheMapCachedAsync()).GetValueOrDefault(key) ?? await SetValueAsync(key, await value());
+    }
+}
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFileCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFileCache.cs
new file mode 100644
index 0000000..87b7636
--- /dev/null
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFileCache.cs
@@ -0,0 +1,3 @@
+namespace LibMatrix.Homeservers.Extensions.NamedCaches;
+
+public class NamedFileCache(AuthenticatedHomeserverGeneric hs) : NamedCache<string>(hs, "gay.rory.libmatrix.named_cache.media") { }
\ No newline at end of file
diff --git a/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs
new file mode 100644
index 0000000..76533a4
--- /dev/null
+++ b/LibMatrix/Homeservers/Extensions/NamedCaches/NamedFilterCache.cs
@@ -0,0 +1,33 @@
+using LibMatrix.Filters;
+using LibMatrix.Utilities;
+
+namespace LibMatrix.Homeservers.Extensions.NamedCaches;
+
+public class NamedFilterCache(AuthenticatedHomeserverGeneric hs) : NamedCache<string>(hs, "gay.rory.libmatrix.named_cache.filter") {
+    /// <summary>
+    ///   <inheritdoc cref="NamedCache{T}.GetOrSetValueAsync"/>
+    ///   Allows passing a filter directly, or using a common filter.
+    ///   Substitutes @me for the user's ID.
+    /// </summary>
+    /// <param name="key">Filter name</param>
+    /// <param name="filter">Filter to upload if not cached, otherwise defaults to common filters if that exists.</param>
+    /// <returns></returns>
+    /// <exception cref="ArgumentNullException"></exception>
+    public async Task<string> GetOrSetValueAsync(string key, SyncFilter? filter = null) {
+        var existingValue = await GetValueAsync(key);
+        if (existingValue != null) {
+            return existingValue;
+        }
+
+        if (filter is null) {
+            if(CommonSyncFilters.FilterMap.TryGetValue(key, out var commonFilter)) {
+                filter = commonFilter;
+            } else {
+                throw new ArgumentNullException(nameof(filter));
+            }
+        }
+
+        var filterUpload = await hs.UploadFilterAsync(filter);
+        return await SetValueAsync(key, filterUpload.FilterId);
+    }
+}
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/BotCommandInstaller.cs b/Utilities/LibMatrix.Utilities.Bot/BotCommandInstaller.cs
index eb67424..215f28a 100644
--- a/Utilities/LibMatrix.Utilities.Bot/BotCommandInstaller.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/BotCommandInstaller.cs
@@ -1,5 +1,7 @@
 using ArcaneLibs;
+using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Homeservers;
+using LibMatrix.Responses;
 using LibMatrix.Services;
 using LibMatrix.Utilities.Bot.Interfaces;
 using LibMatrix.Utilities.Bot.Services;
@@ -8,16 +10,13 @@ using Microsoft.Extensions.DependencyInjection;
 namespace LibMatrix.Utilities.Bot;
 
 public static class BotCommandInstaller {
-    public static IServiceCollection AddBotCommands(this IServiceCollection services) {
-        foreach (var commandClass in new ClassCollector<ICommand>().ResolveFromAllAccessibleAssemblies()) {
-            Console.WriteLine($"Adding command {commandClass.Name}");
-            services.AddScoped(typeof(ICommand), commandClass);
-        }
-
-        return services;
+    public static BotInstaller AddMatrixBot(this IServiceCollection services) {
+        return new BotInstaller(services).AddMatrixBot();
     }
+}
 
-    public static IServiceCollection AddBot(this IServiceCollection services, bool withCommands = true, bool isAppservice = false) {
+public class BotInstaller(IServiceCollection services) {
+    public BotInstaller AddMatrixBot() {
         services.AddSingleton<LibMatrixBotConfiguration>();
 
         services.AddScoped<AuthenticatedHomeserverGeneric>(x => {
@@ -28,13 +27,42 @@ public static class BotCommandInstaller {
             return hs;
         });
 
-        if (withCommands) {
-            Console.WriteLine("Adding command handler...");
-            services.AddBotCommands();
-            services.AddHostedService<CommandListenerHostedService>();
-            // services.AddSingleton<IHostedService, CommandListenerHostedService>();
+        return this;
+    }
+
+    public BotInstaller AddCommandHandler() {
+        Console.WriteLine("Adding command handler...");
+        services.AddHostedService<CommandListenerHostedService>();
+        return this;
+    }
+
+    public BotInstaller DiscoverAllCommands() {
+        foreach (var commandClass in new ClassCollector<ICommand>().ResolveFromAllAccessibleAssemblies()) {
+            Console.WriteLine($"Adding command {commandClass.Name}");
+            services.AddScoped(typeof(ICommand), commandClass);
         }
 
-        return services;
+        return this;
+    }
+    public BotInstaller AddCommands(IEnumerable<Type> commandClasses) {
+        foreach (var commandClass in commandClasses) {
+            if(!commandClass.IsAssignableTo(typeof(ICommand)))
+                throw new Exception($"Type {commandClass.Name} is not assignable to ICommand!");
+            Console.WriteLine($"Adding command {commandClass.Name}");
+            services.AddScoped(typeof(ICommand), commandClass);
+        }
+
+        return this;
+    }
+    
+    public BotInstaller WithInviteHandler(Func<InviteHandlerHostedService.InviteEventArgs, Task> inviteHandler) {
+        services.AddSingleton(inviteHandler);
+        services.AddHostedService<InviteHandlerHostedService>();
+        return this;
+    }
+    
+    public BotInstaller WithCommandResultHandler(Func<CommandResult, Task> commandResultHandler) {
+        services.AddSingleton(commandResultHandler);
+        return this;
     }
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/Commands/HelpCommand.cs b/Utilities/LibMatrix.Utilities.Bot/Commands/HelpCommand.cs
index 9937b3c..979fab6 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Commands/HelpCommand.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Commands/HelpCommand.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
 using System.Text;
 using LibMatrix.EventTypes.Spec;
 using LibMatrix.Utilities.Bot.Interfaces;
@@ -7,12 +8,30 @@ namespace LibMatrix.Utilities.Bot.Commands;
 
 public class HelpCommand(IServiceProvider services) : ICommand {
     public string Name { get; } = "help";
+    public string[]? Aliases { get; } = new[] { "?" };
     public string Description { get; } = "Displays this help message";
+    public bool Unlisted { get; }
 
     public async Task Invoke(CommandContext ctx) {
         var sb = new StringBuilder();
         sb.AppendLine("Available commands:");
-        var commands = services.GetServices<ICommand>().ToList();
+        var commands = services.GetServices<ICommand>().Where(x => !x.Unlisted).ToList();
+        foreach (var command in commands) sb.AppendLine($"- {command.Name}: {command.Description}");
+
+        await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", sb.ToString()));
+    }
+}
+
+public class HelpCommandWithSubCommands<T>(T command) where T : ICommandGroup {
+    public string Name { get; } = "help";
+    public string[]? Aliases { get; } = new[] { "?" };
+    public string Description { get; } = "Displays this help message";
+
+    public async Task Invoke(CommandContext ctx) {
+        var sb = new StringBuilder();
+        sb.AppendLine("Available subcommands:");
+        var commands = command.SubCommands;
+
         foreach (var command in commands) sb.AppendLine($"- {command.Name}: {command.Description}");
 
         await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", sb.ToString()));
diff --git a/Utilities/LibMatrix.Utilities.Bot/Commands/PingCommand.cs b/Utilities/LibMatrix.Utilities.Bot/Commands/PingCommand.cs
index b5fb868..9959bf6 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Commands/PingCommand.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Commands/PingCommand.cs
@@ -5,7 +5,9 @@ namespace LibMatrix.Utilities.Bot.Commands;
 
 public class PingCommand : ICommand {
     public string Name { get; } = "ping";
+    public string[]? Aliases { get; }
     public string Description { get; } = "Pong!";
+    public bool Unlisted { get; }
 
     public async Task Invoke(CommandContext ctx) => await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent(body: "pong!"));
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs b/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs
index e65f86d..062e99f 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Interfaces/CommandContext.cs
@@ -19,4 +19,18 @@ public class CommandContext {
     public required AuthenticatedHomeserverGeneric Homeserver { get; set; }
 
     public async Task<EventIdResponse> Reply(RoomMessageEventContent content) => await Room.SendMessageEventAsync(content);
+}
+
+public class CommandResult {
+    public required bool Success { get; set; }
+    public Exception? Exception { get; set; }
+    public required CommandResultType Result { get; set; }
+    public required CommandContext Context { get; set; }
+
+    public enum CommandResultType {
+        Success,
+        Failure_Exception,
+        Failure_NoPermission,
+        Failure_InvalidCommand
+    }
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/Interfaces/ICommand.cs b/Utilities/LibMatrix.Utilities.Bot/Interfaces/ICommand.cs
index 453a8fe..4626a23 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Interfaces/ICommand.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Interfaces/ICommand.cs
@@ -1,10 +1,20 @@
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
 namespace LibMatrix.Utilities.Bot.Interfaces;
 
 public interface ICommand {
     public string Name { get; }
+    public string[]? Aliases { get; }
     public string Description { get; }
+    public bool Unlisted { get; }
 
     public Task<bool> CanInvoke(CommandContext ctx) => Task.FromResult(true);
 
     public Task Invoke(CommandContext ctx);
+}
+
+
+public interface ICommandGroup : ICommand {
+    public IImmutableList<ICommand> SubCommands { get; }
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/LibMatrixBotConfiguration.cs b/Utilities/LibMatrix.Utilities.Bot/LibMatrixBotConfiguration.cs
index d607637..245442f 100644
--- a/Utilities/LibMatrix.Utilities.Bot/LibMatrixBotConfiguration.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/LibMatrixBotConfiguration.cs
@@ -1,11 +1,13 @@
+using LibMatrix.Utilities.Bot.Interfaces;
 using Microsoft.Extensions.Configuration;
 
 namespace LibMatrix.Utilities.Bot;
 
 public class LibMatrixBotConfiguration {
     public LibMatrixBotConfiguration(IConfiguration config) => config.GetRequiredSection("LibMatrixBot").Bind(this);
-    public string Homeserver { get; set; } = "";
-    public string AccessToken { get; set; } = "";
-    public string Prefix { get; set; } = "?";
+    public string Homeserver { get; set; }
+    public string AccessToken { get; set; }
+    public List<string> Prefixes { get; set; }
+    public bool MentionPrefix { get; set; }
     public string? LogRoom { get; set; }
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
index d9e4dc8..1f91268 100644
--- a/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
+++ b/Utilities/LibMatrix.Utilities.Bot/Services/CommandListenerHostedService.cs
@@ -1,5 +1,7 @@
 using System.Reflection.Metadata;
+using ArcaneLibs.Extensions;
 using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State;
 using LibMatrix.Filters;
 using LibMatrix.Helpers;
 using LibMatrix.Homeservers;
@@ -15,15 +17,17 @@ public class CommandListenerHostedService : IHostedService {
     private readonly ILogger<CommandListenerHostedService> _logger;
     private readonly IEnumerable<ICommand> _commands;
     private readonly LibMatrixBotConfiguration _config;
+    private readonly Func<CommandResult, Task>? _commandResultHandler;
 
     private Task? _listenerTask;
 
     public CommandListenerHostedService(AuthenticatedHomeserverGeneric hs, ILogger<CommandListenerHostedService> logger, IServiceProvider services,
-        LibMatrixBotConfiguration config) {
+        LibMatrixBotConfiguration config, Func<CommandResult, Task>? commandResultHandler = null) {
         logger.LogInformation("{} instantiated!", GetType().Name);
         _hs = hs;
         _logger = logger;
         _config = config;
+        _commandResultHandler = commandResultHandler;
         _logger.LogInformation("Getting commands...");
         _commands = services.GetServices<ICommand>();
         _logger.LogInformation("Got {} commands!", _commands.Count());
@@ -39,7 +43,7 @@ public class CommandListenerHostedService : IHostedService {
 
     private async Task? Run(CancellationToken cancellationToken) {
         _logger.LogInformation("Starting command listener!");
-        var filter = await _hs.GetOrUploadNamedFilterIdAsync("gay.rory.libmatrix.utilities.bot.command_listener_syncfilter.dev2", new SyncFilter() {
+        var filter = await _hs.NamedCaches.FilterCache.GetOrSetValueAsync("gay.rory.libmatrix.utilities.bot.command_listener_syncfilter.dev2", new SyncFilter() {
             AccountData = new SyncFilter.EventFilter(notTypes: ["*"], limit: 1),
             Presence = new SyncFilter.EventFilter(notTypes: ["*"]),
             Room = new SyncFilter.RoomFilter() {
@@ -49,44 +53,22 @@ public class CommandListenerHostedService : IHostedService {
                 Timeline = new SyncFilter.RoomFilter.StateFilter(types: ["m.room.message"], notSenders: [_hs.WhoAmI.UserId]),
             }
         });
+
         var syncHelper = new SyncHelper(_hs, _logger) {
             Timeout = 300_000,
             FilterId = filter
         };
+
         syncHelper.TimelineEventHandlers.Add(async @event => {
             try {
                 var room = _hs.GetRoom(@event.RoomId);
                 // _logger.LogInformation(eventResponse.ToJson(indent: false));
                 if (@event is { Type: "m.room.message", TypedContent: RoomMessageEventContent message })
                     if (message is { MessageType: "m.text" }) {
-                        var messageContentWithoutReply =
-                            message.Body.Split('\n', StringSplitOptions.RemoveEmptyEntries).SkipWhile(x => x.StartsWith(">")).Aggregate((x, y) => $"{x}\n{y}");
-                        if (messageContentWithoutReply.StartsWith(_config.Prefix)) {
-                            var command = _commands.FirstOrDefault(x => x.Name == messageContentWithoutReply.Split(' ')[0][_config.Prefix.Length..]);
-                            if (command == null) {
-                                await room.SendMessageEventAsync(
-                                    new RoomMessageEventContent("m.notice", "Command not found!"));
-                                return;
-                            }
-
-                            var ctx = new CommandContext {
-                                Room = room,
-                                MessageEvent = @event,
-                                Homeserver = _hs
-                            };
-
-                            if (await command.CanInvoke(ctx))
-                                try {
-                                    await command.Invoke(ctx);
-                                }
-                                catch (Exception e) {
-                                    await room.SendMessageEventAsync(
-                                        MessageFormatter.FormatException("An error occurred during the execution of this command", e));
-                                }
-                            else
-                                await room.SendMessageEventAsync(
-                                    new RoomMessageEventContent("m.notice", "You do not have permission to run this command!"));
-                        }
+                        var usedPrefix = await GetUsedPrefix(@event);
+                        if (usedPrefix is null) return;
+                        var res = await InvokeCommand(@event, usedPrefix);
+                        await (_commandResultHandler?.Invoke(res) ?? HandleResult(res));
                     }
             }
             catch (Exception e) {
@@ -107,4 +89,90 @@ public class CommandListenerHostedService : IHostedService {
 
         await _listenerTask.WaitAsync(cancellationToken);
     }
+
+    private async Task<string?> GetUsedPrefix(StateEventResponse evt) {
+        var messageContent = evt.TypedContent as RoomMessageEventContent;
+        var message = messageContent!.BodyWithoutReplyFallback;
+        var prefix = _config.Prefixes.OrderByDescending(x => x.Length).FirstOrDefault(message.StartsWith);
+        if (prefix is null && _config.MentionPrefix) {
+            var profile = await _hs.GetProfileAsync(_hs.WhoAmI.UserId);
+            var roomProfile = await _hs.GetRoom(evt.RoomId!).GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, _hs.WhoAmI.UserId);
+            if(message.StartsWith(_hs.WhoAmI.UserId + ": ")) prefix = profile.DisplayName + ": "; // `@bot:server.xyz: `
+            else if (message.StartsWith(_hs.WhoAmI.UserId + " ")) prefix = profile.DisplayName + " "; // `@bot:server.xyz `
+            else if (!string.IsNullOrWhiteSpace(roomProfile?.DisplayName) && message.StartsWith(roomProfile.DisplayName + ": ")) prefix = roomProfile.DisplayName + ": "; // `local bot: `
+            else if (!string.IsNullOrWhiteSpace(roomProfile?.DisplayName) && message.StartsWith(roomProfile.DisplayName + " ")) prefix = roomProfile.DisplayName + " "; // `local bot `
+            else if (!string.IsNullOrWhiteSpace(profile.DisplayName) && message.StartsWith(profile.DisplayName + ": ")) prefix = profile.DisplayName + ": "; // `bot: `
+            else if (!string.IsNullOrWhiteSpace(profile.DisplayName) && message.StartsWith(profile.DisplayName + " ")) prefix = profile.DisplayName + " "; // `bot `
+        }
+
+        return prefix;
+    }
+    
+    private async Task<CommandResult> InvokeCommand(StateEventResponse evt, string usedPrefix) {
+        var message = evt.TypedContent as RoomMessageEventContent;
+        var room = _hs.GetRoom(evt.RoomId!);
+        
+        var ctx = new CommandContext {
+            Room = room,
+            MessageEvent = @evt,
+            Homeserver = _hs
+        };
+        
+        var commandWithoutPrefix = message.BodyWithoutReplyFallback[usedPrefix.Length..];
+        var command = _commands.OrderByDescending(x => x.Name.Length).FirstOrDefault(x => commandWithoutPrefix.StartsWith(x.Name));
+        if (commandWithoutPrefix.Length != command.Name.Length && commandWithoutPrefix[command.Name.Length] != ' ') command = null;
+
+        if (command == null) {
+            await room.SendMessageEventAsync(
+                new RoomMessageEventContent("m.notice", $"Command \"{commandWithoutPrefix.Split(' ')[0]}\" not found!"));
+            return new() {
+                Success = false,
+                Result = CommandResult.CommandResultType.Failure_InvalidCommand,
+                Context = ctx
+            };
+        }
+
+
+        if (await command.CanInvoke(ctx))
+            try {
+                await command.Invoke(ctx);
+            }
+            catch (Exception e) {
+                return new CommandResult() {
+                    Context = ctx,
+                    Result = CommandResult.CommandResultType.Failure_Exception,
+                    Success = false,
+                    Exception = e
+                };
+                // await room.SendMessageEventAsync(
+                    // MessageFormatter.FormatException("An error occurred during the execution of this command", e));
+            }
+        else
+            return new CommandResult() {
+                Context = ctx,
+                Result = CommandResult.CommandResultType.Failure_NoPermission,
+                Success = false
+            };
+            // await room.SendMessageEventAsync(
+                // new RoomMessageEventContent("m.notice", "You do not have permission to run this command!"));
+
+        return new CommandResult() {
+            Context = ctx,
+            Success = true,
+            Result = CommandResult.CommandResultType.Success
+        };
+    }
+
+    private async Task HandleResult(CommandResult res) {
+        if (res.Success) return;
+        var room = res.Context.Room;
+        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(msg);
+    }
 }
\ No newline at end of file
diff --git a/Utilities/LibMatrix.Utilities.Bot/Services/InviteListenerHostedService.cs b/Utilities/LibMatrix.Utilities.Bot/Services/InviteListenerHostedService.cs
new file mode 100644
index 0000000..7c5cc44
--- /dev/null
+++ b/Utilities/LibMatrix.Utilities.Bot/Services/InviteListenerHostedService.cs
@@ -0,0 +1,86 @@
+using System.Reflection.Metadata;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.Utilities.Bot.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace LibMatrix.Utilities.Bot.Services;
+
+public class InviteHandlerHostedService : IHostedService {
+    private readonly AuthenticatedHomeserverGeneric _hs;
+    private readonly ILogger<InviteHandlerHostedService> _logger;
+    private readonly Func<InviteEventArgs, Task> _inviteHandler;
+
+    private Task? _listenerTask;
+
+    public InviteHandlerHostedService(AuthenticatedHomeserverGeneric hs, ILogger<InviteHandlerHostedService> logger,
+        Func<InviteEventArgs, Task> inviteHandler) {
+        logger.LogInformation("{} instantiated!", GetType().Name);
+        _hs = hs;
+        _logger = logger;
+        _inviteHandler = inviteHandler;
+    }
+
+    /// <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>
+    public Task StartAsync(CancellationToken cancellationToken) {
+        _listenerTask = Run(cancellationToken);
+        _logger.LogInformation("Command listener started (StartAsync)!");
+        return Task.CompletedTask;
+    }
+
+    private async Task? Run(CancellationToken cancellationToken) {
+        _logger.LogInformation("Starting invite listener!");
+        var filter = await _hs.NamedCaches.FilterCache.GetOrSetValueAsync("gay.rory.libmatrix.utilities.bot.command_listener_syncfilter.dev2", new SyncFilter() {
+            AccountData = new SyncFilter.EventFilter(notTypes: ["*"], limit: 1),
+            Presence = new SyncFilter.EventFilter(notTypes: ["*"]),
+            Room = new SyncFilter.RoomFilter() {
+                AccountData = new SyncFilter.RoomFilter.StateFilter(notTypes: ["*"]),
+                Ephemeral = new SyncFilter.RoomFilter.StateFilter(notTypes: ["*"]),
+                State = new SyncFilter.RoomFilter.StateFilter(notTypes: ["*"]),
+                Timeline = new SyncFilter.RoomFilter.StateFilter(types: ["m.room.message"], notSenders: [_hs.WhoAmI.UserId]),
+            }
+        });
+
+        var syncHelper = new SyncHelper(_hs, _logger) {
+            Timeout = 300_000,
+            FilterId = filter
+        };
+        syncHelper.InviteReceivedHandlers.Add(async invite => {
+            _logger.LogInformation("Received invite to room {}", invite.Key);
+
+            var inviteEventArgs = new InviteEventArgs() {
+                RoomId = invite.Key,
+                MemberEvent = invite.Value.InviteState.Events.First(x => x.Type == "m.room.member" && x.StateKey == _hs.WhoAmI.UserId),
+                Homeserver = _hs
+            };
+            await _inviteHandler(inviteEventArgs);
+        });
+
+        await syncHelper.RunSyncLoopAsync(cancellationToken: cancellationToken);
+    }
+
+    /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
+    /// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
+    public async Task StopAsync(CancellationToken cancellationToken) {
+        _logger.LogInformation("Shutting down invite listener!");
+        if (_listenerTask is null) {
+            _logger.LogError("Could not shut down invite listener task because it was null!");
+            return;
+        }
+
+        await _listenerTask.WaitAsync(cancellationToken);
+    }
+
+    public class InviteEventArgs {
+        public string RoomId { get; set; }
+        public StateEventResponse MemberEvent { get; set; }
+        public AuthenticatedHomeserverGeneric Homeserver { get; set; }
+    }
+}
\ No newline at end of file