diff options
author | Rory& <root@rory.gay> | 2024-03-15 18:10:58 +0100 |
---|---|---|
committer | Rory& <root@rory.gay> | 2024-03-15 18:11:18 +0100 |
commit | 096375344ef87fe53ca009b7a7eaa34c9c9f5407 (patch) | |
tree | 76d666cd6961ca04ae9e91e47c43d91eed27a87a | |
parent | Fix README (diff) | |
download | LibMatrix-bak-096375344ef87fe53ca009b7a7eaa34c9c9f5407.tar.xz |
Bot changes, move named filters to subclass
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 |