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
|