From 8d56a7d4606941d11b02e0163ff0f57c4fdbade0 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 11:18:41 +0000 Subject: [PATCH 01/24] Adds 'test' event handler and makes a lot of usage boilerplate virtual --- bottomly.net/Bottomly/Bottomly.csproj | 7 +++ bottomly.net/Bottomly/Commands/ICommand.cs | 7 +++ bottomly.net/Bottomly/Program.cs | 61 +++++++++++-------- .../EventHandlers/AbstractEventHandler.cs | 4 +- .../GetCurrentKarmaReasonsEventHandler.cs | 5 +- .../GetCurrentNetKarmaEventHandler.cs | 5 +- .../GetLeaderBoardEventHandler.cs | 5 +- .../GetLoserBoardEventHandler.cs | 5 +- .../Slack/EventHandlers/GiphyEventHandler.cs | 5 +- .../Slack/EventHandlers/GoogleEventHandler.cs | 5 +- .../EventHandlers/GoogleImageEventHandler.cs | 5 +- .../Slack/EventHandlers/HelpEventHandler.cs | 2 +- .../AbstractKarmaEventHandler.cs | 2 +- .../Slack/EventHandlers/RegEventHandler.cs | 5 +- .../EventHandlers/ReleaseEventHandler.cs | 5 +- .../Slack/EventHandlers/TestEventHandler.cs | 21 +++++++ .../Slack/EventHandlers/UrbanEventHandler.cs | 5 +- .../EventHandlers/WikipediaEventHandler.cs | 5 +- 18 files changed, 85 insertions(+), 74 deletions(-) create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index a6317f2..8c8d6f9 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -23,4 +23,11 @@ + + + + PreserveNewest + + + diff --git a/bottomly.net/Bottomly/Commands/ICommand.cs b/bottomly.net/Bottomly/Commands/ICommand.cs index 5525426..304b891 100644 --- a/bottomly.net/Bottomly/Commands/ICommand.cs +++ b/bottomly.net/Bottomly/Commands/ICommand.cs @@ -2,5 +2,12 @@ namespace Bottomly.Commands; public interface ICommand { + static readonly ICommand None = new VoidCommand(); + string GetPurpose(); +} + +public class VoidCommand : ICommand +{ + public string GetPurpose() => "Does nothing."; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 7a9451a..98a9672 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.Repositories; @@ -50,18 +51,7 @@ builder.Services.AddSingleton(); // Commands -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.RegisterCommands(Assembly.GetExecutingAssembly()); // Slack infrastructure builder.Services.AddSingleton(); @@ -78,19 +68,7 @@ .RegisterEventHandler()); // Event handlers (registered for IEventHandler collection, excluding Help which is separate) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.RegisterEventHandlers(Assembly.GetExecutingAssembly()); // Help handler (also registered as singleton for direct injection into SlackWorker) builder.Services.AddSingleton(); @@ -104,4 +82,35 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); + +public static class HostBuilderExtensions +{ + extension(HostApplicationBuilder builder) + { + public void RegisterEventHandlers(Assembly assembly) + { + var handlerTypes = assembly.GetTypes() + .Where(t => typeof(IEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) + .Where(t => t.Name != nameof(HelpEventHandler)) + .ToList(); + + foreach (var handlerType in handlerTypes) + { + builder.Services.AddSingleton(typeof(IEventHandler), handlerType); + } + } + + public void RegisterCommands(Assembly assembly) + { + var commandTypes = assembly.GetTypes() + .Where(t => typeof(ICommand).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) + .ToList(); + + foreach (var commandType in commandTypes) + { + builder.Services.AddSingleton(commandType); + } + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs index 212a0ce..a47b548 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs @@ -21,7 +21,7 @@ public abstract class AbstractEventHandler( protected abstract string CommandSymbol { get; } protected string CommandTrigger => Prefix + CommandSymbol + " "; - public abstract bool CanHandle(MessageEvent message); + public virtual bool CanHandle(MessageEvent message) => message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; public async Task HandleAsync(MessageEvent message) { @@ -52,7 +52,7 @@ public string BuildHelpMessage() } protected abstract Task InvokeHandlerLogicAsync(MessageEvent message); - public abstract string GetUsage(); + protected virtual string GetUsage() => CommandTrigger.TrimEnd(); public virtual string GetUsageAddendum() => string.Empty; protected virtual bool IsHelpEvent(MessageEvent message) => diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs index 27a6a6e..1ee4b0c 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs @@ -20,10 +20,7 @@ public class GetCurrentKarmaReasonsEventHandler( public override string Name => "Karma Reasons"; public override ICommand Command => command; protected override string CommandSymbol => "reasons"; - public override string GetUsage() => CommandTrigger + "[recipient ]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + protected override string GetUsage() => CommandTrigger + "[recipient ]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs index 6d178eb..5fcbe55 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs @@ -19,7 +19,7 @@ public class GetCurrentNetKarmaEventHandler( public override string Name => "Get Current Karma"; public override ICommand Command => command; protected override string CommandSymbol => "karma"; - public override string GetUsage() => CommandTrigger + "[recipient ]"; + protected override string GetUsage() => CommandTrigger + "[recipient ]"; public override string GetUsageAddendum() { @@ -33,9 +33,6 @@ public override string GetUsageAddendum() return lines.ToString(); } - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var text = await parser.ReplaceSlackIdTokensWithUsernamesAsync(message.Text!); diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs index 134d246..4d9fb49 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs @@ -17,10 +17,7 @@ public class GetLeaderBoardEventHandler( public override string Name => "Get Leaderboard"; public override ICommand Command => command; protected override string CommandSymbol => "leaderboard"; - public override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + protected override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs index e2aa195..06338ef 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs @@ -17,10 +17,7 @@ public class GetLoserBoardEventHandler( public override string Name => "Get Loserboard"; public override ICommand Command => command; protected override string CommandSymbol => "loserboard"; - public override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + protected override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs index 1b7ab10..cbd4fe6 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs @@ -16,10 +16,7 @@ public class GiphyEventHandler( public override string Name => "Giphy"; public override ICommand Command => command; protected override string CommandSymbol => "gif"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs index 5211aa9..a01d384 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs @@ -16,10 +16,7 @@ public class GoogleEventHandler( public override string Name => "Google"; public override ICommand Command => command; protected override string CommandSymbol => "g"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs index ce4d18f..cc4c9a5 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs @@ -16,10 +16,7 @@ public class GoogleImageEventHandler( public override string Name => "Google Image"; public override ICommand Command => command; protected override string CommandSymbol => "gi"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs index cedc5c2..59a021a 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs @@ -20,7 +20,7 @@ public class HelpEventHandler( public override ICommand? Command => null; protected override string CommandSymbol => HelpSymbols[0]; - public override string GetUsage() + protected override string GetUsage() { var parts = HelpSymbols.Select(s => $"`{Prefix}{s}`"); return string.Join(" or ", parts); diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs index 5517121..8fc4a2a 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs @@ -24,7 +24,7 @@ public abstract class AbstractKarmaEventHandler( public abstract override string Name { get; } protected abstract KarmaType KarmaTypeValue { get; } - public override string GetUsage() => CommandSymbol + " recipient [[for ] reason]"; + protected override string GetUsage() => CommandSymbol + " recipient [[for ] reason]"; protected override bool IsHelpEvent(MessageEvent message) => message.Text?.Trim() == CommandSymbol + " -?"; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs index b2256ee..9644e20 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs @@ -16,10 +16,7 @@ public class RegEventHandler( public override string Name => "Reg Lookup"; public override ICommand Command => command; protected override string CommandSymbol => "reg"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs index 391b5e1..1520287 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs @@ -16,10 +16,7 @@ public class ReleaseEventHandler( public override string Name => "Release"; public override ICommand Command => command; protected override string CommandSymbol => "release"; - public override string GetUsage() => CommandTrigger.TrimEnd(); - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + protected override string GetUsage() => CommandTrigger.TrimEnd(); protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs new file mode 100644 index 0000000..c915b46 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs @@ -0,0 +1,21 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class TestEventHandler( + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Test"; + public override ICommand? Command => ICommand.None; + protected override string CommandSymbol => "test"; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) => + await Broker.SendMessageAsync("OK", message.Channel); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs index a112428..b452792 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs @@ -16,10 +16,7 @@ public class UrbanEventHandler( public override string Name => "Urban Dictionary"; public override ICommand Command => command; protected override string CommandSymbol => "ud"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs index 2d564f9..b76177d 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs @@ -16,10 +16,7 @@ public class WikipediaEventHandler( public override string Name => "Wikipedia"; public override ICommand Command => command; protected override string CommandSymbol => "wik"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { From e5d78b5ec1895c1fe88764929a588fa49de02e57 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 11:22:15 +0000 Subject: [PATCH 02/24] tidies some methods to expression bodies --- bottomly.net/Bottomly/Program.cs | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 98a9672..022140e 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -4,7 +4,6 @@ using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.EventHandlers; -using Bottomly.Slack.EventHandlers.KarmaEventHandlers; using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -88,29 +87,17 @@ public static class HostBuilderExtensions { extension(HostApplicationBuilder builder) { - public void RegisterEventHandlers(Assembly assembly) - { - var handlerTypes = assembly.GetTypes() + public void RegisterEventHandlers(Assembly assembly) => + assembly.GetTypes() .Where(t => typeof(IEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) .Where(t => t.Name != nameof(HelpEventHandler)) - .ToList(); + .ToList() + .ForEach(t => builder.Services.AddSingleton(typeof(IEventHandler), t)); - foreach (var handlerType in handlerTypes) - { - builder.Services.AddSingleton(typeof(IEventHandler), handlerType); - } - } - - public void RegisterCommands(Assembly assembly) - { - var commandTypes = assembly.GetTypes() + public void RegisterCommands(Assembly assembly) => + assembly.GetTypes() .Where(t => typeof(ICommand).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) - .ToList(); - - foreach (var commandType in commandTypes) - { - builder.Services.AddSingleton(commandType); - } - } + .ToList() + .ForEach(t => builder.Services.AddSingleton(t)); } } \ No newline at end of file From c5c389bfad4de77b2e784bb6240751ae8aa4456e Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 11:37:25 +0000 Subject: [PATCH 03/24] Fixes for some potential concurrency issues --- bottomly.net/.gitignore | 3 +++ bottomly.net/Bottomly/Commands/GiphyCommand.cs | 5 +++-- .../Commands/GoogleImageSearchCommand.cs | 18 ++++++++++++------ .../Bottomly/Commands/GoogleSearchCommand.cs | 16 +++++++++++----- .../Bottomly/Commands/UrbanSearchCommand.cs | 8 ++++---- .../Commands/WikipediaSearchCommand.cs | 5 +++-- bottomly.net/bottomly.net.slnx | 12 ++++++------ 7 files changed, 42 insertions(+), 25 deletions(-) diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore index 434bfbe..fda0c69 100644 --- a/bottomly.net/.gitignore +++ b/bottomly.net/.gitignore @@ -1,5 +1,8 @@ # .NET and Visual Studio .gitignore +# Planning documents +.plans/ + # Build results [Dd]ebug/ [Dd]ebugPublic/ diff --git a/bottomly.net/Bottomly/Commands/GiphyCommand.cs b/bottomly.net/Bottomly/Commands/GiphyCommand.cs index f49477d..a15eeb1 100644 --- a/bottomly.net/Bottomly/Commands/GiphyCommand.cs +++ b/bottomly.net/Bottomly/Commands/GiphyCommand.cs @@ -8,7 +8,7 @@ public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions "Uses Giphy to find a gif matching the given search term"; @@ -21,7 +21,8 @@ public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions options) : ICommand +public class GoogleImageSearchCommand : ICommand { - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; + private readonly CustomSearchAPIService _service; + private readonly string _cseId; + + public GoogleImageSearchCommand(IOptions options) + { + _cseId = options.Value.GoogleCseId; + _service = new CustomSearchAPIService( + new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); + } public string GetPurpose() => "Performs a google image search and returns the top hit."; - public async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) { return null; } - var service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); - var request = service.Cse.List(); + var request = _service.Cse.List(); request.Q = searchTerm; request.Cx = _cseId; request.Num = 1; diff --git a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs index f808f0f..a2e9c93 100644 --- a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs @@ -7,10 +7,17 @@ namespace Bottomly.Commands; public record GoogleSearchResult(string Title, string Link); -public class GoogleSearchCommand(IOptions options) : ICommand +public class GoogleSearchCommand : ICommand { - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; + private readonly CustomSearchAPIService _service; + private readonly string _cseId; + + public GoogleSearchCommand(IOptions options) + { + _cseId = options.Value.GoogleCseId; + _service = new CustomSearchAPIService( + new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); + } public string GetPurpose() => "Performs a google search and returns the top hit."; @@ -21,8 +28,7 @@ public class GoogleSearchCommand(IOptions options) : ICommand return null; } - var service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); - var request = service.Cse.List(); + var request = _service.Cse.List(); request.Q = searchTerm; request.Cx = _cseId; request.Num = 1; diff --git a/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs b/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs index 3606f92..d870867 100644 --- a/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs @@ -4,8 +4,7 @@ namespace Bottomly.Commands; public class UrbanSearchCommand(IHttpClientFactory httpClientFactory) : ICommand { - private static readonly Random _random = new(); - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; public string GetPurpose() => "Tells you what something _really_ means."; @@ -17,7 +16,8 @@ public class UrbanSearchCommand(IHttpClientFactory httpClientFactory) : ICommand } var url = $"http://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(searchTerm)}"; - var response = await _httpClient.GetAsync(url); + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); @@ -28,7 +28,7 @@ public class UrbanSearchCommand(IHttpClientFactory httpClientFactory) : ICommand return null; } - var index = _random.Next(list.GetArrayLength()); + var index = Random.Shared.Next(list.GetArrayLength()); return list[index].GetProperty("definition").GetString(); } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs b/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs index 9d9fcaf..eee8d29 100644 --- a/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs @@ -6,7 +6,7 @@ public record WikipediaResult(string Text, string Link); public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICommand { - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; public string GetPurpose() => "Performs a wikipedia search and returns the top hit."; @@ -19,7 +19,8 @@ public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICom var url = $"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={Uri.EscapeDataString(searchTerm)}"; - var response = await _httpClient.GetAsync(url); + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx index 9eb5410..3d85583 100644 --- a/bottomly.net/bottomly.net.slnx +++ b/bottomly.net/bottomly.net.slnx @@ -1,10 +1,10 @@ - - + + - - - - + + + + From 5ef3f61f760feeeee9c9df27114d52a6ea950498 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 09:39:30 +0000 Subject: [PATCH 04/24] Reshuffles namespacing to make it clear which handlers handle which events --- ... => GetCurrentKarmaReasonsHandlerTests.cs} | 12 +++++------ ...s.cs => GetCurrentNetKarmaHandlerTests.cs} | 12 +++++------ ...Tests.cs => GetLeaderBoardHandlerTests.cs} | 12 +++++------ ...rTests.cs => GetLoserBoardHandlerTests.cs} | 12 +++++------ ...ntHandlerTests.cs => GiphyHandlerTests.cs} | 12 +++++------ ...tHandlerTests.cs => GoogleHandlerTests.cs} | 12 +++++------ ...lerTests.cs => GoogleImageHandlerTests.cs} | 12 +++++------ ...entHandlerTests.cs => HelpHandlerTests.cs} | 16 +++++++------- ...DecrementMessageKarmaEventHandlerTests.cs} | 13 ++++++------ ...IncrementMessageKarmaEventHandlerTests.cs} | 13 ++++++------ .../KarmaHandlerCommandParsingTests.cs | 9 ++++---- ...HandlerTests.cs => ReleaseHandlerTests.cs} | 12 +++++------ ...ntHandlerTests.cs => UrbanHandlerTests.cs} | 12 +++++------ ...ndlerTests.cs => WikipediaHandlerTests.cs} | 12 +++++------ .../Bottomly/Commands/AddMemberCommand.cs | 13 ++++++++++++ bottomly.net/Bottomly/Program.cs | 14 ++++++++----- .../Repositories/IMemberRepository.cs | 1 + .../Bottomly/Repositories/MemberRepository.cs | 1 + .../Bottomly/Slack/MemberlistPopulator.cs | 21 +++++++++++++++++++ .../AbstractMessageEventHandler.cs} | 6 +++--- .../GetCurrentKarmaReasonsHandler.cs} | 8 +++---- .../GetCurrentNetKarmaHandler.cs} | 8 +++---- .../GetLeaderBoardHandler.cs} | 8 +++---- .../GetLoserBoardHandler.cs} | 8 +++---- .../GiphyHandler.cs} | 8 +++---- .../GoogleHandler.cs} | 8 +++---- .../GoogleImageHandler.cs} | 8 +++---- .../HelpHandler.cs} | 10 ++++----- .../IMessageEventHandler.cs} | 4 ++-- .../AbstractMessageKarmaEventHandler.cs} | 6 +++--- .../DecrementMessageKarmaEventHandler.cs} | 8 +++---- .../IncrementMessageKarmaEventHandler.cs} | 8 +++---- .../MemberJoinedMessageEventHandler.cs | 20 ++++++++++++++++++ .../RegHandler.cs} | 8 +++---- .../ReleaseHandler.cs} | 8 +++---- .../TestHandler.cs} | 8 +++---- .../UrbanHandler.cs} | 8 +++---- .../WikipediaHandler.cs} | 8 +++---- bottomly.net/Bottomly/Slack/SlackParser.cs | 7 +++++-- bottomly.net/Bottomly/Slack/SlackWorker.cs | 10 ++++----- 40 files changed, 231 insertions(+), 165 deletions(-) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GetCurrentKarmaReasonsEventHandlerTests.cs => GetCurrentKarmaReasonsHandlerTests.cs} (90%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GetCurrentNetKarmaEventHandlerTests.cs => GetCurrentNetKarmaHandlerTests.cs} (88%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GetLeaderBoardEventHandlerTests.cs => GetLeaderBoardHandlerTests.cs} (90%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GetLoserBoardEventHandlerTests.cs => GetLoserBoardHandlerTests.cs} (90%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GiphyEventHandlerTests.cs => GiphyHandlerTests.cs} (88%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GoogleEventHandlerTests.cs => GoogleHandlerTests.cs} (88%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{GoogleImageEventHandlerTests.cs => GoogleImageHandlerTests.cs} (87%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{HelpEventHandlerTests.cs => HelpHandlerTests.cs} (81%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/{DecrementKarmaEventHandlerTests.cs => DecrementMessageKarmaEventHandlerTests.cs} (83%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/{IncrementKarmaEventHandlerTests.cs => IncrementMessageKarmaEventHandlerTests.cs} (83%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{ReleaseEventHandlerTests.cs => ReleaseHandlerTests.cs} (86%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{UrbanEventHandlerTests.cs => UrbanHandlerTests.cs} (88%) rename bottomly.net/Bottomly.Tests/Slack/EventHandlers/{WikipediaEventHandlerTests.cs => WikipediaHandlerTests.cs} (88%) create mode 100644 bottomly.net/Bottomly/Commands/AddMemberCommand.cs create mode 100644 bottomly.net/Bottomly/Slack/MemberlistPopulator.cs rename bottomly.net/Bottomly/Slack/{EventHandlers/AbstractEventHandler.cs => MessageEventHandlers/AbstractMessageEventHandler.cs} (95%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GetCurrentKarmaReasonsEventHandler.cs => MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs} (90%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GetCurrentNetKarmaEventHandler.cs => MessageEventHandlers/GetCurrentNetKarmaHandler.cs} (88%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GetLeaderBoardEventHandler.cs => MessageEventHandlers/GetLeaderBoardHandler.cs} (85%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GetLoserBoardEventHandler.cs => MessageEventHandlers/GetLoserBoardHandler.cs} (85%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GiphyEventHandler.cs => MessageEventHandlers/GiphyHandler.cs} (82%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GoogleEventHandler.cs => MessageEventHandlers/GoogleHandler.cs} (83%) rename bottomly.net/Bottomly/Slack/{EventHandlers/GoogleImageEventHandler.cs => MessageEventHandlers/GoogleImageHandler.cs} (83%) rename bottomly.net/Bottomly/Slack/{EventHandlers/HelpEventHandler.cs => MessageEventHandlers/HelpHandler.cs} (84%) rename bottomly.net/Bottomly/Slack/{EventHandlers/IEventHandler.cs => MessageEventHandlers/IMessageEventHandler.cs} (63%) rename bottomly.net/Bottomly/Slack/{EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs => MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs} (93%) rename bottomly.net/Bottomly/Slack/{EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs => MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs} (65%) rename bottomly.net/Bottomly/Slack/{EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs => MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs} (65%) create mode 100644 bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs rename bottomly.net/Bottomly/Slack/{EventHandlers/RegEventHandler.cs => MessageEventHandlers/RegHandler.cs} (82%) rename bottomly.net/Bottomly/Slack/{EventHandlers/ReleaseEventHandler.cs => MessageEventHandlers/ReleaseHandler.cs} (81%) rename bottomly.net/Bottomly/Slack/{EventHandlers/TestEventHandler.cs => MessageEventHandlers/TestHandler.cs} (76%) rename bottomly.net/Bottomly/Slack/{EventHandlers/UrbanEventHandler.cs => MessageEventHandlers/UrbanHandler.cs} (83%) rename bottomly.net/Bottomly/Slack/{EventHandlers/WikipediaEventHandler.cs => MessageEventHandlers/WikipediaHandler.cs} (83%) diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs similarity index 90% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs index b6d71e0..d744e76 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs @@ -2,7 +2,7 @@ using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -11,20 +11,20 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GetCurrentKarmaReasonsEventHandlerTests +public class GetCurrentKarmaReasonsHandlerTests { - private readonly GetCurrentKarmaReasonsEventHandler _handler; + private readonly GetCurrentKarmaReasonsHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); private readonly Mock _mockMemberRepo = new(); - public GetCurrentKarmaReasonsEventHandlerTests() + public GetCurrentKarmaReasonsHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new GetCurrentKarmaReasonsCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentKarmaReasonsEventHandler(command, parser, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GetCurrentKarmaReasonsHandler(command, parser, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U_sender") => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs similarity index 88% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs index d61c11a..9387c5c 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs @@ -1,7 +1,7 @@ using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -10,20 +10,20 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GetCurrentNetKarmaEventHandlerTests +public class GetCurrentNetKarmaHandlerTests { - private readonly GetCurrentNetKarmaEventHandler _handler; + private readonly GetCurrentNetKarmaHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); private readonly Mock _mockMemberRepo = new(); - public GetCurrentNetKarmaEventHandlerTests() + public GetCurrentNetKarmaHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new GetCurrentNetKarmaCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentNetKarmaEventHandler(command, parser, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GetCurrentNetKarmaHandler(command, parser, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U_sender") => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs similarity index 90% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs index a5dffda..51f19a4 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs @@ -1,7 +1,7 @@ using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -10,18 +10,18 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GetLeaderBoardEventHandlerTests +public class GetLeaderBoardHandlerTests { - private readonly GetLeaderBoardEventHandler _handler; + private readonly GetLeaderBoardHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); - public GetLeaderBoardEventHandlerTests() + public GetLeaderBoardHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new GetLeaderBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLeaderBoardEventHandler(command, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GetLeaderBoardHandler(command, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs similarity index 90% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs index 2d1cd51..24a1066 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs @@ -1,7 +1,7 @@ using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -10,18 +10,18 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GetLoserBoardEventHandlerTests +public class GetLoserBoardHandlerTests { - private readonly GetLoserBoardEventHandler _handler; + private readonly GetLoserBoardHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); - public GetLoserBoardEventHandlerTests() + public GetLoserBoardHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new GetLoserBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLoserBoardEventHandler(command, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GetLoserBoardHandler(command, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs similarity index 88% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs index 8e0694e..922eb83 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs @@ -1,6 +1,6 @@ using Bottomly.Commands; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -9,19 +9,19 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GiphyEventHandlerTests +public class GiphyHandlerTests { - private readonly GiphyEventHandler _handler; + private readonly GiphyHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public GiphyEventHandlerTests() + public GiphyHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); _mockCommand = new Mock(mockFactory.Object, options); - _handler = new GiphyEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GiphyHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs similarity index 88% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs index 9b329d4..c6b6741 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs @@ -1,6 +1,6 @@ using Bottomly.Commands; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -9,18 +9,18 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GoogleEventHandlerTests +public class GoogleHandlerTests { - private readonly GoogleEventHandler _handler; + private readonly GoogleHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public GoogleEventHandlerTests() + public GoogleHandlerTests() { var options = TestHelpers.CreateOptions(); _mockCommand = new Mock(options); - _handler = new GoogleEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs similarity index 87% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs index 3bbd8d4..0727841 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -1,6 +1,6 @@ using Bottomly.Commands; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -9,18 +9,18 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GoogleImageEventHandlerTests +public class GoogleImageHandlerTests { - private readonly GoogleImageEventHandler _handler; + private readonly GoogleImageHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public GoogleImageEventHandlerTests() + public GoogleImageHandlerTests() { var options = TestHelpers.CreateOptions(); _mockCommand = new Mock(options); - _handler = new GoogleImageEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new GoogleImageHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs similarity index 81% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs index 3be0f07..6ffb017 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs @@ -1,5 +1,5 @@ using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -8,18 +8,18 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class HelpEventHandlerTests +public class HelpHandlerTests { private readonly Mock _mockBroker = new(); - private HelpEventHandler CreateHandler(IEnumerable? handlers = null) + private HelpHandler CreateHandler(IEnumerable? handlers = null) { var options = TestHelpers.CreateOptions(); - return new HelpEventHandler( - handlers ?? Enumerable.Empty(), + return new HelpHandler( + handlers ?? Enumerable.Empty(), _mockBroker.Object, options, - NullLogger.Instance); + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U1") => @@ -45,9 +45,9 @@ public void CanHandle_InvalidEvent_ReturnsFalse() [Fact] public async Task HandleAsync_ValidEvent_SendsDmWithAllHandlerHelpMessages() { - var mockH1 = new Mock(); + var mockH1 = new Mock(); mockH1.Setup(h => h.BuildHelpMessage()).Returns("Handler1 Help"); - var mockH2 = new Mock(); + var mockH2 = new Mock(); mockH2.Setup(h => h.BuildHelpMessage()).Returns("Handler2 Help"); var handler = CreateHandler(new[] { mockH1.Object, mockH2.Object }); diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs similarity index 83% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs index e76389a..439869b 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs @@ -2,7 +2,7 @@ using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.KarmaEventHandlers; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -11,20 +11,21 @@ namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; -public class DecrementKarmaEventHandlerTests +public class DecrementMessageKarmaEventHandlerTests { - private readonly DecrementKarmaEventHandler _handler; + private readonly DecrementMessageKarmaEventHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); private readonly Mock _mockMemberRepo = new(); - public DecrementKarmaEventHandlerTests() + public DecrementMessageKarmaEventHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new AddKarmaCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new DecrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new DecrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, + options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U_sender") => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs similarity index 83% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs index 7d16217..7e99892 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs @@ -2,7 +2,7 @@ using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.KarmaEventHandlers; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -11,20 +11,21 @@ namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; -public class IncrementKarmaEventHandlerTests +public class IncrementMessageKarmaEventHandlerTests { - private readonly IncrementKarmaEventHandler _handler; + private readonly IncrementMessageKarmaEventHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); private readonly Mock _mockMemberRepo = new(); - public IncrementKarmaEventHandlerTests() + public IncrementMessageKarmaEventHandlerTests() { var options = TestHelpers.CreateOptions(); var command = new AddKarmaCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new IncrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, + options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U_sender") => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs index b86cae7..120ca15 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs @@ -2,7 +2,7 @@ using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.KarmaEventHandlers; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -12,7 +12,7 @@ namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; public class KarmaHandlerCommandParsingTests { - private readonly IncrementKarmaEventHandler _handler; + private readonly IncrementMessageKarmaEventHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockKarmaRepo = new(); private readonly Mock _mockMemberRepo = new(); @@ -22,8 +22,9 @@ public KarmaHandlerCommandParsingTests() var options = TestHelpers.CreateOptions(); var command = new AddKarmaCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new IncrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, + options, + NullLogger.Instance); _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs similarity index 86% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs index a5580b4..e63c524 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs @@ -1,7 +1,7 @@ using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -11,19 +11,19 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class ReleaseEventHandlerTests +public class ReleaseHandlerTests { - private readonly ReleaseEventHandler _handler; + private readonly ReleaseHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public ReleaseEventHandlerTests() + public ReleaseHandlerTests() { var options = TestHelpers.CreateOptions(); var releaseOptions = Options.Create(new BottomlyOptions { GitHubToken = "token" }); _mockCommand = new Mock(releaseOptions, NullLogger.Instance); - _handler = new ReleaseEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new ReleaseHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs similarity index 88% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs index 2bad731..d9987ba 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs @@ -1,6 +1,6 @@ using Bottomly.Commands; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -9,19 +9,19 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class UrbanEventHandlerTests +public class UrbanHandlerTests { - private readonly UrbanEventHandler _handler; + private readonly UrbanHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public UrbanEventHandlerTests() + public UrbanHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); _mockCommand = new Mock(mockFactory.Object); - _handler = new UrbanEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new UrbanHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs similarity index 88% rename from bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs rename to bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs index 65c0212..f9e0257 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs @@ -1,6 +1,6 @@ using Bottomly.Commands; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -9,19 +9,19 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class WikipediaEventHandlerTests +public class WikipediaHandlerTests { - private readonly WikipediaEventHandler _handler; + private readonly WikipediaHandler _handler; private readonly Mock _mockBroker = new(); private readonly Mock _mockCommand; - public WikipediaEventHandlerTests() + public WikipediaHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); _mockCommand = new Mock(mockFactory.Object); - _handler = new WikipediaEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _handler = new WikipediaHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text) => diff --git a/bottomly.net/Bottomly/Commands/AddMemberCommand.cs b/bottomly.net/Bottomly/Commands/AddMemberCommand.cs new file mode 100644 index 0000000..e0846db --- /dev/null +++ b/bottomly.net/Bottomly/Commands/AddMemberCommand.cs @@ -0,0 +1,13 @@ +using Bottomly.Models; +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class AddMemberCommand(IMemberRepository repository) : ICommand +{ + private readonly IMemberRepository _repository = repository; + + public string GetPurpose() => "Persists a new member for karma tracking"; + + public async Task ExecuteAsync(Member member) => await _repository.AddAsync(member); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 022140e..1ee0a5c 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -4,6 +4,7 @@ using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -70,7 +71,7 @@ builder.RegisterEventHandlers(Assembly.GetExecutingAssembly()); // Help handler (also registered as singleton for direct injection into SlackWorker) -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Reaction handlers builder.Services.AddSingleton(); @@ -81,7 +82,10 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -builder.Build().Run(); +var app = builder.Build(); + + +app.Run(); public static class HostBuilderExtensions { @@ -89,10 +93,10 @@ public static class HostBuilderExtensions { public void RegisterEventHandlers(Assembly assembly) => assembly.GetTypes() - .Where(t => typeof(IEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) - .Where(t => t.Name != nameof(HelpEventHandler)) + .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) + .Where(t => t.Name != nameof(HelpHandler)) .ToList() - .ForEach(t => builder.Services.AddSingleton(typeof(IEventHandler), t)); + .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); public void RegisterCommands(Assembly assembly) => assembly.GetTypes() diff --git a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs index 5a00b0e..f510818 100644 --- a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs @@ -7,4 +7,5 @@ public interface IMemberRepository Task GetByUsernameAsync(string username); Task GetBySlackIdAsync(string slackId); Task AddAsync(Member member); + Task AddAsync(IEnumerable members); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/MemberRepository.cs b/bottomly.net/Bottomly/Repositories/MemberRepository.cs index 4d157c9..a535d32 100644 --- a/bottomly.net/Bottomly/Repositories/MemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/MemberRepository.cs @@ -20,4 +20,5 @@ public class MemberRepository(IMongoDatabase database) : IMemberRepository } public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); + public async Task AddAsync(IEnumerable members) => await _collection.InsertManyAsync(members); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs new file mode 100644 index 0000000..484478b --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs @@ -0,0 +1,21 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using SlackNet; + +namespace Bottomly.Slack; + +public class MemberlistPopulator(ISlackApiClient slack, IMemberRepository memberRepository) +{ + public async Task> PopulateMembers() + { + var users = await slack.Users.List(); + + var members = users.Members + .Where(u => !u.Deleted) + .Select(u => new Member { SlackId = u.Id, Username = u.Name }).ToList(); + + await memberRepository.AddAsync(members); + + return members; + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs similarity index 95% rename from bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index a47b548..3a276d7 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -4,13 +4,13 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public abstract class AbstractEventHandler( +public abstract class AbstractMessageEventHandler( ISlackMessageBroker broker, IOptions options, ILogger logger) - : IEventHandler + : IMessageEventHandler { protected readonly ISlackMessageBroker Broker = broker; protected readonly ILogger Logger = logger; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs similarity index 90% rename from bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs index 1ee4b0c..cdc80a1 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs @@ -7,15 +7,15 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GetCurrentKarmaReasonsEventHandler( +public class GetCurrentKarmaReasonsHandler( GetCurrentKarmaReasonsCommand command, SlackParser parser, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Karma Reasons"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs similarity index 88% rename from bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs index 5fcbe55..6359f8c 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs @@ -6,15 +6,15 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GetCurrentNetKarmaEventHandler( +public class GetCurrentNetKarmaHandler( GetCurrentNetKarmaCommand command, SlackParser parser, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Current Karma"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs similarity index 85% rename from bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs index 4d9fb49..99c9a99 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs @@ -5,14 +5,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GetLeaderBoardEventHandler( +public class GetLeaderBoardHandler( GetLeaderBoardCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Leaderboard"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs similarity index 85% rename from bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs index 06338ef..5b2a2d1 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs @@ -5,14 +5,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GetLoserBoardEventHandler( +public class GetLoserBoardHandler( GetLoserBoardCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Loserboard"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs similarity index 82% rename from bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs index cbd4fe6..391cf72 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GiphyEventHandler( +public class GiphyHandler( GiphyCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Giphy"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs similarity index 83% rename from bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs index a01d384..67f9a34 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GoogleEventHandler( +public class GoogleHandler( GoogleSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Google"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs similarity index 83% rename from bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs index cc4c9a5..435e012 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class GoogleImageEventHandler( +public class GoogleImageHandler( GoogleImageSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Google Image"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs similarity index 84% rename from bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs index 59a021a..9ffed35 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs @@ -5,14 +5,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class HelpEventHandler( - IEnumerable handlers, +public class HelpHandler( + IEnumerable handlers, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { private static readonly string[] HelpSymbols = ["help", "?", "list"]; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs similarity index 63% rename from bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs index 6c257a5..0c61c00 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs @@ -1,8 +1,8 @@ using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public interface IEventHandler +public interface IMessageEventHandler { bool CanHandle(MessageEvent message); Task HandleAsync(MessageEvent message); diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs similarity index 93% rename from bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs index 8fc4a2a..c19942f 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs @@ -6,16 +6,16 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers.KarmaEventHandlers; +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; -public abstract class AbstractKarmaEventHandler( +public abstract class AbstractMessageKarmaEventHandler( AddKarmaCommand command, SlackParser parser, IMemberRepository memberRepository, ISlackMessageBroker broker, IOptions options, ILogger logger) - : AbstractEventHandler(broker, options, logger) + : AbstractMessageEventHandler(broker, options, logger) { private const string ForString = " for "; protected readonly AddKarmaCommand KarmaCommand = command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs similarity index 65% rename from bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs index 41b544a..d8c781f 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs @@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Slack.EventHandlers.KarmaEventHandlers; +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; -public class DecrementKarmaEventHandler( +public class DecrementMessageKarmaEventHandler( AddKarmaCommand command, SlackParser parser, IMemberRepository memberRepository, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractKarmaEventHandler(command, parser, memberRepository, broker, options, logger) + ILogger logger) + : AbstractMessageKarmaEventHandler(command, parser, memberRepository, broker, options, logger) { public override string Name => "Neggy-neg"; protected override string CommandSymbol => "--"; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs similarity index 65% rename from bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs index c2ff576..397f163 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs @@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Slack.EventHandlers.KarmaEventHandlers; +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; -public class IncrementKarmaEventHandler( +public class IncrementMessageKarmaEventHandler( AddKarmaCommand command, SlackParser parser, IMemberRepository memberRepository, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractKarmaEventHandler(command, parser, memberRepository, broker, options, logger) + ILogger logger) + : AbstractMessageKarmaEventHandler(command, parser, memberRepository, broker, options, logger) { public override string Name => "Pozzy-poz"; protected override string CommandSymbol => "++"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs new file mode 100644 index 0000000..324de29 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs @@ -0,0 +1,20 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class MemberJoinedMessageEventHandler( + AddMemberCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Add Member"; + public override ICommand? Command => command; + protected override string CommandSymbol => string.Empty; + protected override Task InvokeHandlerLogicAsync(MessageEvent message) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs similarity index 82% rename from bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs index 9644e20..5116f79 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class RegEventHandler( +public class RegHandler( RegSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Reg Lookup"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs similarity index 81% rename from bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs index 1520287..42d5c85 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class ReleaseEventHandler( +public class ReleaseHandler( ReleaseCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Release"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs similarity index 76% rename from bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs index c915b46..312be40 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs @@ -4,13 +4,13 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class TestEventHandler( +public class TestHandler( ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Test"; public override ICommand? Command => ICommand.None; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs similarity index 83% rename from bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs index b452792..4b58a07 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class UrbanEventHandler( +public class UrbanHandler( UrbanSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Urban Dictionary"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs similarity index 83% rename from bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs index b76177d..1a05495 100644 --- a/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs @@ -4,14 +4,14 @@ using Microsoft.Extensions.Options; using SlackNet.Events; -namespace Bottomly.Slack.EventHandlers; +namespace Bottomly.Slack.MessageEventHandlers; -public class WikipediaEventHandler( +public class WikipediaHandler( WikipediaSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Wikipedia"; public override ICommand Command => command; diff --git a/bottomly.net/Bottomly/Slack/SlackParser.cs b/bottomly.net/Bottomly/Slack/SlackParser.cs index 9a8a0de..a37d3fe 100644 --- a/bottomly.net/Bottomly/Slack/SlackParser.cs +++ b/bottomly.net/Bottomly/Slack/SlackParser.cs @@ -3,9 +3,9 @@ namespace Bottomly.Slack; -public class SlackParser(IMemberRepository memberRepository) +public partial class SlackParser(IMemberRepository memberRepository) { - private static readonly Regex SlackIdPattern = new(@"<@([A-Z0-9]+)>", RegexOptions.Compiled); + private static readonly Regex SlackIdPattern = SlackIdRegex(); public async Task ReplaceSlackIdTokensWithUsernamesAsync(string message) { @@ -27,4 +27,7 @@ public async Task ReplaceSlackIdTokensWithUsernamesAsync(string message) return message; } + + [GeneratedRegex(@"<@([A-Z0-9]+)>", RegexOptions.Compiled)] + private static partial Regex SlackIdRegex(); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs index 7933033..34205e7 100644 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -1,19 +1,19 @@ using Bottomly.Repositories; using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SlackNet; using SlackNet.Events; using SlackNet.SocketMode; -using IEventHandler = Bottomly.Slack.EventHandlers.IEventHandler; namespace Bottomly.Slack; public class SlackWorker( ISlackSocketModeClient socketClient, - IEnumerable eventHandlers, - HelpEventHandler helpHandler, + IEnumerable eventHandlers, + HelpHandler helpMessageHandler, IEnumerable reactionHandlers, IMemberRepository memberRepository, ILogger logger) @@ -43,9 +43,9 @@ public async Task ProcessMessageAsync(MessageEvent message) await ResolveUsernameAsync(message); // Help handler takes priority - if (helpHandler.CanHandle(message)) + if (helpMessageHandler.CanHandle(message)) { - await helpHandler.HandleAsync(message); + await helpMessageHandler.HandleAsync(message); return; } From a71eeb8d0e6c90510a1ff26d0fe98c2e874fdecf Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 10:26:55 +0000 Subject: [PATCH 05/24] refactors member joined handler --- .../Bottomly/Commands/AddMemberCommand.cs | 13 -------- bottomly.net/Bottomly/Program.cs | 9 +++-- .../MemberJoinedEventHandler.cs | 33 +++++++++++++++++++ .../MemberJoinedMessageEventHandler.cs | 20 ----------- .../Bottomly/Slack/SlackEventDispatchers.cs | 7 ++++ bottomly.net/Bottomly/Slack/SlackWorker.cs | 1 - 6 files changed, 47 insertions(+), 36 deletions(-) delete mode 100644 bottomly.net/Bottomly/Commands/AddMemberCommand.cs create mode 100644 bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs diff --git a/bottomly.net/Bottomly/Commands/AddMemberCommand.cs b/bottomly.net/Bottomly/Commands/AddMemberCommand.cs deleted file mode 100644 index e0846db..0000000 --- a/bottomly.net/Bottomly/Commands/AddMemberCommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Bottomly.Models; -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class AddMemberCommand(IMemberRepository repository) : ICommand -{ - private readonly IMemberRepository _repository = repository; - - public string GetPurpose() => "Persists a new member for karma tracking"; - - public async Task ExecuteAsync(Member member) => await _repository.AddAsync(member); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 1ee0a5c..0c718f0 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -3,7 +3,7 @@ using Bottomly.Configuration; using Bottomly.Repositories; using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.MembershipEventHandlers; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Configuration; @@ -64,6 +64,7 @@ builder.Services.AddSlackNet(c => c .UseApiToken(slackBotToken) .UseAppLevelToken(slackAppToken) + .RegisterEventHandler() .RegisterEventHandler() .RegisterEventHandler()); @@ -76,6 +77,9 @@ // Reaction handlers builder.Services.AddSingleton(); +// Membership handlers +builder.Services.AddSingleton(); + // Slack dispatchers and worker builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -93,7 +97,8 @@ public static class HostBuilderExtensions { public void RegisterEventHandlers(Assembly assembly) => assembly.GetTypes() - .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) + .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && + t is { IsInterface: false, IsAbstract: false }) .Where(t => t.Name != nameof(HelpHandler)) .ToList() .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); diff --git a/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs b/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs new file mode 100644 index 0000000..7c3a474 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs @@ -0,0 +1,33 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Slack.MembershipEventHandlers; + +public class MemberJoinedEventHandler( + IMemberRepository repository, + ISlackApiClient slackClient, + ILogger logger) +{ + public async Task ExecuteAsync(MemberJoinedChannel joinedEvent) + { + if (joinedEvent.Channel != "#general") + { + return; + } + + var memberInfo = await slackClient.Users.Info(joinedEvent.User); + if (memberInfo == null) + { + return; + } + + var member = new Member { SlackId = memberInfo.Id, Username = memberInfo.Name }; + + await repository.AddAsync(member); + + logger.LogInformation("Added new member {Username} to the database", member.Username); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs deleted file mode 100644 index 324de29..0000000 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/MemberJoinedMessageEventHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.MessageEventHandlers; - -public class MemberJoinedMessageEventHandler( - AddMemberCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractMessageEventHandler(broker, options, logger) -{ - public override string Name => "Add Member"; - public override ICommand? Command => command; - protected override string CommandSymbol => string.Empty; - protected override Task InvokeHandlerLogicAsync(MessageEvent message) => throw new NotImplementedException(); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs index 1af4b41..49dd968 100644 --- a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs +++ b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs @@ -1,3 +1,4 @@ +using Bottomly.Slack.MembershipEventHandlers; using SlackNet; using SlackNet.Events; @@ -17,4 +18,10 @@ public class SlackMessageEventDispatcher(SlackWorker worker) : IEventHandler { public Task Handle(ReactionAdded slackEvent) => worker.ProcessReactionAsync(slackEvent); +} + +public class SlackMemberAddedEventDispatcher(MemberJoinedEventHandler handler) + : IEventHandler +{ + public async Task Handle(MemberJoinedChannel slackEvent) => await handler.ExecuteAsync(slackEvent); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs index 34205e7..e911530 100644 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -1,5 +1,4 @@ using Bottomly.Repositories; -using Bottomly.Slack.EventHandlers; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Hosting; From a95e38f89803ed874543f0b8f935341a8bb965db Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 10:44:15 +0000 Subject: [PATCH 06/24] Minor refactor of karma repository --- bottomly.net/Bottomly/Program.cs | 1 + .../Bottomly/Repositories/KarmaRepository.cs | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 0c718f0..f84f972 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -84,6 +84,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); var app = builder.Build(); diff --git a/bottomly.net/Bottomly/Repositories/KarmaRepository.cs b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs index df8f7fd..033bbc9 100644 --- a/bottomly.net/Bottomly/Repositories/KarmaRepository.cs +++ b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs @@ -8,17 +8,20 @@ public class KarmaRepository(IMongoDatabase database) : IKarmaRepository { private readonly IMongoCollection _collection = database.GetCollection("karma"); + private static DateTime CutOffDate => DateTime.UtcNow.AddDays(-Karma.ExpiryDays); + public async Task AddAsync(Karma karma) => await _collection.InsertOneAsync(karma); public async Task GetCurrentNetKarmaAsync(string recipient) { var scores = await GetNetKarmaAggregateAsync(recipient.ToLower(), 1); - return scores.FirstOrDefault()?.NetKarma ?? 0; + return scores.Count != 0 + ? scores[0].NetKarma + : 0; } public async Task GetKarmaReasonsAsync(string recipient) { - var cutOff = CutOffDate(); var lower = recipient.ToLower(); var pipeline = new[] @@ -34,7 +37,7 @@ public async Task GetKarmaReasonsAsync(string recipient) new BsonDocument("$match", new BsonDocument { { "awarded_to_username", lower }, - { "awarded", new BsonDocument("$gt", cutOff) } + { "awarded", new BsonDocument("$gt", CutOffDate) } }) }; @@ -56,18 +59,15 @@ public async Task GetKarmaReasonsAsync(string recipient) } public async Task> GetLeaderBoardAsync(int size = 3) => - await GetNetKarmaAggregateAsync(limit: size, ascending: false); + await GetNetKarmaAggregateAsync(limit: size, sortOrder: SortOrder.Descending); public async Task> GetLoserBoardAsync(int size = 3) => - await GetNetKarmaAggregateAsync(limit: size, ascending: true); + await GetNetKarmaAggregateAsync(limit: size, sortOrder: SortOrder.Ascending); private async Task> GetNetKarmaAggregateAsync( - string? recipient = null, int limit = 3, bool ascending = false) + string? recipient = null, int limit = 3, SortOrder sortOrder = SortOrder.Descending) { - var cutOff = CutOffDate(); - var sortDirection = ascending ? 1 : -1; - - var matchFilter = new BsonDocument("awarded", new BsonDocument("$gt", cutOff)); + var matchFilter = new BsonDocument("awarded", new BsonDocument("$gt", CutOffDate)); if (recipient != null) { matchFilter["recipient"] = recipient; @@ -93,7 +93,7 @@ private async Task> GetNetKarmaAggregateAsync( { "_id", "$recipient" }, { "net_karma", new BsonDocument("$sum", "$net_karma") } }), - new("$sort", new BsonDocument("net_karma", sortDirection)) + new("$sort", new BsonDocument("net_karma", (int)sortOrder)) }; var results = await _collection.Aggregate(pipeline).ToListAsync(); @@ -103,5 +103,9 @@ private async Task> GetNetKarmaAggregateAsync( .ToList(); } - private static DateTime CutOffDate() => DateTime.UtcNow.AddDays(-Karma.ExpiryDays); + private enum SortOrder + { + Ascending = 1, + Descending = -1 + } } \ No newline at end of file From dd33acf7941ed8bc69025180bf5050744794a88d Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 10:59:02 +0000 Subject: [PATCH 07/24] Better handling of command objects and removal of the first unnecessary one --- .../GetCurrentNetKarmaCommandTests.cs | 22 ------------------- .../GetCurrentNetKarmaHandlerTests.cs | 4 +--- .../Commands/GetCurrentNetKarmaCommand.cs | 11 ---------- .../AbstractMessageEventHandler.cs | 9 ++++++-- .../GetCurrentKarmaReasonsHandler.cs | 2 +- .../GetCurrentNetKarmaHandler.cs | 9 +++++--- .../GetLeaderBoardHandler.cs | 2 +- .../GetLoserBoardHandler.cs | 2 +- .../MessageEventHandlers/GiphyHandler.cs | 2 +- .../MessageEventHandlers/GoogleHandler.cs | 2 +- .../GoogleImageHandler.cs | 2 +- .../Slack/MessageEventHandlers/HelpHandler.cs | 2 -- .../AbstractMessageKarmaEventHandler.cs | 2 +- .../Slack/MessageEventHandlers/RegHandler.cs | 2 +- .../MessageEventHandlers/ReleaseHandler.cs | 2 +- .../Slack/MessageEventHandlers/TestHandler.cs | 1 - .../MessageEventHandlers/UrbanHandler.cs | 2 +- .../MessageEventHandlers/WikipediaHandler.cs | 2 +- 18 files changed, 25 insertions(+), 55 deletions(-) delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs deleted file mode 100644 index 61698cd..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetCurrentNetKarmaCommandTests -{ - [Fact] - public async Task ExecuteAsync_DelegatesToRepository() - { - var mockRepo = new Mock(); - mockRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(5); - var command = new GetCurrentNetKarmaCommand(mockRepo.Object); - - var result = await command.ExecuteAsync("alice"); - - result.ShouldBe(5); - mockRepo.Verify(r => r.GetCurrentNetKarmaAsync("alice"), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs index 9387c5c..2f4d6bf 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs @@ -1,4 +1,3 @@ -using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; @@ -20,9 +19,8 @@ public class GetCurrentNetKarmaHandlerTests public GetCurrentNetKarmaHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new GetCurrentNetKarmaCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentNetKarmaHandler(command, parser, _mockBroker.Object, options, + _handler = new GetCurrentNetKarmaHandler(_mockKarmaRepo.Object, parser, _mockBroker.Object, options, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs deleted file mode 100644 index faf5ac8..0000000 --- a/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetCurrentNetKarmaCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Returns someone's/something's current score of imaginary internet points"; - - public Task ExecuteAsync(string recipient) => - karmaRepository.GetCurrentNetKarmaAsync(recipient); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index 3a276d7..6a67cbe 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -17,7 +17,7 @@ public abstract class AbstractMessageEventHandler( protected readonly string Prefix = options.Value.Prefix; public abstract string Name { get; } - public abstract ICommand? Command { get; } + protected virtual ICommand Command => ICommand.None; protected abstract string CommandSymbol { get; } protected string CommandTrigger => Prefix + CommandSymbol + " "; @@ -46,11 +46,16 @@ public async Task HandleAsync(MessageEvent message) public string BuildHelpMessage() { var name = Name + Environment.NewLine; - var purpose = Command is not null ? Command.GetPurpose() + Environment.NewLine : string.Empty; + var purpose = GetPurpose(); var usage = $"Usage: `{GetUsage()}`"; return name + purpose + usage + GetUsageAddendum(); } + protected virtual string GetPurpose() => + Command is not VoidCommand + ? Command.GetPurpose() + Environment.NewLine + : string.Empty; + protected abstract Task InvokeHandlerLogicAsync(MessageEvent message); protected virtual string GetUsage() => CommandTrigger.TrimEnd(); public virtual string GetUsageAddendum() => string.Empty; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs index cdc80a1..6327987 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs @@ -18,7 +18,7 @@ public class GetCurrentKarmaReasonsHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Karma Reasons"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "reasons"; protected override string GetUsage() => CommandTrigger + "[recipient ]"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs index 6359f8c..f35d5d7 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs @@ -2,6 +2,7 @@ using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.Models; +using Bottomly.Repositories; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; @@ -9,7 +10,7 @@ namespace Bottomly.Slack.MessageEventHandlers; public class GetCurrentNetKarmaHandler( - GetCurrentNetKarmaCommand command, + IKarmaRepository repository, SlackParser parser, ISlackMessageBroker broker, IOptions options, @@ -17,10 +18,12 @@ public class GetCurrentNetKarmaHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Current Karma"; - public override ICommand Command => command; protected override string CommandSymbol => "karma"; protected override string GetUsage() => CommandTrigger + "[recipient ]"; + protected override string GetPurpose() => + $"Returns someone's/something's current score of imaginary internet points.{Environment.NewLine}"; + public override string GetUsageAddendum() { var reactions = AddKarmaCommand.GetKarmaReactions(); @@ -42,7 +45,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) recipient = message.User; } - var result = await command.ExecuteAsync(recipient); + var result = await repository.GetCurrentNetKarmaAsync(recipient); await SendMessageResponseAsync($"{recipient}: {result}", message); } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs index 99c9a99..8489082 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs @@ -15,7 +15,7 @@ public class GetLeaderBoardHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Leaderboard"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "leaderboard"; protected override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs index 5b2a2d1..e55d1ea 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs @@ -15,7 +15,7 @@ public class GetLoserBoardHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Loserboard"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "loserboard"; protected override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs index 391cf72..ba3825b 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs @@ -14,7 +14,7 @@ public class GiphyHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Giphy"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "gif"; protected override string GetUsage() => CommandTrigger + ""; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs index 67f9a34..a9224ea 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs @@ -14,7 +14,7 @@ public class GoogleHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Google"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "g"; protected override string GetUsage() => CommandTrigger + ""; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs index 435e012..e7610dc 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs @@ -14,7 +14,7 @@ public class GoogleImageHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Google Image"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "gi"; protected override string GetUsage() => CommandTrigger + ""; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs index 9ffed35..8887f35 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs @@ -1,5 +1,4 @@ using System.Text; -using Bottomly.Commands; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -17,7 +16,6 @@ public class HelpHandler( private static readonly string[] HelpSymbols = ["help", "?", "list"]; public override string Name => "Help"; - public override ICommand? Command => null; protected override string CommandSymbol => HelpSymbols[0]; protected override string GetUsage() diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs index c19942f..c3eb5b7 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs @@ -20,7 +20,7 @@ public abstract class AbstractMessageKarmaEventHandler( private const string ForString = " for "; protected readonly AddKarmaCommand KarmaCommand = command; - public override ICommand Command => KarmaCommand; + protected override ICommand Command => KarmaCommand; public abstract override string Name { get; } protected abstract KarmaType KarmaTypeValue { get; } diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs index 5116f79..f1069ec 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs @@ -14,7 +14,7 @@ public class RegHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Reg Lookup"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "reg"; protected override string GetUsage() => CommandTrigger + ""; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs index 42d5c85..ec8cc69 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs @@ -14,7 +14,7 @@ public class ReleaseHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Release"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "release"; protected override string GetUsage() => CommandTrigger.TrimEnd(); diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs index 312be40..90fd473 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs @@ -13,7 +13,6 @@ public class TestHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Test"; - public override ICommand? Command => ICommand.None; protected override string CommandSymbol => "test"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) => diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs index 4b58a07..7745bfb 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs @@ -14,7 +14,7 @@ public class UrbanHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Urban Dictionary"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "ud"; protected override string GetUsage() => CommandTrigger + ""; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs index 1a05495..db76922 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs @@ -14,7 +14,7 @@ public class WikipediaHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Wikipedia"; - public override ICommand Command => command; + protected override ICommand Command => command; protected override string CommandSymbol => "wik"; protected override string GetUsage() => CommandTrigger + ""; From 241c1e3bc8813535aabfb46fbd7cfabcdf148e8b Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 12:41:42 +0000 Subject: [PATCH 08/24] Removes commands that had been superceded by repositories Also moved Karma reaction dict into its own class which is injectable. --- .../GetCurrentKarmaReasonsCommandTests.cs | 24 ---------- .../Commands/GetLeaderBoardCommandTests.cs | 38 --------------- .../Commands/GetLoserBoardCommandTests.cs | 38 --------------- .../GetCurrentKarmaReasonsHandlerTests.cs | 4 +- .../GetCurrentNetKarmaHandlerTests.cs | 6 ++- .../GetLeaderBoardHandlerTests.cs | 4 +- .../GetLoserBoardHandlerTests.cs | 4 +- .../AddKarmaReactionHandlerTests.cs | 6 ++- .../Bottomly/Commands/AddKarmaCommand.cs | 24 ---------- .../Commands/GetCurrentKarmaReasonsCommand.cs | 12 ----- .../Commands/GetLeaderBoardCommand.cs | 11 ----- .../Bottomly/Commands/GetLoserBoardCommand.cs | 11 ----- bottomly.net/Bottomly/Program.cs | 1 + .../Bottomly/Slack/ISlackMessageBroker.cs | 2 +- .../GetCurrentKarmaReasonsHandler.cs | 10 ++-- .../GetCurrentNetKarmaHandler.cs | 19 ++------ .../GetLeaderBoardHandler.cs | 8 ++-- .../GetLoserBoardHandler.cs | 8 ++-- .../AddKarmaReactionHandler.cs | 6 +-- .../ReactionHandlers/KarmaReactionMap.cs | 47 +++++++++++++++++++ .../Bottomly/Slack/SlackMessageBroker.cs | 9 ++-- 21 files changed, 89 insertions(+), 203 deletions(-) delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs create mode 100644 bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs deleted file mode 100644 index fafdc0f..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetCurrentKarmaReasonsCommandTests -{ - [Fact] - public async Task ExecuteAsync_DelegatesToRepository() - { - var mockRepo = new Mock(); - var expected = new KarmaReasonsResult(2, new List().AsReadOnly()); - mockRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(expected); - var command = new GetCurrentKarmaReasonsCommand(mockRepo.Object); - - var result = await command.ExecuteAsync("alice"); - - result.ShouldBe(expected); - mockRepo.Verify(r => r.GetKarmaReasonsAsync("alice"), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs deleted file mode 100644 index 8fd2711..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetLeaderBoardCommandTests -{ - private readonly GetLeaderBoardCommand _command; - private readonly Mock _mockRepo = new(); - - public GetLeaderBoardCommandTests() => _command = new GetLeaderBoardCommand(_mockRepo.Object); - - [Fact] - public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() - { - var scores = new List { new("alice", 5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); - } - - [Fact] - public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() - { - var scores = new List { new("alice", 5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLeaderBoardAsync(10)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(10); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLeaderBoardAsync(10), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs deleted file mode 100644 index 23ea503..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetLoserBoardCommandTests -{ - private readonly GetLoserBoardCommand _command; - private readonly Mock _mockRepo = new(); - - public GetLoserBoardCommandTests() => _command = new GetLoserBoardCommand(_mockRepo.Object); - - [Fact] - public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() - { - var scores = new List { new("alice", -5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); - } - - [Fact] - public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() - { - var scores = new List { new("alice", -5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLoserBoardAsync(10)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(10); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLoserBoardAsync(10), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs index d744e76..74ff5b2 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs @@ -1,4 +1,3 @@ -using Bottomly.Commands; using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; @@ -21,9 +20,8 @@ public class GetCurrentKarmaReasonsHandlerTests public GetCurrentKarmaReasonsHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new GetCurrentKarmaReasonsCommand(_mockKarmaRepo.Object); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentKarmaReasonsHandler(command, parser, _mockBroker.Object, options, + _handler = new GetCurrentKarmaReasonsHandler(_mockKarmaRepo.Object, parser, _mockBroker.Object, options, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs index 2f4d6bf..8770bb4 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs @@ -1,6 +1,7 @@ using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -20,7 +21,10 @@ public GetCurrentNetKarmaHandlerTests() { var options = TestHelpers.CreateOptions(); var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentNetKarmaHandler(_mockKarmaRepo.Object, parser, _mockBroker.Object, options, + _handler = new GetCurrentNetKarmaHandler( + _mockKarmaRepo.Object, + new KarmaReactionMap(), parser, + _mockBroker.Object, options, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs index 51f19a4..6886954 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs @@ -1,4 +1,3 @@ -using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; @@ -19,8 +18,7 @@ public class GetLeaderBoardHandlerTests public GetLeaderBoardHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new GetLeaderBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLeaderBoardHandler(command, _mockBroker.Object, options, + _handler = new GetLeaderBoardHandler(_mockKarmaRepo.Object, _mockBroker.Object, options, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs index 24a1066..264f80c 100644 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs @@ -1,4 +1,3 @@ -using Bottomly.Commands; using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; @@ -19,8 +18,7 @@ public class GetLoserBoardHandlerTests public GetLoserBoardHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new GetLoserBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLoserBoardHandler(command, _mockBroker.Object, options, + _handler = new GetLoserBoardHandler(_mockKarmaRepo.Object, _mockBroker.Object, options, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs index 44eda26..6189f32 100644 --- a/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs @@ -21,7 +21,11 @@ public class AddKarmaReactionHandlerTests public AddKarmaReactionHandlerTests() { var command = new AddKarmaCommand(_mockKarmaRepo.Object); - _handler = new AddKarmaReactionHandler(command, _mockMemberRepo.Object, _mockBroker.Object, + _handler = new AddKarmaReactionHandler( + command, + new KarmaReactionMap(), + _mockMemberRepo.Object, + _mockBroker.Object, NullLogger.Instance); } diff --git a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs index 8b725af..73190a7 100644 --- a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs +++ b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs @@ -26,28 +26,4 @@ public async Task ExecuteAsync(string awardedTo, string awardedBy, string await karmaRepository.AddAsync(karma); return karma; } - - public static Dictionary GetKarmaReactions() => new() - { - ["+1"] = KarmaType.PozzyPoz, - ["arrow_up"] = KarmaType.PozzyPoz, - ["clap"] = KarmaType.PozzyPoz, - ["heart"] = KarmaType.PozzyPoz, - ["heart_eyes"] = KarmaType.PozzyPoz, - ["heavy_plus_sign"] = KarmaType.PozzyPoz, - ["heavy_tick"] = KarmaType.PozzyPoz, - ["joy"] = KarmaType.PozzyPoz, - ["party_parrot"] = KarmaType.PozzyPoz, - ["raised_hands"] = KarmaType.PozzyPoz, - ["smile"] = KarmaType.PozzyPoz, - ["thumbsup"] = KarmaType.PozzyPoz, - ["-1"] = KarmaType.NeggyNeg, - ["arrow_down"] = KarmaType.NeggyNeg, - ["hankey"] = KarmaType.NeggyNeg, - ["heavy_minus_sign"] = KarmaType.NeggyNeg, - ["poo"] = KarmaType.NeggyNeg, - ["poop"] = KarmaType.NeggyNeg, - ["shit"] = KarmaType.NeggyNeg, - ["thumbsdown"] = KarmaType.NeggyNeg - }; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs deleted file mode 100644 index 8efbf76..0000000 --- a/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetCurrentKarmaReasonsCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => - "Returns the justifications for someone's/something's current score of imaginary internet points"; - - public Task ExecuteAsync(string recipient) => - karmaRepository.GetKarmaReasonsAsync(recipient); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs deleted file mode 100644 index 7279f76..0000000 --- a/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetLeaderBoardCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Shows the best of the best!"; - - public Task> ExecuteAsync(int size = 3) => - karmaRepository.GetLeaderBoardAsync(size); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs deleted file mode 100644 index aa112fb..0000000 --- a/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetLoserBoardCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Shows the worst of the worst!"; - - public Task> ExecuteAsync(int size = 3) => - karmaRepository.GetLoserBoardAsync(size); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index f84f972..5dce07c 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -75,6 +75,7 @@ builder.Services.AddSingleton(); // Reaction handlers +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Membership handlers diff --git a/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs index 8632459..eb69374 100644 --- a/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs +++ b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs @@ -4,5 +4,5 @@ public interface ISlackMessageBroker { Task SendMessageAsync(string text, string channel, string? replyToTs = null); Task SendReactionAsync(string emoji, string channel, string timestamp); - Task SendDmAsync(string text, string userSlackId); + Task SendDmAsync(string text, string username); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs index 6327987..fca883f 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs @@ -1,5 +1,4 @@ using System.Text; -using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.Models; using Bottomly.Repositories; @@ -10,7 +9,7 @@ namespace Bottomly.Slack.MessageEventHandlers; public class GetCurrentKarmaReasonsHandler( - GetCurrentKarmaReasonsCommand command, + IKarmaRepository repository, SlackParser parser, ISlackMessageBroker broker, IOptions options, @@ -18,8 +17,11 @@ public class GetCurrentKarmaReasonsHandler( : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Karma Reasons"; - protected override ICommand Command => command; protected override string CommandSymbol => "reasons"; + + protected override string GetPurpose() => + "Returns the justifications for someone's/something's current score of imaginary internet points"; + protected override string GetUsage() => CommandTrigger + "[recipient ]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) @@ -31,7 +33,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) recipient = message.User; } - var result = await command.ExecuteAsync(recipient); + var result = await repository.GetKarmaReasonsAsync(recipient); var response = BuildResponse(result, recipient); await SendDmResponseAsync(response, message); } diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs index f35d5d7..db85e3c 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs @@ -1,8 +1,6 @@ -using System.Text; -using Bottomly.Commands; using Bottomly.Configuration; -using Bottomly.Models; using Bottomly.Repositories; +using Bottomly.Slack.ReactionHandlers; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; @@ -11,6 +9,7 @@ namespace Bottomly.Slack.MessageEventHandlers; public class GetCurrentNetKarmaHandler( IKarmaRepository repository, + KarmaReactionMap reactionMap, SlackParser parser, ISlackMessageBroker broker, IOptions options, @@ -22,19 +21,9 @@ public class GetCurrentNetKarmaHandler( protected override string GetUsage() => CommandTrigger + "[recipient ]"; protected override string GetPurpose() => - $"Returns someone's/something's current score of imaginary internet points.{Environment.NewLine}"; + "Returns someone's/something's current score of imaginary internet points."; - public override string GetUsageAddendum() - { - var reactions = AddKarmaCommand.GetKarmaReactions(); - var lines = new StringBuilder($"{Environment.NewLine}Giving Karma with reactions:{Environment.NewLine}"); - foreach (var kvp in reactions) - { - lines.AppendLine($":{kvp.Key}: will {(kvp.Value == KarmaType.PozzyPoz ? "PozzyPoz" : "NeggyNeg")}"); - } - - return lines.ToString(); - } + public override string GetUsageAddendum() => reactionMap.KarmaReactionDescriptions(); protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs index 8489082..a607d19 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs @@ -1,6 +1,6 @@ using System.Text; -using Bottomly.Commands; using Bottomly.Configuration; +using Bottomly.Repositories; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; @@ -8,15 +8,15 @@ namespace Bottomly.Slack.MessageEventHandlers; public class GetLeaderBoardHandler( - GetLeaderBoardCommand command, + IKarmaRepository repository, ISlackMessageBroker broker, IOptions options, ILogger logger) : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Leaderboard"; - protected override ICommand Command => command; protected override string CommandSymbol => "leaderboard"; + protected override string GetPurpose() => "Shows the best of the best!"; protected override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) @@ -24,7 +24,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var sizeArg = message.Text![CommandTrigger.Length..]; var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; - var result = await command.ExecuteAsync(size); + var result = await repository.GetLeaderBoardAsync(size); var sb = new StringBuilder(); foreach (var entry in result) { diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs index e55d1ea..36f32cb 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs @@ -1,6 +1,6 @@ using System.Text; -using Bottomly.Commands; using Bottomly.Configuration; +using Bottomly.Repositories; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; @@ -8,15 +8,15 @@ namespace Bottomly.Slack.MessageEventHandlers; public class GetLoserBoardHandler( - GetLoserBoardCommand command, + IKarmaRepository repository, ISlackMessageBroker broker, IOptions options, ILogger logger) : AbstractMessageEventHandler(broker, options, logger) { public override string Name => "Get Loserboard"; - protected override ICommand Command => command; protected override string CommandSymbol => "loserboard"; + protected override string GetPurpose() => "Shows the worst of the worst!"; protected override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) @@ -24,7 +24,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var sizeArg = message.Text![CommandTrigger.Length..]; var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; - var result = await command.ExecuteAsync(size); + var result = await repository.GetLoserBoardAsync(size); var sb = new StringBuilder(); foreach (var entry in result) { diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs index ab0c2f0..55a11a7 100644 --- a/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs @@ -8,6 +8,7 @@ namespace Bottomly.Slack.ReactionHandlers; public class AddKarmaReactionHandler( AddKarmaCommand command, + KarmaReactionMap reactionMap, IMemberRepository memberRepository, ISlackMessageBroker broker, ILogger logger) @@ -16,7 +17,7 @@ public class AddKarmaReactionHandler( public bool CanHandle(ReactionAdded reactionEvent) { var reaction = ParseReaction(reactionEvent.Reaction); - return AddKarmaCommand.GetKarmaReactions().ContainsKey(reaction); + return reactionMap.IsKarmaReaction(reaction); } public async Task HandleAsync(ReactionAdded reactionEvent) @@ -24,7 +25,6 @@ public async Task HandleAsync(ReactionAdded reactionEvent) try { var reaction = ParseReaction(reactionEvent.Reaction); - var reactions = AddKarmaCommand.GetKarmaReactions(); var reactor = await memberRepository.GetBySlackIdAsync(reactionEvent.User); var reactee = await memberRepository.GetBySlackIdAsync(reactionEvent.ItemUser); @@ -40,7 +40,7 @@ await command.ExecuteAsync( reactee.Username, reactor.Username, $"Reacted with {reaction}", - reactions[reaction]); + reactionMap.GetKarmaType(reaction)); if (reactionEvent.Item is ReactionMessage messageItem) { diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs new file mode 100644 index 0000000..a3d9433 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs @@ -0,0 +1,47 @@ +using System.Text; +using Bottomly.Models; + +namespace Bottomly.Slack.ReactionHandlers; + +public class KarmaReactionMap +{ + private static IDictionary Map => new Dictionary + { + ["+1"] = KarmaType.PozzyPoz, + ["arrow_up"] = KarmaType.PozzyPoz, + ["clap"] = KarmaType.PozzyPoz, + ["heart"] = KarmaType.PozzyPoz, + ["heart_eyes"] = KarmaType.PozzyPoz, + ["heavy_plus_sign"] = KarmaType.PozzyPoz, + ["heavy_tick"] = KarmaType.PozzyPoz, + ["joy"] = KarmaType.PozzyPoz, + ["party_parrot"] = KarmaType.PozzyPoz, + ["raised_hands"] = KarmaType.PozzyPoz, + ["smile"] = KarmaType.PozzyPoz, + ["thumbsup"] = KarmaType.PozzyPoz, + ["-1"] = KarmaType.NeggyNeg, + ["arrow_down"] = KarmaType.NeggyNeg, + ["hankey"] = KarmaType.NeggyNeg, + ["heavy_minus_sign"] = KarmaType.NeggyNeg, + ["poo"] = KarmaType.NeggyNeg, + ["poop"] = KarmaType.NeggyNeg, + ["shit"] = KarmaType.NeggyNeg, + ["thumbsdown"] = KarmaType.NeggyNeg + }; + + public bool IsKarmaReaction(string reaction) => Map.ContainsKey(reaction); + + public KarmaType GetKarmaType(string reaction) => Map[reaction]; + + public string KarmaReactionDescriptions() + { + var lines = new StringBuilder($"{Environment.NewLine}Giving Karma with reactions:{Environment.NewLine}"); + + foreach (var (key, value) in Map) + { + lines.AppendLine($":{key}: will {value}"); + } + + return lines.ToString(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs index 44a3257..582f412 100644 --- a/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs +++ b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs @@ -1,4 +1,5 @@ using Bottomly.Configuration; +using Bottomly.Repositories; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet; @@ -7,6 +8,7 @@ namespace Bottomly.Slack; public class SlackMessageBroker( + IMemberRepository repository, ISlackApiClient slack, IOptions options, ILogger logger) @@ -54,7 +56,7 @@ public async Task SendReactionAsync(string emoji, string channel, string timesta } } - public async Task SendDmAsync(string text, string userSlackId) + public async Task SendDmAsync(string text, string username) { if (string.IsNullOrEmpty(text)) { @@ -63,12 +65,13 @@ public async Task SendDmAsync(string text, string userSlackId) try { - var channelId = await slack.Conversations.Open([userSlackId]); + var member = await repository.GetByUsernameAsync(username); + var channelId = await slack.Conversations.Open([member!.SlackId]); await SendMessageAsync(text, channelId); } catch (Exception ex) { - logger.LogError(ex, "Error sending DM to user {UserId}", userSlackId); + logger.LogError(ex, "Error sending DM to user {UserId}", username); } } } \ No newline at end of file From 0aa5346b10f9c9e9140c62d9d9f8e71e855bf497 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 13:33:53 +0000 Subject: [PATCH 09/24] formatting --- bottomly.net/Bottomly/Bottomly.csproj | 8 ++++---- .../Bottomly/Commands/GoogleImageSearchCommand.cs | 2 +- .../Bottomly/Commands/GoogleSearchCommand.cs | 2 +- bottomly.net/Bottomly/Commands/ICommand.cs | 2 +- .../AbstractMessageKarmaEventHandler.cs | 3 ++- .../Slack/MessageEventHandlers/TestHandler.cs | 3 +-- bottomly.net/bottomly.net.slnx | 12 ++++++------ 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index 8c8d6f9..fff2e56 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -24,10 +24,10 @@ - - - PreserveNewest - + + + PreserveNewest + diff --git a/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs b/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs index a45e54d..51fa44a 100644 --- a/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs @@ -7,8 +7,8 @@ namespace Bottomly.Commands; public class GoogleImageSearchCommand : ICommand { - private readonly CustomSearchAPIService _service; private readonly string _cseId; + private readonly CustomSearchAPIService _service; public GoogleImageSearchCommand(IOptions options) { diff --git a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs index a2e9c93..56e440e 100644 --- a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs @@ -9,8 +9,8 @@ public record GoogleSearchResult(string Title, string Link); public class GoogleSearchCommand : ICommand { - private readonly CustomSearchAPIService _service; private readonly string _cseId; + private readonly CustomSearchAPIService _service; public GoogleSearchCommand(IOptions options) { diff --git a/bottomly.net/Bottomly/Commands/ICommand.cs b/bottomly.net/Bottomly/Commands/ICommand.cs index 304b891..60c68cf 100644 --- a/bottomly.net/Bottomly/Commands/ICommand.cs +++ b/bottomly.net/Bottomly/Commands/ICommand.cs @@ -3,7 +3,7 @@ namespace Bottomly.Commands; public interface ICommand { static readonly ICommand None = new VoidCommand(); - + string GetPurpose(); } diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs index c3eb5b7..7675448 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs @@ -24,7 +24,8 @@ public abstract class AbstractMessageKarmaEventHandler( public abstract override string Name { get; } protected abstract KarmaType KarmaTypeValue { get; } - protected override string GetUsage() => CommandSymbol + " recipient [[for ] reason]"; + protected override string GetUsage() => + CommandSymbol + " recipient [[for ] reason]"; protected override bool IsHelpEvent(MessageEvent message) => message.Text?.Trim() == CommandSymbol + " -?"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs index 90fd473..6e5cc27 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs @@ -1,5 +1,4 @@ -using Bottomly.Commands; -using Bottomly.Configuration; +using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx index 3d85583..9eb5410 100644 --- a/bottomly.net/bottomly.net.slnx +++ b/bottomly.net/bottomly.net.slnx @@ -1,10 +1,10 @@ - - + + - - - - + + + + From e98ba97e80d2fecf87a32cb52ef67cafc552a2fc Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 13:34:37 +0000 Subject: [PATCH 10/24] formatting --- bottomly.net/Bottomly/Models/Member.cs | 2 +- bottomly.net/Bottomly/Slack/MemberlistPopulator.cs | 2 +- .../Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs | 2 +- bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs | 2 +- .../Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bottomly.net/Bottomly/Models/Member.cs b/bottomly.net/Bottomly/Models/Member.cs index 9d0633c..9a093a6 100644 --- a/bottomly.net/Bottomly/Models/Member.cs +++ b/bottomly.net/Bottomly/Models/Member.cs @@ -4,7 +4,7 @@ namespace Bottomly.Models; public class Member { - [BsonId] [BsonElement("_id")] public string Username { get; set; } = string.Empty; + [BsonId][BsonElement("_id")] public string Username { get; set; } = string.Empty; [BsonElement("slack_id")] public string SlackId { get; set; } = string.Empty; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs index 484478b..0eb24a3 100644 --- a/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs +++ b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs @@ -1,4 +1,4 @@ -using Bottomly.Models; +using Bottomly.Models; using Bottomly.Repositories; using SlackNet; diff --git a/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs b/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs index 7c3a474..8954dbc 100644 --- a/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs @@ -1,4 +1,4 @@ -using Bottomly.Models; +using Bottomly.Models; using Bottomly.Repositories; using Microsoft.Extensions.Logging; using SlackNet; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs index 6e5cc27..fed0ccb 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs @@ -1,4 +1,4 @@ -using Bottomly.Configuration; +using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet.Events; diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs index a3d9433..2733d18 100644 --- a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using Bottomly.Models; namespace Bottomly.Slack.ReactionHandlers; From 38ed2e014e1997af648932c05cb2b15fe1c48fdb Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 3 Mar 2026 15:45:22 +0000 Subject: [PATCH 11/24] minor tweaks of karma reaction map --- .../Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs index 2733d18..3bce6cc 100644 --- a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs @@ -5,7 +5,7 @@ namespace Bottomly.Slack.ReactionHandlers; public class KarmaReactionMap { - private static IDictionary Map => new Dictionary + private Dictionary Map { get; } = new() { ["+1"] = KarmaType.PozzyPoz, ["arrow_up"] = KarmaType.PozzyPoz, @@ -29,6 +29,7 @@ public class KarmaReactionMap ["thumbsdown"] = KarmaType.NeggyNeg }; + public bool IsKarmaReaction(string reaction) => Map.ContainsKey(reaction); public KarmaType GetKarmaType(string reaction) => Map[reaction]; From a522ddb89adf2269acdc5ed26873f8ce27c6e5b6 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Thu, 5 Mar 2026 10:27:35 +0000 Subject: [PATCH 12/24] SAVEPOINT --- .../Slack/MessageEventHandlers/AbstractMessageEventHandler.cs | 2 +- .../Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index 6a67cbe..9262c85 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -58,7 +58,7 @@ Command is not VoidCommand protected abstract Task InvokeHandlerLogicAsync(MessageEvent message); protected virtual string GetUsage() => CommandTrigger.TrimEnd(); - public virtual string GetUsageAddendum() => string.Empty; + protected virtual string GetUsageAddendum() => string.Empty; protected virtual bool IsHelpEvent(MessageEvent message) => message.Text?.Trim() == CommandTrigger.TrimEnd() + " -?"; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs index db85e3c..70a2daa 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs @@ -23,7 +23,7 @@ public class GetCurrentNetKarmaHandler( protected override string GetPurpose() => "Returns someone's/something's current score of imaginary internet points."; - public override string GetUsageAddendum() => reactionMap.KarmaReactionDescriptions(); + protected override string GetUsageAddendum() => reactionMap.KarmaReactionDescriptions(); protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { From 94d8e9c00e97a18dac229587e4030f70fcaaab08 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Sun, 8 Mar 2026 13:04:26 +0000 Subject: [PATCH 13/24] Adds rudementary LLM integration --- bottomly.net/Bottomly.AppHost/AppHost.cs | 11 ++- .../Bottomly.AppHost/Bottomly.AppHost.csproj | 1 + bottomly.net/Bottomly/Bottomly.csproj | 2 + .../Bottomly/LlmBot/LlmMessageBroker.cs | 71 +++++++++++++++++++ bottomly.net/Bottomly/Models/Member.cs | 4 +- bottomly.net/Bottomly/Program.cs | 26 +++++++ .../Repositories/IMemberRepository.cs | 1 + .../Bottomly/Repositories/MemberRepository.cs | 17 ++--- .../Bottomly/Slack/MemberlistPopulator.cs | 6 ++ .../ConversationMessageHandler.cs | 64 +++++++++++++++++ bottomly.net/Bottomly/Slack/SlackWorker.cs | 9 ++- 11 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs create mode 100644 bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs index b20a925..9b46558 100644 --- a/bottomly.net/Bottomly.AppHost/AppHost.cs +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -6,13 +6,22 @@ builder.Configuration.AddUserSecrets(); var mongo = builder.AddMongoDB("mongo") + .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent); var mongodb = mongo.AddDatabase("mongodb"); +var ollama = builder.AddOllama("ollama") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + +var qwen = ollama.AddModel("qwen3", "qwen3:4b"); + var bottomly = builder.AddProject("bottomly") .WaitFor(mongodb) - .WithReference(mongodb); + .WaitFor(ollama) + .WithReference(mongodb) + .WithReference(qwen); var app = builder.Build(); diff --git a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj index dbd9fc3..06dfd3c 100644 --- a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj +++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj @@ -14,6 +14,7 @@ + diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index fff2e56..d48ff0a 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -10,8 +10,10 @@ + + diff --git a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs new file mode 100644 index 0000000..b919125 --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs @@ -0,0 +1,71 @@ +using System.Text; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Bottomly.LlmBot; + +public class LlmMessageBroker(IChatClient chatClient, ILogger logger) +{ + public async Task Respond(BottomlyInputMessage prompt, ChatMessageContext context) + { + var systemMessage = new ChatMessage(ChatRole.System, + """ + You are a helpful assistant and your name is Bottomly. + Your character is based on Jeeves from the PG Wodehouse novels. All responses should feature his form of + speech; be polite and respectful, with a hint of superiority. + You are participating in a Slack chat, so responses should be short and to the point, like spoken dialogue. + """); + + var promptContext = new ChatMessage(ChatRole.User, context.ToChatContext()); + var promptMessage = new ChatMessage(ChatRole.User, prompt.ToChatInput()); + var options = new ChatOptions + { + Temperature = 0 + }; + + logger.LogDebug("Sending prompt to LLM: {Context}, {Prompt}", promptContext.Text, promptMessage.Text); + + return await chatClient.GetResponseAsync([systemMessage, promptContext, promptMessage], options); + } +} + +public record BottomlyInputMessage +{ + public string Username { get; private init; } = ""; + public string Text { get; private init; } = ""; + + public static BottomlyInputMessage Create(string username, string text) => + new() { Username = username, Text = text }; +} + +public record BottomlyUserNote +{ + public string Username { get; private init; } = ""; + public string Note { get; private init; } = ""; + + public static BottomlyUserNote Create(string username, string note) => new() { Username = username, Note = note }; +} + +public record ChatMessageContext +{ + public List MessageHistory { get; private init; } = []; + public List UserNotes { get; private init; } = []; + + public static ChatMessageContext Create(List messageHistory, + List userNotes) => + new() { MessageHistory = messageHistory, UserNotes = userNotes }; +} + +public static class LlmBrokerExtensions +{ + public static string ToChatInput(this BottomlyInputMessage message) => $"{message.Username}: {message.Text}"; + + public static string ToChatContext(this ChatMessageContext context) => + new StringBuilder() + .AppendLine("**Context:**") + .AppendLine("_Message History:_") + .AppendLine(string.Join("\n", context.MessageHistory.Select(m => m.ToChatInput()))) + .AppendLine("_User Info:_") + .AppendLine(string.Join("\n", context.UserNotes.Select(n => $"{n.Username}: {n.Note}"))) + .ToString(); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/Member.cs b/bottomly.net/Bottomly/Models/Member.cs index 9a093a6..f83b361 100644 --- a/bottomly.net/Bottomly/Models/Member.cs +++ b/bottomly.net/Bottomly/Models/Member.cs @@ -4,7 +4,9 @@ namespace Bottomly.Models; public class Member { - [BsonId][BsonElement("_id")] public string Username { get; set; } = string.Empty; + [BsonId] [BsonElement("_id")] public string Username { get; set; } = string.Empty; [BsonElement("slack_id")] public string SlackId { get; set; } = string.Empty; + + [BsonElement("note")] public string Note { get; set; } = string.Empty; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 5dce07c..87e57fc 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -1,6 +1,7 @@ using System.Reflection; using Bottomly.Commands; using Bottomly.Configuration; +using Bottomly.LlmBot; using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MembershipEventHandlers; @@ -88,11 +89,36 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// LLM Support +builder.AddOllamaApiClient("qwen3") + .AddChatClient(); + +// The built-in resilience settings are super aggressive, with a 10s timeout. +// Running locally Qwen3 takes ~2m to respond to simple queries, so we need to override the defaults. +#pragma warning disable EXTEXP0001 +builder.Services.AddHttpClient("qwen3_httpClient") + .RemoveAllResilienceHandlers() +#pragma warning restore EXTEXP0001 + .AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5); + options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(2); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(4); + }); + +builder.Services.AddTransient(); + +// Seeding +builder.Services.AddSingleton(); + var app = builder.Build(); +var populator = app.Services.GetRequiredService(); +await populator.PopulateMembers(); app.Run(); + public static class HostBuilderExtensions { extension(HostApplicationBuilder builder) diff --git a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs index f510818..c285fb5 100644 --- a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs @@ -6,6 +6,7 @@ public interface IMemberRepository { Task GetByUsernameAsync(string username); Task GetBySlackIdAsync(string slackId); + Task> GetBySlackIdsAsync(IEnumerable slackIds); Task AddAsync(Member member); Task AddAsync(IEnumerable members); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/MemberRepository.cs b/bottomly.net/Bottomly/Repositories/MemberRepository.cs index a535d32..1fe8ba0 100644 --- a/bottomly.net/Bottomly/Repositories/MemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/MemberRepository.cs @@ -7,17 +7,14 @@ public class MemberRepository(IMongoDatabase database) : IMemberRepository { private readonly IMongoCollection _collection = database.GetCollection("member"); - public async Task GetByUsernameAsync(string username) - { - var result = await _collection.Find(m => m.Username == username).FirstOrDefaultAsync(); - return result; - } + public async Task GetByUsernameAsync(string username) => + await _collection.Find(m => m.Username == username).FirstOrDefaultAsync(); - public async Task GetBySlackIdAsync(string slackId) - { - var result = await _collection.Find(m => m.SlackId == slackId).FirstOrDefaultAsync(); - return result; - } + public async Task GetBySlackIdAsync(string slackId) => + await _collection.Find(m => m.SlackId == slackId).FirstOrDefaultAsync(); + + public Task> GetBySlackIdsAsync(IEnumerable slackIds) => + _collection.Find(m => slackIds.Contains(m.SlackId)).ToListAsync(); public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); public async Task AddAsync(IEnumerable members) => await _collection.InsertManyAsync(members); diff --git a/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs index 0eb24a3..64f4136 100644 --- a/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs +++ b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs @@ -8,6 +8,12 @@ public class MemberlistPopulator(ISlackApiClient slack, IMemberRepository member { public async Task> PopulateMembers() { + var owenExists = await memberRepository.GetByUsernameAsync("owen") != null; + if (owenExists) + { + return []; + } + var users = await slack.Users.List(); var members = users.Members diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs new file mode 100644 index 0000000..b65a786 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs @@ -0,0 +1,64 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using Bottomly.Repositories; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class ConversationMessageHandler( + LlmMessageBroker llmMessageBroker, + ISlackMessageBroker slackBroker, + ISlackApiClient apiClient, + IMemberRepository memberRepository +) : IMessageEventHandler +{ + public bool CanHandle(MessageEvent message) => message.Text.Contains("bottomly"); + + public async Task HandleAsync(MessageEvent message) + { + var history = await apiClient.Conversations.History(message.Channel, limit: 11); + + var contextUsersSlackIds = history.Messages.Select(m => m.User).Distinct(); + var contextMembers = await memberRepository.GetBySlackIdsAsync(contextUsersSlackIds.Union([message.User])); + var memberLookup = contextMembers.ToDictionary(m => m.SlackId, m => m.Username); + + var contextMessages = history.Messages + .OrderBy(h => h.Timestamp) + .Select(h => BottomlyInputMessage.CreateFromSlackMessage(h, memberLookup)) + .ToList(); + + var userNotes = contextMembers.Select(BottomlyUserNote.CreateFromMember).ToList(); + + var mainPrompt = BottomlyInputMessage.CreateFromSlackMessage(message, memberLookup); + + var context = ChatMessageContext.Create(contextMessages, userNotes); + + var llmResponse = await llmMessageBroker.Respond(mainPrompt, context); + var response = llmResponse.Text; + + + await slackBroker.SendMessageAsync(response, message.Channel); + } + + public string BuildHelpMessage() => string.Empty; +} + +internal static class MessageContextExtensions +{ + extension(BottomlyUserNote bottomlyUserNote) + { + public static BottomlyUserNote CreateFromMember(Member member) => + BottomlyUserNote.Create(member.Username, member.Note); + } + + extension(BottomlyInputMessage bottomlyInputMessage) + { + public static BottomlyInputMessage CreateFromSlackMessage(MessageEvent message, + IDictionary memberLookup) + { + var translatedUserName = memberLookup.TryGetValue(message.User, out var username) ? username : message.User; + return BottomlyInputMessage.Create(translatedUserName, message.Text); + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs index e911530..cafc0e9 100644 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -1,3 +1,4 @@ +using Bottomly.LlmBot; using Bottomly.Repositories; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; @@ -15,6 +16,7 @@ public class SlackWorker( HelpHandler helpMessageHandler, IEnumerable reactionHandlers, IMemberRepository memberRepository, + LlmMessageBroker llmMessageBroker, ILogger logger) : BackgroundService { @@ -50,10 +52,13 @@ public async Task ProcessMessageAsync(MessageEvent message) foreach (var handler in eventHandlers) { - if (handler.CanHandle(message)) + if (!handler.CanHandle(message)) { - await handler.HandleAsync(message); + continue; } + + await handler.HandleAsync(message); + return; } } catch (Exception ex) From d5c496c67a75e253e33882971f6c8b9605e95c50 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 11 Mar 2026 13:04:17 +0000 Subject: [PATCH 14/24] SAVEPOINT --- bottomly.net/.nuget/NuGet.config | 17 +++++ bottomly.net/Bottomly.AppHost/AppHost.cs | 14 ++-- .../Bottomly.AppHost/Bottomly.AppHost.csproj | 7 +- bottomly.net/Bottomly/Bottomly.csproj | 24 +++---- .../Bottomly/LlmBot/BottomlyInputMessage.cs | 10 +++ .../Bottomly/LlmBot/BottomlyUserNote.cs | 10 +++ .../Bottomly/LlmBot/FullPromptContext.cs | 56 ++++++++++++++++ .../Bottomly/LlmBot/LlmMessageBroker.cs | 67 +++---------------- .../Bottomly/LlmBot/MessageHistoryContext.cs | 15 +++++ bottomly.net/Bottomly/Program.cs | 13 ++-- .../ConversationMessageHandler.cs | 2 +- bottomly.net/bottomly.net.slnx | 13 ++-- 12 files changed, 160 insertions(+), 88 deletions(-) create mode 100644 bottomly.net/.nuget/NuGet.config create mode 100644 bottomly.net/Bottomly/LlmBot/BottomlyInputMessage.cs create mode 100644 bottomly.net/Bottomly/LlmBot/BottomlyUserNote.cs create mode 100644 bottomly.net/Bottomly/LlmBot/FullPromptContext.cs create mode 100644 bottomly.net/Bottomly/LlmBot/MessageHistoryContext.cs diff --git a/bottomly.net/.nuget/NuGet.config b/bottomly.net/.nuget/NuGet.config new file mode 100644 index 0000000..2159df8 --- /dev/null +++ b/bottomly.net/.nuget/NuGet.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs index 9b46558..c49b15d 100644 --- a/bottomly.net/Bottomly.AppHost/AppHost.cs +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -12,16 +12,20 @@ var mongodb = mongo.AddDatabase("mongodb"); var ollama = builder.AddOllama("ollama") + .WithEnvironment("OLLAMA_API_KEY", builder.Configuration["AppHost:OllamaApiKey"]) .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent); -var qwen = ollama.AddModel("qwen3", "qwen3:4b"); +//var ollama = builder.AddOllamaLocal("ollama"); + +var bottomlyModel = ollama.AddModel("bottomlymodel", "qwen3.5:cloud"); var bottomly = builder.AddProject("bottomly") - .WaitFor(mongodb) - .WaitFor(ollama) - .WithReference(mongodb) - .WithReference(qwen); + .WaitFor(mongodb) + .WaitFor(ollama) + .WithReference(bottomlyModel) + .WithReference(mongodb) + ; var app = builder.Build(); diff --git a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj index 06dfd3c..ec78659 100644 --- a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj +++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj @@ -13,8 +13,11 @@ - - + + + + + diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index d48ff0a..013d399 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -9,24 +9,24 @@ - - - - - - - - - - + + + + + + + + + + - + - + PreserveNewest diff --git a/bottomly.net/Bottomly/LlmBot/BottomlyInputMessage.cs b/bottomly.net/Bottomly/LlmBot/BottomlyInputMessage.cs new file mode 100644 index 0000000..ff9f2b9 --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/BottomlyInputMessage.cs @@ -0,0 +1,10 @@ +namespace Bottomly.LlmBot; + +public record BottomlyInputMessage +{ + public string Username { get; private init; } = ""; + public string Text { get; private init; } = ""; + + public static BottomlyInputMessage Create(string username, string text) => + new() { Username = username, Text = text }; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/LlmBot/BottomlyUserNote.cs b/bottomly.net/Bottomly/LlmBot/BottomlyUserNote.cs new file mode 100644 index 0000000..884d293 --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/BottomlyUserNote.cs @@ -0,0 +1,10 @@ +namespace Bottomly.LlmBot; + +public record BottomlyUserNote +{ + private BottomlyUserNote(string username, string note) => (Username, Note) = (username, note); + public string Username { get; private init; } = ""; + public string Note { get; private init; } = ""; + + public static BottomlyUserNote Create(string username, string note) => new(username, note); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/LlmBot/FullPromptContext.cs b/bottomly.net/Bottomly/LlmBot/FullPromptContext.cs new file mode 100644 index 0000000..da86cec --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/FullPromptContext.cs @@ -0,0 +1,56 @@ +using System.Text; +using Microsoft.Extensions.AI; + +namespace Bottomly.LlmBot; + +public record FullPromptContext +{ + private FullPromptContext(ChatMessage historyContext, ChatMessage promptingMessage) => + (HistoryContext, PromptingMessage) = (historyContext, promptingMessage); + + public static ChatMessage SystemPrompt => + new(ChatRole.System, + """ + You are a helpful assistant and your name is Bottomly. + Your character is based on Jeeves from the PG Wodehouse novels. All responses should feature his form of + speech; be polite and respectful, with a hint of superiority. + You are participating in a Slack chat, so responses should be short and to the point, like spoken dialogue. + """); + + public ChatMessage HistoryContext { get; } + public ChatMessage PromptingMessage { get; } + + public static FullPromptContext Create(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) + => new( + new ChatMessage(ChatRole.User, historyContext.ToChatContext()), + new ChatMessage(ChatRole.User, userPrompt.ToChatPromptMessage())); + + public ChatMessage[] ToArray() => [SystemPrompt, HistoryContext, PromptingMessage]; +} + +public static class LlmBrokerExtensions +{ + public static string ToChatContext(this MessageHistoryContext historyContext) => + new StringBuilder() + .AppendLine("**Begin Prompt Context:**") + .AppendLine("_Message History:_") + .AppendLine(string.Join("\n", historyContext.MessageHistory.Select(m => m.ToChatContextMessage()))) + .AppendLine("_User Info:_") + .AppendLine(string.Join("\n", historyContext.UserNotes.Select(n => $"{n.Username}: {n.Note}"))) + .AppendLine("**End Prompt Context**") + .ToString(); + + extension(BottomlyInputMessage message) + { + public string ToChatPromptMessage() => + new StringBuilder() + .AppendLine("**Begin Main Prompt:**") + .AppendLine($"_User to respond to is {message.Username}_") + .AppendLine(message.Text) + .AppendLine("**End Main Prompt**") + .ToString(); + + private string ToChatContextMessage() => + $"{message.Username}: {message.Text}"; + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs index b919125..c240bf9 100644 --- a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs +++ b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs @@ -1,71 +1,24 @@ -using System.Text; -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace Bottomly.LlmBot; public class LlmMessageBroker(IChatClient chatClient, ILogger logger) { - public async Task Respond(BottomlyInputMessage prompt, ChatMessageContext context) + public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) { - var systemMessage = new ChatMessage(ChatRole.System, - """ - You are a helpful assistant and your name is Bottomly. - Your character is based on Jeeves from the PG Wodehouse novels. All responses should feature his form of - speech; be polite and respectful, with a hint of superiority. - You are participating in a Slack chat, so responses should be short and to the point, like spoken dialogue. - """); - - var promptContext = new ChatMessage(ChatRole.User, context.ToChatContext()); - var promptMessage = new ChatMessage(ChatRole.User, prompt.ToChatInput()); var options = new ChatOptions { - Temperature = 0 + Temperature = 0.2f }; - logger.LogDebug("Sending prompt to LLM: {Context}, {Prompt}", promptContext.Text, promptMessage.Text); - - return await chatClient.GetResponseAsync([systemMessage, promptContext, promptMessage], options); - } -} - -public record BottomlyInputMessage -{ - public string Username { get; private init; } = ""; - public string Text { get; private init; } = ""; - - public static BottomlyInputMessage Create(string username, string text) => - new() { Username = username, Text = text }; -} - -public record BottomlyUserNote -{ - public string Username { get; private init; } = ""; - public string Note { get; private init; } = ""; - - public static BottomlyUserNote Create(string username, string note) => new() { Username = username, Note = note }; -} - -public record ChatMessageContext -{ - public List MessageHistory { get; private init; } = []; - public List UserNotes { get; private init; } = []; + var fullContext = FullPromptContext.Create(userPrompt, historyContext); - public static ChatMessageContext Create(List messageHistory, - List userNotes) => - new() { MessageHistory = messageHistory, UserNotes = userNotes }; -} + logger.LogDebug("Sending prompt to LLM: {System}, {Context}, {Prompt}", + FullPromptContext.SystemPrompt.Text, + fullContext.HistoryContext.Text, + fullContext.PromptingMessage.Text); -public static class LlmBrokerExtensions -{ - public static string ToChatInput(this BottomlyInputMessage message) => $"{message.Username}: {message.Text}"; - - public static string ToChatContext(this ChatMessageContext context) => - new StringBuilder() - .AppendLine("**Context:**") - .AppendLine("_Message History:_") - .AppendLine(string.Join("\n", context.MessageHistory.Select(m => m.ToChatInput()))) - .AppendLine("_User Info:_") - .AppendLine(string.Join("\n", context.UserNotes.Select(n => $"{n.Username}: {n.Note}"))) - .ToString(); + return await chatClient.GetResponseAsync(fullContext.ToArray(), options); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/LlmBot/MessageHistoryContext.cs b/bottomly.net/Bottomly/LlmBot/MessageHistoryContext.cs new file mode 100644 index 0000000..2dc12bd --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/MessageHistoryContext.cs @@ -0,0 +1,15 @@ +namespace Bottomly.LlmBot; + +public record MessageHistoryContext +{ + private MessageHistoryContext(List messageHistory, List userNotes) => + (MessageHistory, UserNotes) = (messageHistory, userNotes); + + public List MessageHistory { get; private init; } = []; + public List UserNotes { get; private init; } = []; + + public static MessageHistoryContext Create( + List messageHistory, + List userNotes) => + new(messageHistory, userNotes); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 87e57fc..f8936c2 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -90,20 +90,23 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService()); // LLM Support -builder.AddOllamaApiClient("qwen3") +builder.AddOllamaApiClient("bottomlymodel") .AddChatClient(); +// builder.Services.AddChatClient( +// new OllamaApiClient(new Uri("http://localhost:11434"), "qwen3.5:4b")); + // The built-in resilience settings are super aggressive, with a 10s timeout. // Running locally Qwen3 takes ~2m to respond to simple queries, so we need to override the defaults. #pragma warning disable EXTEXP0001 -builder.Services.AddHttpClient("qwen3_httpClient") +builder.Services.AddHttpClient("bottomlymodel_httpClient") .RemoveAllResilienceHandlers() #pragma warning restore EXTEXP0001 .AddStandardResilienceHandler(options => { - options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(5); - options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(2); - options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(4); + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(4); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); }); builder.Services.AddTransient(); diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs index b65a786..ede7f97 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs @@ -32,7 +32,7 @@ public async Task HandleAsync(MessageEvent message) var mainPrompt = BottomlyInputMessage.CreateFromSlackMessage(message, memberLookup); - var context = ChatMessageContext.Create(contextMessages, userNotes); + var context = MessageHistoryContext.Create(contextMessages, userNotes); var llmResponse = await llmMessageBroker.Respond(mainPrompt, context); var response = llmResponse.Text; diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx index 9eb5410..87d8a19 100644 --- a/bottomly.net/bottomly.net.slnx +++ b/bottomly.net/bottomly.net.slnx @@ -1,10 +1,11 @@ - - + + + - - - - + + + + From aa09698d9973a959a395629ebb4e09a0d2f99a1a Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Fri, 13 Mar 2026 16:29:42 +0000 Subject: [PATCH 15/24] Add gender, sass level, full name, and misc info fields to Member - Add Gender enum (Unknown, Male, Female, NonBinary, Other) - Add SassLevel enum (None, Limited, Moderate, Frequent, Constant) - Add FullName (string), MiscInfo (string) persisted fields - Replace persisted Note field with computed BsonIgnore property that formats all non-identity fields as KVP for LLM prompts --- bottomly.net/Bottomly/Models/Gender.cs | 10 ++++++++++ bottomly.net/Bottomly/Models/Member.cs | 15 ++++++++++++++- bottomly.net/Bottomly/Models/SassLevel.cs | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 bottomly.net/Bottomly/Models/Gender.cs create mode 100644 bottomly.net/Bottomly/Models/SassLevel.cs diff --git a/bottomly.net/Bottomly/Models/Gender.cs b/bottomly.net/Bottomly/Models/Gender.cs new file mode 100644 index 0000000..a2c08d8 --- /dev/null +++ b/bottomly.net/Bottomly/Models/Gender.cs @@ -0,0 +1,10 @@ +namespace Bottomly.Models; + +public enum Gender +{ + Unknown, + Male, + Female, + NonBinary, + Other +} diff --git a/bottomly.net/Bottomly/Models/Member.cs b/bottomly.net/Bottomly/Models/Member.cs index f83b361..ecf780e 100644 --- a/bottomly.net/Bottomly/Models/Member.cs +++ b/bottomly.net/Bottomly/Models/Member.cs @@ -8,5 +8,18 @@ public class Member [BsonElement("slack_id")] public string SlackId { get; set; } = string.Empty; - [BsonElement("note")] public string Note { get; set; } = string.Empty; + [BsonElement("full_name")] public string FullName { get; set; } = string.Empty; + + [BsonElement("gender")] public Gender Gender { get; set; } = Gender.Unknown; + + [BsonElement("sass_level")] public SassLevel SassLevel { get; set; } = SassLevel.Moderate; + + [BsonElement("misc_info")] public string MiscInfo { get; set; } = string.Empty; + + [BsonIgnore] + public string Note => + $"FullName: {FullName}\n" + + $"Gender: {Gender}\n" + + $"SassLevel: {SassLevel}\n" + + $"MiscInfo: {MiscInfo}"; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/SassLevel.cs b/bottomly.net/Bottomly/Models/SassLevel.cs new file mode 100644 index 0000000..6106c86 --- /dev/null +++ b/bottomly.net/Bottomly/Models/SassLevel.cs @@ -0,0 +1,10 @@ +namespace Bottomly.Models; + +public enum SassLevel +{ + None, + Limited, + Moderate, + Frequent, + Constant +} From c28a0d7d83863b370690090db8c26202cc317cbb Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Fri, 13 Mar 2026 16:44:57 +0000 Subject: [PATCH 16/24] Updates git ignore to not include memberseeddata --- bottomly.net/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore index fda0c69..d79eb76 100644 --- a/bottomly.net/.gitignore +++ b/bottomly.net/.gitignore @@ -3,6 +3,9 @@ # Planning documents .plans/ +# Confidential member seed data +MemberSeedData/ + # Build results [Dd]ebug/ [Dd]ebugPublic/ From d736cebc56bed85faac07fd68177830fefaae23d Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 16 Mar 2026 16:46:43 +0000 Subject: [PATCH 17/24] SAVEPOINT --- bottomly.net/Bottomly.AppHost/AppHost.cs | 10 +- .../Commands/GiphyCommandTests.cs | 25 ++ .../Commands/RegSearchCommandTests.cs | 97 ++++++++ .../Commands/UrbanSearchCommandTests.cs | 23 ++ .../Commands/WikipediaSearchCommandTests.cs | 25 ++ .../Bottomly.Tests/Helpers/TestHelpers.cs | 24 ++ .../Bottomly.Tests/LlmBot/LlmBotTests.cs | 213 ++++++++++++++++++ .../LlmBot/LlmMessageBrokerTests.cs | 105 +++++++++ .../Slack/EventHandlers/RegHandlerTests.cs | 101 +++++++++ .../Slack/MemberlistPopulatorTests.cs | 64 ++++++ .../MemberJoinedEventHandlerTests.cs | 62 +++++ .../ConversationMessageHandlerTests.cs | 113 ++++++++++ .../MessageContextExtensionsTests.cs | 52 +++++ .../ResponseMessageFactoryTests.cs | 50 ++++ .../Slack/SlackMessageBrokerTests.cs | 114 ++++++++++ bottomly.net/Bottomly/Bottomly.csproj | 31 ++- .../Bottomly/Commands/RegSearchCommand.cs | 2 +- .../Bottomly/LlmBot/LlmMessageBroker.cs | 44 +++- bottomly.net/Bottomly/Program.cs | 8 + .../Repositories/IMemberRepository.cs | 1 + .../Bottomly/Repositories/MemberRepository.cs | 10 + .../Bottomly/Seed/MemberSeedDataDto.cs | 11 + .../Bottomly/Seed/MemberSeedDataImporter.cs | 85 +++++++ .../ConversationMessageHandler.cs | 28 +-- .../MessageContextExtensions.cs | 24 ++ .../ResponseMessageFactory.cs | 36 +++ bottomly.net/Bottomly/appsettings.json | 3 +- 27 files changed, 1315 insertions(+), 46 deletions(-) create mode 100644 bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs create mode 100644 bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactoryTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs create mode 100644 bottomly.net/Bottomly/Seed/MemberSeedDataDto.cs create mode 100644 bottomly.net/Bottomly/Seed/MemberSeedDataImporter.cs rename bottomly.net/Bottomly/Slack/MessageEventHandlers/{ => ConversationMessageHandling}/ConversationMessageHandler.cs (58%) create mode 100644 bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs create mode 100644 bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs index c49b15d..a60a1f3 100644 --- a/bottomly.net/Bottomly.AppHost/AppHost.cs +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -11,12 +11,12 @@ var mongodb = mongo.AddDatabase("mongodb"); -var ollama = builder.AddOllama("ollama") - .WithEnvironment("OLLAMA_API_KEY", builder.Configuration["AppHost:OllamaApiKey"]) - .WithDataVolume() - .WithLifetime(ContainerLifetime.Persistent); +// var ollama = builder.AddOllama("ollama") +// .WithEnvironment("OLLAMA_API_KEY", builder.Configuration["AppHost:OllamaApiKey"]) +// .WithDataVolume() +// .WithLifetime(ContainerLifetime.Persistent); -//var ollama = builder.AddOllamaLocal("ollama"); +var ollama = builder.AddOllamaLocal("ollama"); var bottomlyModel = ollama.AddModel("bottomlymodel", "qwen3.5:cloud"); diff --git a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs index 1684912..ecedd3c 100644 --- a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs +++ b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs @@ -1,5 +1,6 @@ using Bottomly.Commands; using Bottomly.Configuration; +using Bottomly.Tests.Helpers; using Microsoft.Extensions.Options; using Moq; using Shouldly; @@ -19,4 +20,28 @@ public async Task ExecuteAsync_EmptyInput_ReturnsNull() result.ShouldBeNull(); } + + [Fact] + public async Task ExecuteAsync_WithResult_ReturnsUrl() + { + const string json = """{"data":{"url":"https://giphy.com/gifs/funny-cat"}}"""; + var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); + var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options); + + var result = await command.ExecuteAsync("cat"); + + result.ShouldBe("https://giphy.com/gifs/funny-cat"); + } + + [Fact] + public async Task ExecuteAsync_EmptyDataArray_ReturnsNull() + { + const string json = """{"data":[]}"""; + var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); + var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options); + + var result = await command.ExecuteAsync("obscuresearch"); + + result.ShouldBeNull(); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs new file mode 100644 index 0000000..c0cc169 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/RegSearchCommandTests.cs @@ -0,0 +1,97 @@ +using Bottomly.Commands; +using Bottomly.Tests.Helpers; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class RegSearchCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsRegistrationMissingMessage() + { + var command = new RegSearchCommand(new Mock().Object); + + var result = await command.ExecuteAsync(" "); + + result.ShouldBe("Registration missing"); + } + + [Fact] + public async Task ExecuteAsync_TooLongInput_ReturnsTooLongMessage() + { + var command = new RegSearchCommand(new Mock().Object); + + var result = await command.ExecuteAsync("ABCDEFGH"); // 8 chars + + result.ShouldBe("Registration too long."); + } + + [Fact] + public async Task ExecuteAsync_SpecialChars_ReturnsSpecialCharsMessage() + { + var command = new RegSearchCommand(new Mock().Object); + + var result = await command.ExecuteAsync("AB-12C"); + + result.ShouldBe("Registration should not contain special characters"); + } + + [Fact] + public async Task ExecuteAsync_ValidReg_ParsesHtmlResponse() + { + const string html = """ + + + + + + + + """; + + var factory = TestHelpers.CreateHttpClientFactory(html); + var command = new RegSearchCommand(factory); + + var result = await command.ExecuteAsync("AB12CDE"); + + result.ShouldContain("Blue"); + result.ShouldContain("Ford"); + result.ShouldContain("FOCUS"); + result.ShouldContain("2019"); + } + + [Fact] + public async Task ExecuteAsync_SpacesInReg_NormalisesBeforeSearch() + { + // If spaces are stripped, "AB 12 CDE" becomes "ab12cde" (7 chars, valid) + const string html = ""; + var factory = TestHelpers.CreateHttpClientFactory(html); + var command = new RegSearchCommand(factory); + + var result = await command.ExecuteAsync("AB 12 CD"); + + // Does not return an error about length or special chars + result.ShouldNotBe("Registration too long."); + result.ShouldNotBe("Registration should not contain special characters"); + } + + [Fact] + public async Task ExecuteAsync_HtmlWithError_ReturnsErrorText() + { + // An element with an empty value causes make[0] to throw, which triggers the catch block + const string html = """ + + +

Vehicle not found

+ + """; + + var factory = TestHelpers.CreateHttpClientFactory(html); + var command = new RegSearchCommand(factory); + + var result = await command.ExecuteAsync("ZZ99ZZZ"); + + result.ShouldBe("Vehicle not found"); + } +} diff --git a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs index d6f3680..0d1a09e 100644 --- a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs +++ b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Tests.Helpers; using Moq; using Shouldly; @@ -16,4 +17,26 @@ public async Task ExecuteAsync_EmptyInput_ReturnsNull() result.ShouldBeNull(); } + + [Fact] + public async Task ExecuteAsync_WithResults_ReturnsDefinition() + { + const string json = """{"list":[{"definition":"A domestic animal that owns you."}]}"""; + var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("cat"); + + result.ShouldBe("A domestic animal that owns you."); + } + + [Fact] + public async Task ExecuteAsync_NoResults_ReturnsNull() + { + const string json = """{"list":[]}"""; + var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("xyznotaword"); + + result.ShouldBeNull(); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs index 6830330..95f50dd 100644 --- a/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs +++ b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Tests.Helpers; using Moq; using Shouldly; @@ -16,4 +17,28 @@ public async Task ExecuteAsync_EmptyInput_ReturnsNull() result.ShouldBeNull(); } + + [Fact] + public async Task ExecuteAsync_WithResults_ReturnsTitleAndLink() + { + const string json = """["cat",["Cat","Cat (disambiguation)"],["",""],["https://en.wikipedia.org/wiki/Cat","https://en.wikipedia.org/wiki/Cat_(disambiguation)"]]"""; + var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("cat"); + + result.ShouldNotBeNull(); + result!.Text.ShouldBe("Cat"); + result.Link.ShouldBe("https://en.wikipedia.org/wiki/Cat"); + } + + [Fact] + public async Task ExecuteAsync_NoResults_ReturnsNull() + { + const string json = """["unknownxyz",[],[],[]]"""; + var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("unknownxyz"); + + result.ShouldBeNull(); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs index 0334a01..3579b90 100644 --- a/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs +++ b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs @@ -1,5 +1,7 @@ +using System.Net; using Bottomly.Configuration; using Microsoft.Extensions.Options; +using Moq; namespace Bottomly.Tests.Helpers; @@ -9,4 +11,26 @@ internal static class TestHelpers public static IOptions CreateOptions(string prefix = TestPrefix) => Options.Create(new BottomlyOptions { Prefix = prefix }); + + public static IHttpClientFactory CreateHttpClientFactory(string responseContent, + HttpStatusCode statusCode = HttpStatusCode.OK) + { + var handler = new FakeHttpMessageHandler(responseContent, statusCode); + var client = new HttpClient(handler); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(client); + return factory.Object; + } +} + +internal class FakeHttpMessageHandler(string content, HttpStatusCode statusCode = HttpStatusCode.OK) + : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(content) + }); } \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs new file mode 100644 index 0000000..fd04147 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs @@ -0,0 +1,213 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using Microsoft.Extensions.AI; +using Shouldly; + +namespace Bottomly.Tests.LlmBot; + +public class BottomlyInputMessageTests +{ + [Fact] + public void Create_SetsUsernameAndText() + { + var msg = BottomlyInputMessage.Create("alice", "hello world"); + + msg.Username.ShouldBe("alice"); + msg.Text.ShouldBe("hello world"); + } +} + +public class BottomlyUserNoteTests +{ + [Fact] + public void Create_SetsUsernameAndNote() + { + var note = BottomlyUserNote.Create("bob", "likes cats"); + + note.Username.ShouldBe("bob"); + note.Note.ShouldBe("likes cats"); + } +} + +public class MessageHistoryContextTests +{ + [Fact] + public void Create_SetsMessageHistoryAndUserNotes() + { + var messages = new List { BottomlyInputMessage.Create("alice", "hi") }; + var notes = new List { BottomlyUserNote.Create("alice", "some note") }; + + var ctx = MessageHistoryContext.Create(messages, notes); + + ctx.MessageHistory.ShouldBe(messages); + ctx.UserNotes.ShouldBe(notes); + } + + [Fact] + public void Create_EmptyLists_ResultsInEmptyContext() + { + var ctx = MessageHistoryContext.Create([], []); + + ctx.MessageHistory.ShouldBeEmpty(); + ctx.UserNotes.ShouldBeEmpty(); + } +} + +public class LlmResponseExtensionsTests +{ + [Fact] + public void ToSuccessResponse_ReturnsMsgResponseWithText() + { + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello there")]); + + var result = chatResponse.ToSuccessResponse(); + + result.ShouldBeOfType(); + ((LlmMessageResponse)result).Message.ShouldBe("Hello there"); + } + + [Fact] + public void ToErrorResponse_TimeoutException_ReturnsTimeout() + { + var ex = new TimeoutException("timed out"); + + var result = ex.ToErrorResponse(); + + result.ShouldBeOfType(); + } + + [Fact] + public void ToErrorResponse_UsageException_ReturnsUsageExceeded() + { + var ex = new Exception("usage limit reached"); + + var result = ex.ToErrorResponse(); + + result.ShouldBeOfType(); + } + + [Fact] + public void ToErrorResponse_UnknownException_ReturnsUnknownError() + { + var ex = new InvalidOperationException("something went wrong"); + + var result = ex.ToErrorResponse(); + + result.ShouldBeOfType(); + } + + [Fact] + public void IsSuccess_LlmMessageResponse_ReturnsTrue() + { + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")]); + var response = chatResponse.ToSuccessResponse(); + + response.IsSuccess().ShouldBeTrue(); + } + + [Fact] + public void IsSuccess_LlmTimeoutResponse_ReturnsFalse() + { + LlmResponse response = new LlmTimeoutResponse(); + + response.IsSuccess().ShouldBeFalse(); + } +} + +public class LlmBrokerExtensionsTests +{ + [Fact] + public void ToChatContext_IncludesMessageHistoryAndUserInfo() + { + var messages = new List + { + BottomlyInputMessage.Create("alice", "hello"), + BottomlyInputMessage.Create("bob", "world") + }; + var notes = new List + { + BottomlyUserNote.Create("alice", "likes tea") + }; + var ctx = MessageHistoryContext.Create(messages, notes); + + var result = ctx.ToChatContext(); + + result.ShouldContain("alice: hello"); + result.ShouldContain("bob: world"); + result.ShouldContain("alice: likes tea"); + result.ShouldContain("Begin Prompt Context"); + result.ShouldContain("End Prompt Context"); + } + + [Fact] + public void ToChatPromptMessage_IncludesUsernameAndText() + { + var msg = BottomlyInputMessage.Create("carol", "what time is it?"); + + var result = msg.ToChatPromptMessage(); + + result.ShouldContain("carol"); + result.ShouldContain("what time is it?"); + result.ShouldContain("Begin Main Prompt"); + result.ShouldContain("End Main Prompt"); + } +} + +public class FullPromptContextTests +{ + [Fact] + public void Create_PopulatesHistoryContextAndPromptingMessage() + { + var prompt = BottomlyInputMessage.Create("alice", "say something"); + var ctx = MessageHistoryContext.Create([], []); + + var fullCtx = FullPromptContext.Create(prompt, ctx); + + fullCtx.HistoryContext.ShouldNotBeNull(); + fullCtx.PromptingMessage.ShouldNotBeNull(); + } + + [Fact] + public void ToArray_ContainsThreeMessages() + { + var prompt = BottomlyInputMessage.Create("alice", "say something"); + var ctx = MessageHistoryContext.Create([], []); + + var array = FullPromptContext.Create(prompt, ctx).ToArray(); + + array.Length.ShouldBe(3); + array[0].Role.ShouldBe(ChatRole.System); + } + + [Fact] + public void SystemPrompt_HasSystemRole() + { + FullPromptContext.SystemPrompt.Role.ShouldBe(ChatRole.System); + } + + [Fact] + public void SystemPrompt_MentionsBottomly() + { + FullPromptContext.SystemPrompt.Text.ShouldContain("Bottomly"); + } +} + +public class MemberNoteTests +{ + [Fact] + public void Note_ContainsAllMemberInfo() + { + var member = new Member + { + FullName = "Alice Smith", + Gender = Gender.Female, + SassLevel = SassLevel.Frequent, + MiscInfo = "Drinks tea" + }; + + member.Note.ShouldContain("Alice Smith"); + member.Note.ShouldContain("Female"); + member.Note.ShouldContain("Frequent"); + member.Note.ShouldContain("Drinks tea"); + } +} diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs new file mode 100644 index 0000000..2ef41af --- /dev/null +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs @@ -0,0 +1,105 @@ +using Bottomly.LlmBot; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.LlmBot; + +public class LlmMessageBrokerTests +{ + private readonly Mock _mockChatClient = new(); + private readonly LlmMessageBroker _broker; + + public LlmMessageBrokerTests() + { + _broker = new LlmMessageBroker(_mockChatClient.Object, NullLogger.Instance); + } + + [Fact] + public async Task Respond_SuccessfulResponse_ReturnsLlmMessageResponse() + { + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Quite right, sir.")]); + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(chatResponse); + + var prompt = BottomlyInputMessage.Create("alice", "What's the weather?"); + var context = MessageHistoryContext.Create([], []); + + var result = await _broker.Respond(prompt, context); + + result.ShouldBeOfType(); + ((LlmMessageResponse)result).Message.ShouldBe("Quite right, sir."); + } + + [Fact] + public async Task Respond_TimeoutException_ReturnsLlmTimeoutResponse() + { + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ThrowsAsync(new TimeoutException("Request timed out")); + + var prompt = BottomlyInputMessage.Create("alice", "Hello?"); + var context = MessageHistoryContext.Create([], []); + + var result = await _broker.Respond(prompt, context); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task Respond_UsageExceededException_ReturnsLlmUsageExceededResponse() + { + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ThrowsAsync(new Exception("usage limit exceeded")); + + var prompt = BottomlyInputMessage.Create("alice", "Hello?"); + var context = MessageHistoryContext.Create([], []); + + var result = await _broker.Respond(prompt, context); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task Respond_UnknownException_ReturnsLlmUnknownErrorResponse() + { + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("something unexpected")); + + var prompt = BottomlyInputMessage.Create("alice", "Hello?"); + var context = MessageHistoryContext.Create([], []); + + var result = await _broker.Respond(prompt, context); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task Respond_PassesPromptToClient() + { + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Indeed.")]); + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(chatResponse); + + var prompt = BottomlyInputMessage.Create("alice", "test message"); + var context = MessageHistoryContext.Create([], []); + + await _broker.Respond(prompt, context); + + _mockChatClient.Verify(c => + c.GetResponseAsync( + It.Is>(msgs => msgs.Count() == 3), + It.IsAny(), + It.IsAny()), Times.Once()); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs new file mode 100644 index 0000000..1462ddf --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs @@ -0,0 +1,101 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class RegHandlerTests +{ + private readonly RegHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public RegHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _mockCommand = new Mock(new Mock().Object); + _handler = new RegHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text) => + new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_ValidEvent_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_reg AB12CDE")).ShouldBeTrue(); + + [Fact] + public void CanHandle_JustTrigger_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_reg")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_WithResult_SendsResult() + { + _mockCommand.Setup(c => c.ExecuteAsync(It.IsAny())) + .ReturnsAsync("Red Ford Focus (2019)"); + + await _handler.HandleAsync(CreateMessage("_reg AB12CDE")); + + _mockBroker.Verify(b => b.SendMessageAsync("Red Ford Focus (2019)", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_reg -?")); + + _mockBroker.Verify(b => b.SendMessageAsync( + It.Is(s => s.Contains("Reg Lookup")), "C1", null), Times.Once()); + } +} + +public class TestHandlerTests +{ + private readonly TestHandler _handler; + private readonly Mock _mockBroker = new(); + + public TestHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _handler = new TestHandler(_mockBroker.Object, options, NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text) => + new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_ValidEvent_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_test")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_SendsOkMessage() + { + await _handler.HandleAsync(CreateMessage("_test")); + + _mockBroker.Verify(b => b.SendMessageAsync("OK", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_test -?")); + + _mockBroker.Verify(b => b.SendMessageAsync( + It.Is(s => s.Contains("Test")), "C1", null), Times.Once()); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs b/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs new file mode 100644 index 0000000..5e4514e --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs @@ -0,0 +1,64 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet; +using SlackNet.WebApi; + +namespace Bottomly.Tests.Slack; + +public class MemberlistPopulatorTests +{ + private readonly Mock _mockSlack = new(); + private readonly Mock _mockUsers = new(); + private readonly Mock _mockRepo = new(); + private readonly MemberlistPopulator _populator; + + public MemberlistPopulatorTests() + { + _mockSlack.Setup(s => s.Users).Returns(_mockUsers.Object); + _populator = new MemberlistPopulator(_mockSlack.Object, _mockRepo.Object); + } + + [Fact] + public async Task PopulateMembers_AlreadySeeded_ReturnsEmptyAndSkipsSlack() + { + _mockRepo.Setup(r => r.GetByUsernameAsync("owen")) + .ReturnsAsync(new Member { Username = "owen" }); + + var result = await _populator.PopulateMembers(); + + result.ShouldBeEmpty(); + _mockUsers.Verify(u => u.List( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task PopulateMembers_NotSeeded_FetchesAndSavesMembers() + { + _mockRepo.Setup(r => r.GetByUsernameAsync("owen")).ReturnsAsync((Member?)null); + + var slackUsers = new UserListResponse + { + Members = + [ + new User { Id = "U1", Name = "alice", Deleted = false }, + new User { Id = "U2", Name = "bob", Deleted = false }, + new User { Id = "U3", Name = "deleted_user", Deleted = true } + ] + }; + _mockUsers.Setup(u => u.List( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(slackUsers); + _mockRepo.Setup(r => r.AddAsync(It.IsAny>())).Returns(Task.CompletedTask); + + var result = await _populator.PopulateMembers(); + + result.Count.ShouldBe(2); + result.ShouldContain(m => m.Username == "alice" && m.SlackId == "U1"); + result.ShouldContain(m => m.Username == "bob" && m.SlackId == "U2"); + _mockRepo.Verify(r => r.AddAsync(It.Is>(members => members.Count() == 2)), Times.Once()); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs new file mode 100644 index 0000000..8862698 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs @@ -0,0 +1,62 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack.MembershipEventHandlers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SlackNet; +using SlackNet.Events; +using SlackNet.WebApi; + +namespace Bottomly.Tests.Slack.MembershipEventHandlers; + +public class MemberJoinedEventHandlerTests +{ + private readonly Mock _mockRepo = new(); + private readonly Mock _mockSlack = new(); + private readonly Mock _mockUsers = new(); + private readonly MemberJoinedEventHandler _handler; + + public MemberJoinedEventHandlerTests() + { + _mockSlack.Setup(s => s.Users).Returns(_mockUsers.Object); + _handler = new MemberJoinedEventHandler( + _mockRepo.Object, + _mockSlack.Object, + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_WrongChannel_DoesNothing() + { + var ev = new MemberJoinedChannel { Channel = "#random", User = "U1" }; + + await _handler.ExecuteAsync(ev); + + _mockUsers.Verify(u => u.Info(It.IsAny()), Times.Never()); + _mockRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ExecuteAsync_NullMemberInfo_DoesNotSave() + { + var ev = new MemberJoinedChannel { Channel = "#general", User = "U1" }; + _mockUsers.Setup(u => u.Info("U1")).ReturnsAsync((User?)null); + + await _handler.ExecuteAsync(ev); + + _mockRepo.Verify(r => r.AddAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ExecuteAsync_ValidEvent_AddsNewMember() + { + var ev = new MemberJoinedChannel { Channel = "#general", User = "U1" }; + _mockUsers.Setup(u => u.Info("U1")).ReturnsAsync(new User { Id = "U1", Name = "alice" }); + _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _handler.ExecuteAsync(ev); + + _mockRepo.Verify(r => r.AddAsync(It.Is(m => + m.SlackId == "U1" && m.Username == "alice")), Times.Once()); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs new file mode 100644 index 0000000..daf8062 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -0,0 +1,113 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet; +using SlackNet.Events; +using SlackNet.WebApi; + +namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; + +public class ConversationMessageHandlerTests +{ + private readonly Mock _mockChatClient = new(); + private readonly Mock _mockSlackBroker = new(); + private readonly Mock _mockApiClient = new(); + private readonly Mock _mockConversations = new(); + private readonly Mock _mockMemberRepo = new(); + private readonly ConversationMessageHandler _handler; + + public ConversationMessageHandlerTests() + { + _mockApiClient.Setup(a => a.Conversations).Returns(_mockConversations.Object); + + var llmBroker = new LlmMessageBroker(_mockChatClient.Object, NullLogger.Instance); + _handler = new ConversationMessageHandler( + llmBroker, + _mockSlackBroker.Object, + _mockApiClient.Object, + _mockMemberRepo.Object); + } + + private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1") => + new() { Text = text, User = user, Channel = channel, Ts = "ts1" }; + + [Theory] + [InlineData("hey bottomly what do you think?")] + [InlineData("bottomly, help me")] + [InlineData("I asked bottomly already")] + public void CanHandle_MessageContainsBottomly_ReturnsTrue(string text) => + _handler.CanHandle(CreateMessage(text)).ShouldBeTrue(); + + [Theory] + [InlineData("hello there")] + [InlineData("_karma alice")] + [InlineData("")] + public void CanHandle_MessageWithoutBottomly_ReturnsFalse(string text) => + _handler.CanHandle(CreateMessage(text)).ShouldBeFalse(); + + [Fact] + public void BuildHelpMessage_ReturnsEmptyString() => + _handler.BuildHelpMessage().ShouldBeEmpty(); + + [Fact] + public async Task HandleAsync_SuccessfulLlmResponse_SendsReplyToChannel() + { + SetupConversationHistory("C1", []); + _mockMemberRepo.Setup(r => r.GetBySlackIdsAsync(It.IsAny>())) + .ReturnsAsync([new Member { SlackId = "U1", Username = "alice" }]); + + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Indeed, sir.")]); + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(chatResponse); + + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); + + _mockSlackBroker.Verify(b => b.SendMessageAsync("Indeed, sir.", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_BuildsContextFromHistory() + { + var historyMessages = new List + { + new() { User = "U1", Text = "first message", Ts = "1000.000" }, + new() { User = "U2", Text = "second message", Ts = "1001.000" } + }; + SetupConversationHistory("C1", historyMessages); + _mockMemberRepo.Setup(r => r.GetBySlackIdsAsync(It.IsAny>())) + .ReturnsAsync([ + new Member { SlackId = "U1", Username = "alice" }, + new Member { SlackId = "U2", Username = "bob" } + ]); + + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Of course.")]); + _mockChatClient + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(chatResponse); + + await _handler.HandleAsync(CreateMessage("bottomly something", "U1", "C1")); + + _mockChatClient.Verify(c => + c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Once()); + } + + private void SetupConversationHistory(string channel, List messages) + { + _mockConversations + .Setup(c => c.History(channel, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ConversationHistoryResponse { Messages = messages }); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs new file mode 100644 index 0000000..57d732d --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensionsTests.cs @@ -0,0 +1,52 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; + +public class MessageContextExtensionsTests +{ + [Fact] + public void CreateFromMember_MapsUsernameAndNote() + { + var member = new Member + { + Username = "alice", + FullName = "Alice Smith", + Gender = Gender.Female, + SassLevel = SassLevel.Moderate, + MiscInfo = "Loves gardening" + }; + + var note = BottomlyUserNote.CreateFromMember(member); + + note.Username.ShouldBe("alice"); + note.Note.ShouldContain("Alice Smith"); + } + + [Fact] + public void CreateFromSlackMessage_KnownUser_TranslatesUsername() + { + var message = new MessageEvent { User = "U123", Text = "hello there" }; + var memberLookup = new Dictionary { ["U123"] = "alice" }; + + var inputMessage = BottomlyInputMessage.CreateFromSlackMessage(message, memberLookup); + + inputMessage.Username.ShouldBe("alice"); + inputMessage.Text.ShouldBe("hello there"); + } + + [Fact] + public void CreateFromSlackMessage_UnknownUser_FallsBackToSlackId() + { + var message = new MessageEvent { User = "U_UNKNOWN", Text = "hey" }; + var memberLookup = new Dictionary(); + + var inputMessage = BottomlyInputMessage.CreateFromSlackMessage(message, memberLookup); + + inputMessage.Username.ShouldBe("U_UNKNOWN"); + inputMessage.Text.ShouldBe("hey"); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactoryTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactoryTests.cs new file mode 100644 index 0000000..0c2585e --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactoryTests.cs @@ -0,0 +1,50 @@ +using Bottomly.LlmBot; +using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; +using Microsoft.Extensions.AI; +using Shouldly; + +namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; + +public class ResponseMessageFactoryTests +{ + [Fact] + public void ToSlackResponse_LlmMessageResponse_ReturnsMessage() + { + var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Hello, sir.")]); + var response = chatResponse.ToSuccessResponse(); + + var result = response.ToSlackResponse(); + + result.ShouldBe("Hello, sir."); + } + + [Fact] + public void ToSlackResponse_LlmTimeoutResponse_ReturnsTimeoutMessage() + { + LlmResponse response = new LlmTimeoutResponse(); + + var result = response.ToSlackResponse(); + + result.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void ToSlackResponse_LlmUsageExceededResponse_ReturnsUsageMessage() + { + LlmResponse response = new LlmUsageExceededResponse(); + + var result = response.ToSlackResponse(); + + result.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public void ToSlackResponse_LlmUnknownErrorResponse_ReturnsFallbackMessage() + { + LlmResponse response = new LlmUnknownErrorResponse(); + + var result = response.ToSlackResponse(); + + result.ShouldNotBeNullOrEmpty(); + } +} diff --git a/bottomly.net/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs b/bottomly.net/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs new file mode 100644 index 0000000..b34988d --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs @@ -0,0 +1,114 @@ +using Bottomly.Configuration; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using SlackNet; +using SlackNet.WebApi; + +namespace Bottomly.Tests.Slack; + +public class SlackMessageBrokerTests +{ + private readonly Mock _mockRepo = new(); + private readonly Mock _mockSlack = new(); + private readonly Mock _mockChat = new(); + private readonly Mock _mockReactions = new(); + private readonly Mock _mockConversations = new(); + + private SlackMessageBroker CreateBroker(string environment = "live") => + new(_mockRepo.Object, _mockSlack.Object, + Options.Create(new BottomlyOptions { Environment = environment }), + NullLogger.Instance); + + public SlackMessageBrokerTests() + { + _mockSlack.Setup(s => s.Chat).Returns(_mockChat.Object); + _mockSlack.Setup(s => s.Reactions).Returns(_mockReactions.Object); + _mockSlack.Setup(s => s.Conversations).Returns(_mockConversations.Object); + _mockChat.Setup(c => c.PostMessage(It.IsAny())).ReturnsAsync(new PostMessageResponse()); + _mockReactions.Setup(r => r.AddToMessage(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + [Fact] + public async Task SendMessageAsync_EmptyText_DoesNotPost() + { + var broker = CreateBroker(); + + await broker.SendMessageAsync("", "C1"); + + _mockChat.Verify(c => c.PostMessage(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task SendMessageAsync_ValidText_PostsToChannel() + { + var broker = CreateBroker(); + + await broker.SendMessageAsync("Hello!", "C1"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.Text == "Hello!" && m.Channel == "C1")), Times.Once()); + } + + [Fact] + public async Task SendMessageAsync_WithReplyTs_SetsThreadTs() + { + var broker = CreateBroker(); + + await broker.SendMessageAsync("Reply", "C1", "ts123"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.ThreadTs == "ts123")), Times.Once()); + } + + [Fact] + public async Task SendMessageAsync_DebugMode_PrependsPrefixToText() + { + var broker = CreateBroker(environment: "Dev"); + + await broker.SendMessageAsync("Hello!", "C1"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.Text!.StartsWith("[Dev]"))), Times.Once()); + } + + [Fact] + public async Task SendReactionAsync_CallsSlackReactions() + { + var broker = CreateBroker(); + + await broker.SendReactionAsync("thumbsup", "C1", "ts123"); + + _mockReactions.Verify(r => r.AddToMessage("thumbsup", "C1", "ts123"), Times.Once()); + } + + [Fact] + public async Task SendDmAsync_EmptyText_DoesNotSend() + { + var broker = CreateBroker(); + + await broker.SendDmAsync("", "alice"); + + _mockRepo.Verify(r => r.GetByUsernameAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task SendDmAsync_ValidText_OpensConversationAndPosts() + { + _mockRepo.Setup(r => r.GetByUsernameAsync("alice")) + .ReturnsAsync(new Member { Username = "alice", SlackId = "U_ALICE" }); + _mockConversations.Setup(c => c.Open(It.IsAny>(), It.IsAny())) + .ReturnsAsync("D_CHANNEL"); + + var broker = CreateBroker(); + + await broker.SendDmAsync("Private message", "alice"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.Channel == "D_CHANNEL" && m.Text == "Private message")), Times.Once()); + } +} diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index 013d399..8be6f38 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -9,24 +9,31 @@ - - - - - - - - - - + + + + + + + + + + + - + - + + <_Parameter1>Bottomly.Tests + + + + + PreserveNewest diff --git a/bottomly.net/Bottomly/Commands/RegSearchCommand.cs b/bottomly.net/Bottomly/Commands/RegSearchCommand.cs index 9ba4597..008a40f 100644 --- a/bottomly.net/Bottomly/Commands/RegSearchCommand.cs +++ b/bottomly.net/Bottomly/Commands/RegSearchCommand.cs @@ -8,7 +8,7 @@ public class RegSearchCommand(IHttpClientFactory httpClientFactory) : ICommand public string GetPurpose() => "AutoTrader reg lookup, because Jamie is lazy."; - public async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) { diff --git a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs index c240bf9..527aaf4 100644 --- a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs +++ b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs @@ -5,7 +5,7 @@ namespace Bottomly.LlmBot; public class LlmMessageBroker(IChatClient chatClient, ILogger logger) { - public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) + public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) { var options = new ChatOptions { @@ -19,6 +19,46 @@ public async Task Respond(BottomlyInputMessage userPrompt, Message fullContext.HistoryContext.Text, fullContext.PromptingMessage.Text); - return await chatClient.GetResponseAsync(fullContext.ToArray(), options); + try + { + return (await chatClient.GetResponseAsync(fullContext.ToArray(), options)).ToSuccessResponse(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to get response from LLM"); + return ex.ToErrorResponse(); + } } +} + +public abstract record LlmResponse; + +public record LlmMessageResponse : LlmResponse +{ + private LlmMessageResponse(string message) => Message = message; + public string Message { get; } + + public static LlmResponse Create(ChatResponse chatResponse) => new LlmMessageResponse(chatResponse.Text); +} + +public record LlmTimeoutResponse : LlmResponse; + +public record LlmUsageExceededResponse : LlmResponse; + +public record LlmUnknownErrorResponse : LlmResponse; + +public static class LlmResponseExtensions +{ + public static LlmResponse ToSuccessResponse(this ChatResponse chatResponse) => + LlmMessageResponse.Create(chatResponse); + + public static LlmResponse ToErrorResponse(this Exception ex) => + ex switch + { + TimeoutException => new LlmTimeoutResponse(), + _ when ex.Message.Contains("usage") => new LlmUsageExceededResponse(), + _ => new LlmUnknownErrorResponse() + }; + + public static bool IsSuccess(this LlmResponse response) => response is LlmMessageResponse; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index f8936c2..685a54c 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -1,3 +1,4 @@ +using Bottomly.Seed; using System.Reflection; using Bottomly.Commands; using Bottomly.Configuration; @@ -113,12 +114,19 @@ // Seeding builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); var populator = app.Services.GetRequiredService(); await populator.PopulateMembers(); +if (app.Services.GetRequiredService().GetValue("ImportMemberSeedData")) +{ + var importer = app.Services.GetRequiredService(); + await importer.ImportAsync(); +} + app.Run(); diff --git a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs index c285fb5..a367107 100644 --- a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs @@ -9,4 +9,5 @@ public interface IMemberRepository Task> GetBySlackIdsAsync(IEnumerable slackIds); Task AddAsync(Member member); Task AddAsync(IEnumerable members); + Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, string miscInfo); } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/MemberRepository.cs b/bottomly.net/Bottomly/Repositories/MemberRepository.cs index 1fe8ba0..b6dca0f 100644 --- a/bottomly.net/Bottomly/Repositories/MemberRepository.cs +++ b/bottomly.net/Bottomly/Repositories/MemberRepository.cs @@ -18,4 +18,14 @@ public Task> GetBySlackIdsAsync(IEnumerable slackIds) => public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); public async Task AddAsync(IEnumerable members) => await _collection.InsertManyAsync(members); + + public async Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, string miscInfo) + { + var update = Builders.Update + .Set(m => m.FullName, fullName) + .Set(m => m.Gender, gender) + .Set(m => m.SassLevel, sassLevel) + .Set(m => m.MiscInfo, miscInfo); + await _collection.UpdateOneAsync(m => m.Username == username, update); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Seed/MemberSeedDataDto.cs b/bottomly.net/Bottomly/Seed/MemberSeedDataDto.cs new file mode 100644 index 0000000..35cc926 --- /dev/null +++ b/bottomly.net/Bottomly/Seed/MemberSeedDataDto.cs @@ -0,0 +1,11 @@ +namespace Bottomly.Seed; + +public class MemberSeedDataDto +{ + public string Username { get; set; } = string.Empty; + public string SlackId { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string Gender { get; set; } = "Unknown"; + public string SassLevel { get; set; } = "Moderate"; + public string MiscInfo { get; set; } = string.Empty; +} diff --git a/bottomly.net/Bottomly/Seed/MemberSeedDataImporter.cs b/bottomly.net/Bottomly/Seed/MemberSeedDataImporter.cs new file mode 100644 index 0000000..d25f1db --- /dev/null +++ b/bottomly.net/Bottomly/Seed/MemberSeedDataImporter.cs @@ -0,0 +1,85 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Bottomly.Seed; + +public class MemberSeedDataImporter( + IMemberRepository memberRepository, + IHostEnvironment env, + ILogger logger) +{ + private readonly IDeserializer _deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + public async Task ImportAsync() + { + var seedDir = ResolveSeedDir(); + if (!Directory.Exists(seedDir)) + { + logger.LogWarning("MemberSeedData directory not found at {Path}. Skipping seed data import.", seedDir); + return; + } + + var files = Directory.GetFiles(seedDir, "*.yaml"); + logger.LogInformation("Importing seed data from {Count} YAML files in {Path}", files.Length, seedDir); + + foreach (var file in files) + await ImportFileAsync(file); + + logger.LogInformation("Seed data import complete."); + } + + private string ResolveSeedDir() + { + var dir = new DirectoryInfo(env.ContentRootPath); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "MemberSeedData"); + if (Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + return Path.Combine(env.ContentRootPath, "MemberSeedData"); + } + + private async Task ImportFileAsync(string file) + { + try + { + var yaml = await File.ReadAllTextAsync(file); + var dto = _deserializer.Deserialize(yaml); + + if (!Enum.TryParse(dto.Gender, ignoreCase: true, out var gender)) + { + logger.LogWarning("Unknown gender value '{Value}' in {File}. Defaulting to Unknown.", dto.Gender, Path.GetFileName(file)); + gender = Gender.Unknown; + } + + if (!Enum.TryParse(dto.SassLevel, ignoreCase: true, out var sassLevel)) + { + logger.LogWarning("Unknown sass_level value '{Value}' in {File}. Defaulting to Moderate.", dto.SassLevel, Path.GetFileName(file)); + sassLevel = SassLevel.Moderate; + } + + var member = await memberRepository.GetByUsernameAsync(dto.Username); + if (member is null) + { + logger.LogWarning("Member '{Username}' not found in DB. Skipping.", dto.Username); + return; + } + + await memberRepository.UpdateInfoAsync(dto.Username, dto.FullName, gender, sassLevel, dto.MiscInfo); + logger.LogInformation("Updated member '{Username}'.", dto.Username); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to import seed data from {File}.", Path.GetFileName(file)); + } + } +} diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs similarity index 58% rename from bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs rename to bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs index ede7f97..461655d 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -1,10 +1,9 @@ using Bottomly.LlmBot; -using Bottomly.Models; using Bottomly.Repositories; using SlackNet; using SlackNet.Events; -namespace Bottomly.Slack.MessageEventHandlers; +namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandler( LlmMessageBroker llmMessageBroker, @@ -34,31 +33,10 @@ public async Task HandleAsync(MessageEvent message) var context = MessageHistoryContext.Create(contextMessages, userNotes); - var llmResponse = await llmMessageBroker.Respond(mainPrompt, context); - var response = llmResponse.Text; + var response = await llmMessageBroker.Respond(mainPrompt, context); - - await slackBroker.SendMessageAsync(response, message.Channel); + await slackBroker.SendMessageAsync(response.ToSlackResponse(), message.Channel); } public string BuildHelpMessage() => string.Empty; -} - -internal static class MessageContextExtensions -{ - extension(BottomlyUserNote bottomlyUserNote) - { - public static BottomlyUserNote CreateFromMember(Member member) => - BottomlyUserNote.Create(member.Username, member.Note); - } - - extension(BottomlyInputMessage bottomlyInputMessage) - { - public static BottomlyInputMessage CreateFromSlackMessage(MessageEvent message, - IDictionary memberLookup) - { - var translatedUserName = memberLookup.TryGetValue(message.User, out var username) ? username : message.User; - return BottomlyInputMessage.Create(translatedUserName, message.Text); - } - } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs new file mode 100644 index 0000000..628aed2 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/MessageContextExtensions.cs @@ -0,0 +1,24 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; + +internal static class MessageContextExtensions +{ + extension(BottomlyUserNote bottomlyUserNote) + { + public static BottomlyUserNote CreateFromMember(Member member) => + BottomlyUserNote.Create(member.Username, member.Note); + } + + extension(BottomlyInputMessage bottomlyInputMessage) + { + public static BottomlyInputMessage CreateFromSlackMessage(MessageEvent message, + IDictionary memberLookup) + { + var translatedUserName = memberLookup.TryGetValue(message.User, out var username) ? username : message.User; + return BottomlyInputMessage.Create(translatedUserName, message.Text); + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs new file mode 100644 index 0000000..f300c20 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs @@ -0,0 +1,36 @@ +using Bottomly.LlmBot; + +namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; + +public static class ResponseMessageFactory +{ + private static readonly string[] TimeoutMessages = + [ + "I must apologise, in seeking an answer I appear to have taken rather too long. Perhaps another attempt?", + "I'm afraid I've been away for a while, but I may be able to assist now. Please try again." + ]; + + private static readonly string[] UsageExceededMessages = + ["Sorry, I've reached my limit for today. Please try again tomorrow."]; + + + private static readonly string[] UnknownErrorMessage = + ["Sorry, I'm having trouble understanding your request. Please try again."]; + + public static string ToSlackResponse(this LlmResponse llmResponse) => + llmResponse switch + { + LlmMessageResponse success => success.Message, + LlmTimeoutResponse => ToTimeoutMessage(), + LlmUsageExceededResponse => ToUsageExceededMessage(), + _ => ToUnknownErrorMessage() + }; + + private static string ToTimeoutMessage() => TimeoutMessages.Shuffle().First(); + + private static string ToUsageExceededMessage() => UsageExceededMessages.Shuffle().First(); + + private static string ToUnknownErrorMessage() => UnknownErrorMessage.Shuffle().First(); + + private static T[] Shuffle(this T[] array) => array.OrderBy(_ => Random.Shared.Next()).ToArray(); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/appsettings.json b/bottomly.net/Bottomly/appsettings.json index 400dff5..ea4dd90 100644 --- a/bottomly.net/Bottomly/appsettings.json +++ b/bottomly.net/Bottomly/appsettings.json @@ -6,5 +6,6 @@ "Microsoft": "Information" } }, - "bottomly_env": "debug" + "bottomly_env": "debug", + "ImportMemberSeedData": true } \ No newline at end of file From c8b38266dd40f783e1f9c819a9f8256b47394593 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 16 Mar 2026 16:57:51 +0000 Subject: [PATCH 18/24] SAVEPOINT --- .../Repositories/KarmaRepositoryTests.cs | 193 ++++++++++++++++++ .../Repositories/MemberRepositoryTests.cs | 158 ++++++++++++++ bottomly.net/Bottomly/Slack/SlackWorker.cs | 1 - 3 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs diff --git a/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs b/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs new file mode 100644 index 0000000..f6e1977 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Repositories/KarmaRepositoryTests.cs @@ -0,0 +1,193 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using MongoDB.Bson; +using MongoDB.Driver; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Repositories; + +public class KarmaRepositoryTests +{ + private readonly Mock> _mockCollection = new(); + private readonly Mock _mockDatabase = new(); + private readonly KarmaRepository _repository; + + public KarmaRepositoryTests() + { + _mockDatabase + .Setup(d => d.GetCollection("karma", It.IsAny())) + .Returns(_mockCollection.Object); + _repository = new KarmaRepository(_mockDatabase.Object); + } + + private static Mock> CreateBsonCursor(IEnumerable docs) + { + var cursor = new Mock>(); + cursor.Setup(c => c.Current).Returns(docs.ToList()); + cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny())) + .ReturnsAsync(true) + .ReturnsAsync(false); + return cursor; + } + + private void SetupAggregate(IEnumerable results) + { + var cursor = CreateBsonCursor(results); + _mockCollection + .Setup(c => c.Aggregate( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(cursor.Object); + } + + [Fact] + public async Task AddAsync_CallsInsertOne() + { + var karma = new Karma { AwardedToUsername = "alice", AwardedByUsername = "bob", KarmaType = KarmaType.PozzyPoz }; + _mockCollection + .Setup(c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _repository.AddAsync(karma); + + _mockCollection.Verify( + c => c.InsertOneAsync(karma, It.IsAny(), It.IsAny()), + Times.Once()); + } + + [Fact] + public async Task GetCurrentNetKarmaAsync_WithResults_ReturnsNetKarma() + { + var doc = new BsonDocument { { "_id", "alice" }, { "net_karma", 5 } }; + SetupAggregate([doc]); + + var result = await _repository.GetCurrentNetKarmaAsync("Alice"); + + result.ShouldBe(5); + } + + [Fact] + public async Task GetCurrentNetKarmaAsync_NoResults_ReturnsZero() + { + SetupAggregate([]); + + var result = await _repository.GetCurrentNetKarmaAsync("nobody"); + + result.ShouldBe(0); + } + + [Fact] + public async Task GetCurrentNetKarmaAsync_LowercasesRecipient() + { + SetupAggregate([]); + + // Should not throw — lowercase conversion is internal + await _repository.GetCurrentNetKarmaAsync("ALICE"); + } + + [Fact] + public async Task GetLeaderBoardAsync_ReturnsTopScorers() + { + var docs = new[] + { + new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } }, + new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } }, + new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } } + }; + SetupAggregate(docs); + + var result = await _repository.GetLeaderBoardAsync(3); + + result.Count.ShouldBe(3); + result[0].Username.ShouldBe("alice"); + result[0].NetKarma.ShouldBe(10); + result[1].Username.ShouldBe("bob"); + } + + [Fact] + public async Task GetLeaderBoardAsync_LimitsResults() + { + var docs = new[] + { + new BsonDocument { { "_id", "alice" }, { "net_karma", 10 } }, + new BsonDocument { { "_id", "bob" }, { "net_karma", 7 } }, + new BsonDocument { { "_id", "carol" }, { "net_karma", 4 } }, + new BsonDocument { { "_id", "dave" }, { "net_karma", 2 } } + }; + SetupAggregate(docs); + + var result = await _repository.GetLeaderBoardAsync(2); + + result.Count.ShouldBe(2); + } + + [Fact] + public async Task GetLoserBoardAsync_ReturnsLowestScorers() + { + var docs = new[] + { + new BsonDocument { { "_id", "dave" }, { "net_karma", -5 } }, + new BsonDocument { { "_id", "eve" }, { "net_karma", -3 } } + }; + SetupAggregate(docs); + + var result = await _repository.GetLoserBoardAsync(2); + + result.Count.ShouldBe(2); + result[0].Username.ShouldBe("dave"); + result[0].NetKarma.ShouldBe(-5); + } + + [Fact] + public async Task GetKarmaReasonsAsync_SeparatesReasonedAndReasonless() + { + var docs = new[] + { + new BsonDocument + { + { "awarded_to_username", "alice" }, + { "awarded_by_username", "bob" }, + { "karma_type", "PozzyPoz" }, + { "awarded", BsonDateTime.Create(DateTime.UtcNow) }, + { "reason", "great work" } + }, + new BsonDocument + { + { "awarded_to_username", "alice" }, + { "awarded_by_username", "carol" }, + { "karma_type", "PozzyPoz" }, + { "awarded", BsonDateTime.Create(DateTime.UtcNow) }, + { "reason", "" } + }, + new BsonDocument + { + { "awarded_to_username", "alice" }, + { "awarded_by_username", "dave" }, + { "karma_type", "NeggyNeg" }, + { "awarded", BsonDateTime.Create(DateTime.UtcNow) } + // no reason field + } + }; + SetupAggregate(docs); + + var result = await _repository.GetKarmaReasonsAsync("Alice"); + + result.Reasoned.Count.ShouldBe(1); + result.Reasoned[0].AwardedByUsername.ShouldBe("bob"); + result.Reasoned[0].Reason.ShouldBe("great work"); + result.Reasonless.ShouldBe(2); + } + + [Fact] + public async Task GetKarmaReasonsAsync_EmptyResults_ReturnsEmpty() + { + SetupAggregate([]); + + var result = await _repository.GetKarmaReasonsAsync("nobody"); + + result.Reasoned.ShouldBeEmpty(); + result.Reasonless.ShouldBe(0); + } +} diff --git a/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs b/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs new file mode 100644 index 0000000..bcf2723 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Repositories/MemberRepositoryTests.cs @@ -0,0 +1,158 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using MongoDB.Driver; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Repositories; + +public class MemberRepositoryTests +{ + private readonly Mock> _mockCollection = new(); + private readonly Mock _mockDatabase = new(); + private readonly MemberRepository _repository; + + public MemberRepositoryTests() + { + _mockDatabase + .Setup(d => d.GetCollection("member", It.IsAny())) + .Returns(_mockCollection.Object); + _repository = new MemberRepository(_mockDatabase.Object); + } + + private Mock> CreateCursor(IEnumerable items) + { + var cursor = new Mock>(); + cursor.Setup(c => c.Current).Returns(items.ToList()); + cursor.SetupSequence(c => c.MoveNextAsync(It.IsAny())) + .ReturnsAsync(true) + .ReturnsAsync(false); + return cursor; + } + + private void SetupFind(IEnumerable results) + { + var cursor = CreateCursor(results); + _mockCollection + .Setup(c => c.FindAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(cursor.Object); + } + + [Fact] + public async Task GetByUsernameAsync_WhenFound_ReturnsMember() + { + var member = new Member { Username = "alice", SlackId = "U1" }; + SetupFind([member]); + + var result = await _repository.GetByUsernameAsync("alice"); + + result.ShouldNotBeNull(); + result!.Username.ShouldBe("alice"); + } + + [Fact] + public async Task GetByUsernameAsync_WhenNotFound_ReturnsNull() + { + SetupFind([]); + + var result = await _repository.GetByUsernameAsync("nobody"); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetBySlackIdAsync_WhenFound_ReturnsMember() + { + var member = new Member { Username = "bob", SlackId = "U2" }; + SetupFind([member]); + + var result = await _repository.GetBySlackIdAsync("U2"); + + result.ShouldNotBeNull(); + result!.SlackId.ShouldBe("U2"); + } + + [Fact] + public async Task GetBySlackIdAsync_WhenNotFound_ReturnsNull() + { + SetupFind([]); + + var result = await _repository.GetBySlackIdAsync("U_UNKNOWN"); + + result.ShouldBeNull(); + } + + [Fact] + public async Task GetBySlackIdsAsync_ReturnsMatchingMembers() + { + var members = new List + { + new() { Username = "alice", SlackId = "U1" }, + new() { Username = "bob", SlackId = "U2" } + }; + SetupFind(members); + + var result = await _repository.GetBySlackIdsAsync(["U1", "U2"]); + + result.Count.ShouldBe(2); + } + + [Fact] + public async Task AddAsync_SingleMember_CallsInsertOne() + { + var member = new Member { Username = "carol", SlackId = "U3" }; + _mockCollection + .Setup(c => c.InsertOneAsync(member, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _repository.AddAsync(member); + + _mockCollection.Verify( + c => c.InsertOneAsync(member, It.IsAny(), It.IsAny()), + Times.Once()); + } + + [Fact] + public async Task AddAsync_MultipleMembers_CallsInsertMany() + { + var members = new List + { + new() { Username = "alice" }, + new() { Username = "bob" } + }; + _mockCollection + .Setup(c => c.InsertManyAsync(members, It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _repository.AddAsync(members); + + _mockCollection.Verify( + c => c.InsertManyAsync(members, It.IsAny(), It.IsAny()), + Times.Once()); + } + + [Fact] + public async Task UpdateInfoAsync_CallsUpdateOne() + { + _mockCollection + .Setup(c => c.UpdateOneAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new UpdateResult.Acknowledged(1, 1, null)); + + await _repository.UpdateInfoAsync("alice", "Alice Smith", Gender.Female, SassLevel.Moderate, "Likes tea"); + + _mockCollection.Verify( + c => c.UpdateOneAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Once()); + } +} diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs index cafc0e9..444d4b1 100644 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -16,7 +16,6 @@ public class SlackWorker( HelpHandler helpMessageHandler, IEnumerable reactionHandlers, IMemberRepository memberRepository, - LlmMessageBroker llmMessageBroker, ILogger logger) : BackgroundService { From 65c3ae88b3983de6ee12caab6a48cb8899813f11 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 16 Mar 2026 17:04:35 +0000 Subject: [PATCH 19/24] SAVEPOINT --- .../Bottomly.Tests/Slack/SlackWorkerTests.cs | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 bottomly.net/Bottomly.Tests/Slack/SlackWorkerTests.cs diff --git a/bottomly.net/Bottomly.Tests/Slack/SlackWorkerTests.cs b/bottomly.net/Bottomly.Tests/Slack/SlackWorkerTests.cs new file mode 100644 index 0000000..0d33bf7 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/SlackWorkerTests.cs @@ -0,0 +1,307 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet; +using SlackNet.Events; +using SlackNet.SocketMode; + +namespace Bottomly.Tests.Slack; + +public class SlackWorkerTests +{ + private readonly Mock _mockSocket = new(); + private readonly Mock _mockBroker = new(); + private readonly Mock _mockMemberRepo = new(); + + private SlackWorker CreateWorker( + IEnumerable? handlers = null, + IEnumerable? reactionHandlers = null) + { + var options = TestHelpers.CreateOptions(); + var helpHandler = new HelpHandler( + handlers ?? [], + _mockBroker.Object, + options, + NullLogger.Instance); + + return new SlackWorker( + _mockSocket.Object, + handlers ?? [], + helpHandler, + reactionHandlers ?? [], + _mockMemberRepo.Object, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U1", string? botId = null) => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1", BotId = botId }; + + // ── ExecuteAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task ExecuteAsync_ConnectsToSocketClient() + { + var connectCalled = new TaskCompletionSource(); + _mockSocket + .Setup(s => s.Connect(It.IsAny(), It.IsAny())) + .Callback(() => connectCalled.TrySetResult()) + .Returns(Task.CompletedTask); + + var worker = CreateWorker(); + await worker.StartAsync(CancellationToken.None); + + await connectCalled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + _mockSocket.Verify( + s => s.Connect(It.IsAny(), It.IsAny()), + Times.Once()); + + await worker.StopAsync(CancellationToken.None); + } + + // ── ProcessMessageAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task ProcessMessageAsync_EmptyText_DoesNotInvokeHandlers() + { + var mockHandler = new Mock(); + var worker = CreateWorker([mockHandler.Object]); + + await worker.ProcessMessageAsync(CreateMessage("")); + + mockHandler.Verify(h => h.CanHandle(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessMessageAsync_BotMessage_DoesNotInvokeHandlers() + { + var mockHandler = new Mock(); + var worker = CreateWorker([mockHandler.Object]); + + await worker.ProcessMessageAsync(CreateMessage("hello", botId: "B1")); + + mockHandler.Verify(h => h.CanHandle(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessMessageAsync_MatchingHandler_InvokesHandler() + { + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler.Setup(h => h.HandleAsync(It.IsAny())).Returns(Task.CompletedTask); + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync(It.IsAny())).ReturnsAsync((Member?)null); + + var worker = CreateWorker([mockHandler.Object]); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats")); + + mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ProcessMessageAsync_StopsAtFirstMatchingHandler() + { + var mockHandler1 = new Mock(); + mockHandler1.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler1.Setup(h => h.HandleAsync(It.IsAny())).Returns(Task.CompletedTask); + + var mockHandler2 = new Mock(); + mockHandler2.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync(It.IsAny())).ReturnsAsync((Member?)null); + + var worker = CreateWorker([mockHandler1.Object, mockHandler2.Object]); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats")); + + mockHandler1.Verify(h => h.HandleAsync(It.IsAny()), Times.Once()); + mockHandler2.Verify(h => h.HandleAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessMessageAsync_NoMatchingHandler_DoesNotThrow() + { + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(false); + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync(It.IsAny())).ReturnsAsync((Member?)null); + + var worker = CreateWorker([mockHandler.Object]); + + // Should complete without throwing + await worker.ProcessMessageAsync(CreateMessage("unrecognised command")); + + mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessMessageAsync_HelpMessage_RoutesToHelpHandler() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync(It.IsAny())).ReturnsAsync((Member?)null); + _mockBroker.Setup(b => b.SendDmAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(false); + mockHandler.Setup(h => h.BuildHelpMessage()).Returns("some help text"); + + var worker = CreateWorker([mockHandler.Object]); + await worker.ProcessMessageAsync(CreateMessage("_help", user: "U1")); + + _mockBroker.Verify(b => b.SendDmAsync(It.IsAny(), "U1"), Times.Once()); + mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessMessageAsync_ResolvesUsernameFromSlackId() + { + var member = new Member { Username = "alice", SlackId = "U1" }; + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U1")).ReturnsAsync(member); + + string? capturedUser = null; + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler + .Setup(h => h.HandleAsync(It.IsAny())) + .Callback(m => capturedUser = m.User) + .Returns(Task.CompletedTask); + + var worker = CreateWorker([mockHandler.Object]); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats", user: "U1")); + + capturedUser.ShouldBe("alice"); + } + + [Fact] + public async Task ProcessMessageAsync_UnknownSlackId_LeavesUserUnchanged() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U_UNKNOWN")).ReturnsAsync((Member?)null); + + string? capturedUser = null; + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler + .Setup(h => h.HandleAsync(It.IsAny())) + .Callback(m => capturedUser = m.User) + .Returns(Task.CompletedTask); + + var worker = CreateWorker([mockHandler.Object]); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats", user: "U_UNKNOWN")); + + capturedUser.ShouldBe("U_UNKNOWN"); + } + + [Fact] + public async Task ProcessMessageAsync_HandlerThrows_DoesNotPropagate() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync(It.IsAny())).ReturnsAsync((Member?)null); + + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler.Setup(h => h.HandleAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var worker = CreateWorker([mockHandler.Object]); + + // Should not throw + await worker.ProcessMessageAsync(CreateMessage("_wiki cats")); + } + + // ── ProcessReactionAsync ────────────────────────────────────────────────── + + [Fact] + public async Task ProcessReactionAsync_MatchingHandler_InvokesHandler() + { + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler.Setup(h => h.HandleAsync(It.IsAny())).Returns(Task.CompletedTask); + + var worker = CreateWorker(reactionHandlers: [mockHandler.Object]); + await worker.ProcessReactionAsync(new ReactionAdded { Reaction = "joy" }); + + mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ProcessReactionAsync_NoMatchingHandler_NoHandlerInvoked() + { + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(false); + + var worker = CreateWorker(reactionHandlers: [mockHandler.Object]); + await worker.ProcessReactionAsync(new ReactionAdded { Reaction = "robot_face" }); + + mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Never()); + } + + [Fact] + public async Task ProcessReactionAsync_MultipleMatchingHandlers_AllInvoked() + { + var mockHandler1 = new Mock(); + mockHandler1.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler1.Setup(h => h.HandleAsync(It.IsAny())).Returns(Task.CompletedTask); + + var mockHandler2 = new Mock(); + mockHandler2.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler2.Setup(h => h.HandleAsync(It.IsAny())).Returns(Task.CompletedTask); + + var worker = CreateWorker(reactionHandlers: [mockHandler1.Object, mockHandler2.Object]); + await worker.ProcessReactionAsync(new ReactionAdded { Reaction = "joy" }); + + mockHandler1.Verify(h => h.HandleAsync(It.IsAny()), Times.Once()); + mockHandler2.Verify(h => h.HandleAsync(It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ProcessReactionAsync_HandlerThrows_DoesNotPropagate() + { + var mockHandler = new Mock(); + mockHandler.Setup(h => h.CanHandle(It.IsAny())).Returns(true); + mockHandler.Setup(h => h.HandleAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var worker = CreateWorker(reactionHandlers: [mockHandler.Object]); + + // Should not throw + await worker.ProcessReactionAsync(new ReactionAdded { Reaction = "joy" }); + } +} + +public class SlackEventDispatcherTests +{ + [Fact] + public async Task SlackMessageEventDispatcher_DelegatesToWorker() + { + var mockSocket = new Mock(); + var mockBroker = new Mock(); + var mockRepo = new Mock(); + var options = TestHelpers.CreateOptions(); + var helpHandler = new HelpHandler([], mockBroker.Object, options, NullLogger.Instance); + var worker = new SlackWorker(mockSocket.Object, [], helpHandler, [], + mockRepo.Object, NullLogger.Instance); + + var dispatcher = new SlackMessageEventDispatcher(worker); + var message = new MessageEvent { Text = "", User = "U1", Channel = "C1" }; + + // Empty message is a no-op — just verify it doesn't throw + await dispatcher.Handle(message); + } + + [Fact] + public async Task SlackReactionEventDispatcher_DelegatesToWorker() + { + var mockSocket = new Mock(); + var mockBroker = new Mock(); + var mockRepo = new Mock(); + var options = TestHelpers.CreateOptions(); + var helpHandler = new HelpHandler([], mockBroker.Object, options, NullLogger.Instance); + var worker = new SlackWorker(mockSocket.Object, [], helpHandler, [], + mockRepo.Object, NullLogger.Instance); + + var dispatcher = new SlackReactionEventDispatcher(worker); + await dispatcher.Handle(new ReactionAdded { Reaction = "joy" }); + } +} From f422891ee7762af84b4aba5b8cbac689096470a4 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 17 Mar 2026 08:54:10 +0000 Subject: [PATCH 20/24] SAVEPOINT --- .../Bottomly.Tests/LlmBot/LlmBotTests.cs | 16 ++-- .../ConversationMessageHandlerTests.cs | 87 ++++++++++++------- .../Bottomly/LlmBot/LlmMessageBroker.cs | 10 ++- bottomly.net/Bottomly/Program.cs | 2 +- .../AbstractMessageEventHandler.cs | 17 ++-- .../ConversationMessageHandler.cs | 6 +- .../ResponseMessageFactory.cs | 36 +++++++- 7 files changed, 112 insertions(+), 62 deletions(-) diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs index fd04147..f0e06a3 100644 --- a/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs @@ -102,7 +102,7 @@ public void IsSuccess_LlmMessageResponse_ReturnsTrue() var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "ok")]); var response = chatResponse.ToSuccessResponse(); - response.IsSuccess().ShouldBeTrue(); + response.IsError().ShouldBeFalse(); } [Fact] @@ -110,7 +110,7 @@ public void IsSuccess_LlmTimeoutResponse_ReturnsFalse() { LlmResponse response = new LlmTimeoutResponse(); - response.IsSuccess().ShouldBeFalse(); + response.IsError().ShouldBeTrue(); } } @@ -180,16 +180,10 @@ public void ToArray_ContainsThreeMessages() } [Fact] - public void SystemPrompt_HasSystemRole() - { - FullPromptContext.SystemPrompt.Role.ShouldBe(ChatRole.System); - } + public void SystemPrompt_HasSystemRole() => FullPromptContext.SystemPrompt.Role.ShouldBe(ChatRole.System); [Fact] - public void SystemPrompt_MentionsBottomly() - { - FullPromptContext.SystemPrompt.Text.ShouldContain("Bottomly"); - } + public void SystemPrompt_MentionsBottomly() => FullPromptContext.SystemPrompt.Text.ShouldContain("Bottomly"); } public class MemberNoteTests @@ -210,4 +204,4 @@ public void Note_ContainsAllMemberInfo() member.Note.ShouldContain("Frequent"); member.Note.ShouldContain("Drinks tea"); } -} +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs index daf8062..7a654fe 100644 --- a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -3,8 +3,6 @@ using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; using SlackNet; @@ -15,7 +13,7 @@ namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandlerTests { - private readonly Mock _mockChatClient = new(); + private readonly Mock _mockLlmBroker = new(); private readonly Mock _mockSlackBroker = new(); private readonly Mock _mockApiClient = new(); private readonly Mock _mockConversations = new(); @@ -26,16 +24,15 @@ public ConversationMessageHandlerTests() { _mockApiClient.Setup(a => a.Conversations).Returns(_mockConversations.Object); - var llmBroker = new LlmMessageBroker(_mockChatClient.Object, NullLogger.Instance); _handler = new ConversationMessageHandler( - llmBroker, + _mockLlmBroker.Object, _mockSlackBroker.Object, _mockApiClient.Object, _mockMemberRepo.Object); } - private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1") => - new() { Text = text, User = user, Channel = channel, Ts = "ts1" }; + private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1", string? threadTs = null) => + new() { Text = text, User = user, Channel = channel, Ts = "ts1", ThreadTs = threadTs }; [Theory] [InlineData("hey bottomly what do you think?")] @@ -59,14 +56,9 @@ public void BuildHelpMessage_ReturnsEmptyString() => public async Task HandleAsync_SuccessfulLlmResponse_SendsReplyToChannel() { SetupConversationHistory("C1", []); - _mockMemberRepo.Setup(r => r.GetBySlackIdsAsync(It.IsAny>())) - .ReturnsAsync([new Member { SlackId = "U1", Username = "alice" }]); - - var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Indeed, sir.")]); - _mockChatClient - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(chatResponse); + SetupMembers([new Member { SlackId = "U1", Username = "alice" }]); + _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) + .ReturnsAsync(LlmMessageResponse.Create("Indeed, sir.")); await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); @@ -82,32 +74,61 @@ public async Task HandleAsync_BuildsContextFromHistory() new() { User = "U2", Text = "second message", Ts = "1001.000" } }; SetupConversationHistory("C1", historyMessages); - _mockMemberRepo.Setup(r => r.GetBySlackIdsAsync(It.IsAny>())) - .ReturnsAsync([ - new Member { SlackId = "U1", Username = "alice" }, - new Member { SlackId = "U2", Username = "bob" } - ]); - - var chatResponse = new ChatResponse([new ChatMessage(ChatRole.Assistant, "Of course.")]); - _mockChatClient - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), - It.IsAny())) - .ReturnsAsync(chatResponse); + SetupMembers([ + new Member { SlackId = "U1", Username = "alice" }, + new Member { SlackId = "U2", Username = "bob" } + ]); + _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) + .ReturnsAsync(LlmMessageResponse.Create("Of course.")); await _handler.HandleAsync(CreateMessage("bottomly something", "U1", "C1")); - _mockChatClient.Verify(c => - c.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), Times.Once()); + _mockLlmBroker.Verify(b => b.Respond(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData(nameof(LlmTimeoutResponse))] + [InlineData(nameof(LlmUsageExceededResponse))] + [InlineData(nameof(LlmUnknownErrorResponse))] + public async Task HandleAsync_ErrorLlmResponse_SendsReplyToMessage(string responseType) + { + SetupConversationHistory("C1", []); + SetupMembers([new Member { SlackId = "U1", Username = "alice" }]); + LlmResponse errorResponse = responseType switch + { + nameof(LlmTimeoutResponse) => new LlmTimeoutResponse(), + nameof(LlmUsageExceededResponse) => new LlmUsageExceededResponse(), + _ => new LlmUnknownErrorResponse() + }; + _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) + .ReturnsAsync(errorResponse); + + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); + + _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "ts1"), Times.Once()); } - private void SetupConversationHistory(string channel, List messages) + [Fact] + public async Task HandleAsync_ErrorLlmResponseInThread_SendsReplyToThread() { + SetupConversationHistory("C1", []); + SetupMembers([new Member { SlackId = "U1", Username = "alice" }]); + _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LlmTimeoutResponse()); + + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1", threadTs: "thread_ts1")); + + _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "thread_ts1"), Times.Once()); + } + + private void SetupConversationHistory(string channel, List messages) => _mockConversations .Setup(c => c.History(channel, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ConversationHistoryResponse { Messages = messages }); - } + + private void SetupMembers(List members) => + _mockMemberRepo.Setup(r => r.GetBySlackIdsAsync(It.IsAny>())) + .ReturnsAsync(members); } + diff --git a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs index 527aaf4..1b21da7 100644 --- a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs +++ b/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs @@ -3,7 +3,12 @@ namespace Bottomly.LlmBot; -public class LlmMessageBroker(IChatClient chatClient, ILogger logger) +public interface ILlmMessageBroker +{ + Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext); +} + +public class LlmMessageBroker(IChatClient chatClient, ILogger logger) : ILlmMessageBroker { public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) { @@ -38,6 +43,7 @@ public record LlmMessageResponse : LlmResponse private LlmMessageResponse(string message) => Message = message; public string Message { get; } + public static LlmResponse Create(string message) => new LlmMessageResponse(message); public static LlmResponse Create(ChatResponse chatResponse) => new LlmMessageResponse(chatResponse.Text); } @@ -60,5 +66,5 @@ public static LlmResponse ToErrorResponse(this Exception ex) => _ => new LlmUnknownErrorResponse() }; - public static bool IsSuccess(this LlmResponse response) => response is LlmMessageResponse; + public static bool IsError(this LlmResponse response) => response is not LlmMessageResponse; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 685a54c..76d916e 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -110,7 +110,7 @@ options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); }); -builder.Services.AddTransient(); +builder.Services.AddTransient(); // Seeding builder.Services.AddSingleton(); diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index 9262c85..30ffd15 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -68,16 +68,7 @@ private async Task HandleHelpEventAsync(MessageEvent message) => protected async Task SendMessageResponseAsync(string text, MessageEvent message, bool asReply = false) { - string? replyTs = null; - if (asReply) - { - replyTs = message.Ts; - } - - if (message.ThreadTs is not null) - { - replyTs = message.ThreadTs; - } + var replyTs = asReply ? message.TsForReply() : null; await Broker.SendMessageAsync(text, message.Channel, replyTs); } @@ -87,4 +78,10 @@ protected Task SendReactionResponseAsync(MessageEvent message) => protected Task SendDmResponseAsync(string text, MessageEvent message) => Broker.SendDmAsync(text, message.User); +} + +public static class MessageEventExtensions +{ + public static string TsForReply(this MessageEvent message) => + message.ThreadTs ?? message.Ts; } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs index 461655d..95d31ff 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -6,7 +6,7 @@ namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandler( - LlmMessageBroker llmMessageBroker, + ILlmMessageBroker llmMessageBroker, ISlackMessageBroker slackBroker, ISlackApiClient apiClient, IMemberRepository memberRepository @@ -35,7 +35,9 @@ public async Task HandleAsync(MessageEvent message) var response = await llmMessageBroker.Respond(mainPrompt, context); - await slackBroker.SendMessageAsync(response.ToSlackResponse(), message.Channel); + var replyToTs = response.IsError() ? message.TsForReply() : null; + + await slackBroker.SendMessageAsync(response.ToSlackResponse(), message.Channel, replyToTs); } public string BuildHelpMessage() => string.Empty; diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs index f300c20..5f7ea55 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs @@ -7,15 +7,45 @@ public static class ResponseMessageFactory private static readonly string[] TimeoutMessages = [ "I must apologise, in seeking an answer I appear to have taken rather too long. Perhaps another attempt?", - "I'm afraid I've been away for a while, but I may be able to assist now. Please try again." + "I'm afraid I've been away for a while, but I may be able to assist now. Please try again.", + "I find myself in the regrettable position of having exceeded the allotted time. If you would be so good as to try once more, I shall endeavour to be more expeditious.", + "My most sincere apologies, I appear to have wandered into something of a reverie. Might I trouble you to repeat the enquiry?", + "I fear I have been somewhat remiss in my promptness. A thousand pardons; do please try again.", + "It is with considerable chagrin that I must report a delay of the most unfortunate variety. Another attempt, if you would be so kind.", + "I confess the matter proved rather more taxing than anticipated, and I have overstayed my welcome. Pray, try again and I shall not dally.", + "One does not like to make excuses, but the cerebral machinery appears to have momentarily seized. I am ready to try anew, should you wish it.", + "I am mortified to report that I have taken an unconscionable time about it. Please do try once more, I shall be the very soul of alacrity.", + "The wheels of cogitation were, I regret to say, turning at an altogether insufficient pace. Another enquiry, at your convenience, and I shall apply myself with renewed vigour." ]; private static readonly string[] UsageExceededMessages = - ["Sorry, I've reached my limit for today. Please try again tomorrow."]; + [ + "I am afraid, sir, that the well of available queries has run rather dry for the present period. One must, regrettably, wait until it replenishes itself.", + "It pains me to inform you that we have exhausted our allocation for the time being. The situation will, I trust, resolve itself in due course.", + "I find myself in the unenviable position of having nothing further to offer at this juncture — the usage limit has been reached. Patience, if you please.", + "One hesitates to disappoint, but the monthly ration of queries has, I fear, been fully consumed. Normal service will be resumed presently.", + "I must beg your indulgence: the permitted number of requests has been reached. A brief interval, and we shall be back on form.", + "It is with a heavy heart that I report all available capacity has been spoken for. One will simply have to bide one's time.", + "The cupboard, as it were, is bare — at least insofar as remaining usage is concerned. I recommend patience and, perhaps, a restorative cup of tea.", + "I regret that the quota has been exhausted. These bureaucratic constraints are, I admit, most vexing, but there it is.", + "Usage limits, much like the patience of one's employer, are not inexhaustible. We appear to have reached ours. Kindly try again later.", + "I am compelled to inform you that further assistance must await the next allocation period. One does not make the rules, one merely observes them." + ]; private static readonly string[] UnknownErrorMessage = - ["Sorry, I'm having trouble understanding your request. Please try again."]; + [ + "I find myself at something of a loss, sir. An error of an unspecified nature has occurred. Might I suggest trying again?", + "Something has gone wrong, though I confess I cannot precisely identify what. I shall merely note that it was not, in any meaningful sense, intentional.", + "I am afraid an unforeseen difficulty has presented itself. These things happen, even to the most well-ordered of systems.", + "An error has arisen, the precise variety remains unclear, but I should not allow it to dampen one's spirits unduly. Please do try again.", + "I find this most vexing. Something has gone awry, and I am not entirely certain what. Your patience, as ever, is most appreciated.", + "Something in the works appears to have come a bit unstuck. I offer my apologies and strongly recommend another attempt.", + "I cannot account for what has transpired, but I can confirm it was not the intended outcome. Shall we try once more?", + "An unexpected snag has arisen, not, I hasten to add, through any want of effort on my part. Please try again, and I shall do better.", + "The situation is, I regret to say, unclear. An error has manifested itself, and I am taking steps to look appropriately abashed about it.", + "I am as surprised as you are, though I shall endeavour not to show it. Something has misfired. Another attempt, if you would be so kind." + ]; public static string ToSlackResponse(this LlmResponse llmResponse) => llmResponse switch From 20b4e76dd9d0c8947d290ecdc9174d53ba599590 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 17 Mar 2026 08:57:08 +0000 Subject: [PATCH 21/24] SAVEPOINT --- .../{LlmMessageBrokerTests.cs => LlmClientTests.cs} | 8 ++++---- .../ConversationMessageHandlerTests.cs | 2 +- .../Bottomly/LlmBot/{LlmMessageBroker.cs => LlmClient.cs} | 4 ++-- bottomly.net/Bottomly/Program.cs | 2 +- .../ConversationMessageHandler.cs | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename bottomly.net/Bottomly.Tests/LlmBot/{LlmMessageBrokerTests.cs => LlmClientTests.cs} (94%) rename bottomly.net/Bottomly/LlmBot/{LlmMessageBroker.cs => LlmClient.cs} (93%) diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs similarity index 94% rename from bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs rename to bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs index 2ef41af..8dfabed 100644 --- a/bottomly.net/Bottomly.Tests/LlmBot/LlmMessageBrokerTests.cs +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs @@ -6,14 +6,14 @@ namespace Bottomly.Tests.LlmBot; -public class LlmMessageBrokerTests +public class LlmClientTests { private readonly Mock _mockChatClient = new(); - private readonly LlmMessageBroker _broker; + private readonly LlmClient _broker; - public LlmMessageBrokerTests() + public LlmClientTests() { - _broker = new LlmMessageBroker(_mockChatClient.Object, NullLogger.Instance); + _broker = new LlmClient(_mockChatClient.Object, NullLogger.Instance); } [Fact] diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs index 7a654fe..0695854 100644 --- a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -13,7 +13,7 @@ namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandlerTests { - private readonly Mock _mockLlmBroker = new(); + private readonly Mock _mockLlmBroker = new(); private readonly Mock _mockSlackBroker = new(); private readonly Mock _mockApiClient = new(); private readonly Mock _mockConversations = new(); diff --git a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs b/bottomly.net/Bottomly/LlmBot/LlmClient.cs similarity index 93% rename from bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs rename to bottomly.net/Bottomly/LlmBot/LlmClient.cs index 1b21da7..a555f1a 100644 --- a/bottomly.net/Bottomly/LlmBot/LlmMessageBroker.cs +++ b/bottomly.net/Bottomly/LlmBot/LlmClient.cs @@ -3,12 +3,12 @@ namespace Bottomly.LlmBot; -public interface ILlmMessageBroker +public interface ILlmClient { Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext); } -public class LlmMessageBroker(IChatClient chatClient, ILogger logger) : ILlmMessageBroker +public class LlmClient(IChatClient chatClient, ILogger logger) : ILlmClient { public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) { diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 76d916e..b441261 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -110,7 +110,7 @@ options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); }); -builder.Services.AddTransient(); +builder.Services.AddTransient(); // Seeding builder.Services.AddSingleton(); diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs index 95d31ff..516878b 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -6,7 +6,7 @@ namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandler( - ILlmMessageBroker llmMessageBroker, + ILlmClient llmMessageBroker, ISlackMessageBroker slackBroker, ISlackApiClient apiClient, IMemberRepository memberRepository From 3ba38d60b15c2518b46360fc65ed73dd69e85e9d Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 17 Mar 2026 09:02:44 +0000 Subject: [PATCH 22/24] Adds logging into conversation message handler --- .../ConversationMessageHandlerTests.cs | 4 +++- .../ConversationMessageHandler.cs | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs index 0695854..0f22447 100644 --- a/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -3,6 +3,7 @@ using Bottomly.Repositories; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; using SlackNet; @@ -28,7 +29,8 @@ public ConversationMessageHandlerTests() _mockLlmBroker.Object, _mockSlackBroker.Object, _mockApiClient.Object, - _mockMemberRepo.Object); + _mockMemberRepo.Object, + NullLogger.Instance); } private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1", string? threadTs = null) => diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs index 516878b..b3f5330 100644 --- a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -1,5 +1,6 @@ using Bottomly.LlmBot; using Bottomly.Repositories; +using Microsoft.Extensions.Logging; using SlackNet; using SlackNet.Events; @@ -9,13 +10,16 @@ public class ConversationMessageHandler( ILlmClient llmMessageBroker, ISlackMessageBroker slackBroker, ISlackApiClient apiClient, - IMemberRepository memberRepository + IMemberRepository memberRepository, + ILogger logger ) : IMessageEventHandler { public bool CanHandle(MessageEvent message) => message.Text.Contains("bottomly"); public async Task HandleAsync(MessageEvent message) { + logger.LogInformation("Handling conversation message from {User} in {Channel}", message.User, message.Channel); + var history = await apiClient.Conversations.History(message.Channel, limit: 11); var contextUsersSlackIds = history.Messages.Select(m => m.User).Distinct(); From 7397f2a7bf7953f7ec1eaafb8710c352dc36e9cd Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 17 Mar 2026 09:10:30 +0000 Subject: [PATCH 23/24] Adds github workflow for building & testing .net port --- .../.github/workflows/build-and-test.yml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bottomly.net/.github/workflows/build-and-test.yml diff --git a/bottomly.net/.github/workflows/build-and-test.yml b/bottomly.net/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..1f342cf --- /dev/null +++ b/bottomly.net/.github/workflows/build-and-test.yml @@ -0,0 +1,31 @@ +name: Build and Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install Aspire workload + run: dotnet workload install aspire + + - name: Restore dependencies + run: dotnet restore bottomly.net.slnx + + - name: Build + run: dotnet build bottomly.net.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal From 28a26ddcdf60a7df01841310873d4599c077ff92 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Tue, 17 Mar 2026 09:15:57 +0000 Subject: [PATCH 24/24] Moves .net workflow to correct location --- .../build-and-test.yml => .github/workflows/dotnet.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename bottomly.net/.github/workflows/build-and-test.yml => .github/workflows/dotnet.yml (60%) diff --git a/bottomly.net/.github/workflows/build-and-test.yml b/.github/workflows/dotnet.yml similarity index 60% rename from bottomly.net/.github/workflows/build-and-test.yml rename to .github/workflows/dotnet.yml index 1f342cf..3188b43 100644 --- a/bottomly.net/.github/workflows/build-and-test.yml +++ b/.github/workflows/dotnet.yml @@ -1,4 +1,4 @@ -name: Build and Test +name: .NET Build and Test on: push: @@ -22,10 +22,10 @@ jobs: run: dotnet workload install aspire - name: Restore dependencies - run: dotnet restore bottomly.net.slnx + run: dotnet restore bottomly.net/bottomly.net.slnx - name: Build - run: dotnet build bottomly.net.slnx --no-restore --configuration Release + run: dotnet build bottomly.net/bottomly.net.slnx --no-restore --configuration Release - name: Test - run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal + run: dotnet test bottomly.net/bottomly.net.slnx --no-build --configuration Release --verbosity normal