diff --git a/Bottomly.Tests/Commands/GiphyCommandTests.cs b/Bottomly.Tests/Commands/GiphyCommandTests.cs index ecedd3c..43e2a12 100644 --- a/Bottomly.Tests/Commands/GiphyCommandTests.cs +++ b/Bottomly.Tests/Commands/GiphyCommandTests.cs @@ -1,6 +1,7 @@ using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Shouldly; @@ -10,38 +11,39 @@ namespace Bottomly.Tests.Commands; public class GiphyCommandTests { [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsBadInputResult() { var mockFactory = new Mock(); var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); - var command = new GiphyCommand(mockFactory.Object, options); + var command = new GiphyCommand(mockFactory.Object, options, NullLogger.Instance); var result = await command.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] - public async Task ExecuteAsync_WithResult_ReturnsUrl() + public async Task ExecuteAsync_WithResult_ReturnsSuccessResult() { 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 command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, NullLogger.Instance); var result = await command.ExecuteAsync("cat"); - result.ShouldBe("https://giphy.com/gifs/funny-cat"); + var successResult = result.ShouldBeOfType(); + successResult.Url.ShouldBe("https://giphy.com/gifs/funny-cat"); } [Fact] - public async Task ExecuteAsync_EmptyDataArray_ReturnsNull() + public async Task ExecuteAsync_EmptyDataArray_ReturnsEmptyResult() { const string json = """{"data":[]}"""; var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); - var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options); + var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, NullLogger.Instance); var result = await command.ExecuteAsync("obscuresearch"); - result.ShouldBeNull(); + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs index 922eb83..421f76d 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; +using SlackNet.Blocks; using SlackNet.Events; namespace Bottomly.Tests.Slack.EventHandlers; @@ -19,7 +20,7 @@ public GiphyHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object, options); + _mockCommand = new Mock(mockFactory.Object, options, NullLogger.Instance); _handler = new GiphyHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } @@ -36,7 +37,7 @@ private static MessageEvent CreateMessage(string text) => [Fact] public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() { - _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((string?)null); + _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync(new GiphyEmptyResult()); await _handler.HandleAsync(CreateMessage("_gif cats")); @@ -44,19 +45,32 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() } [Fact] - public async Task HandleAsync_ValidEvent_WithResult_SendsResult() + public async Task HandleAsync_ValidEvent_WithResult_SendsImageBlock() { - _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync("https://giphy.com/cat.gif"); + _mockCommand.Setup(c => c.ExecuteAsync("cats")) + .ReturnsAsync(new GiphySuccessResult("https://giphy.com/cat.gif")); + + IReadOnlyList? capturedBlocks = null; + _mockBroker + .Setup(b => b.SendBlocksMessageAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback, string, string?, string?>((blocks, _, _, _) => capturedBlocks = blocks) + .Returns(Task.CompletedTask); await _handler.HandleAsync(CreateMessage("_gif cats")); - _mockBroker.Verify(b => b.SendMessageAsync("https://giphy.com/cat.gif", "C1", null), Times.Once()); + _mockBroker.Verify(b => b.SendBlocksMessageAsync( + It.IsAny>(), "C1", "cats", null), Times.Once()); + capturedBlocks.ShouldNotBeNull(); + capturedBlocks.Count.ShouldBe(1); + var imageBlock = capturedBlocks[0].ShouldBeOfType(); + imageBlock.ImageUrl.ShouldBe("https://giphy.com/cat.gif"); + imageBlock.AltText.ShouldBe("cats"); } [Fact] - public async Task HandleAsync_ValidEvent_NullResult_SendsNoGifsMessage() + public async Task HandleAsync_ValidEvent_EmptyResult_SendsNoGifsMessage() { - _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null); + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync(new GiphyEmptyResult()); await _handler.HandleAsync(CreateMessage("_gif xyz")); diff --git a/Bottomly/AppInitialisation.cs b/Bottomly/AppInitialisation.cs new file mode 100644 index 0000000..d74d2b7 --- /dev/null +++ b/Bottomly/AppInitialisation.cs @@ -0,0 +1,26 @@ +using Bottomly.Repositories; +using Bottomly.Seed; +using Bottomly.Slack; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bottomly; + +public static class AppInitialisation +{ + public static async Task InitialiseAsync(this IHost app) + { + var featureFlagRepository = app.Services.GetRequiredService(); + await featureFlagRepository.SeedAsync("EnableLlm", false); + + var populator = app.Services.GetRequiredService(); + await populator.PopulateMembers(); + + if (app.Services.GetRequiredService().GetValue("ImportMemberSeedData")) + { + var importer = app.Services.GetRequiredService(); + await importer.ImportAsync(); + } + } +} diff --git a/Bottomly/Commands/GiphyCommand.cs b/Bottomly/Commands/GiphyCommand.cs index a15eeb1..511914a 100644 --- a/Bottomly/Commands/GiphyCommand.cs +++ b/Bottomly/Commands/GiphyCommand.cs @@ -1,10 +1,14 @@ using System.Text.Json; using Bottomly.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Bottomly.Commands; -public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions options) +public class GiphyCommand( + IHttpClientFactory httpClientFactory, + IOptions options, + ILogger logger) : ICommand { private readonly string _apiKey = options.Value.GiphyApiKey; @@ -12,27 +16,48 @@ public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions "Uses Giphy to find a gif matching the given search term"; - public virtual async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) { - return null; + return new GiphyBadInputResult(); } - var url = - $"http://api.giphy.com/v1/gifs/translate?limit=1&api_key={_apiKey}&s={Uri.EscapeDataString(searchTerm)}"; - var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var data = doc.RootElement.GetProperty("data"); - - if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() == 0) + try { - return null; + var url = + $"http://api.giphy.com/v1/gifs/translate?limit=1&api_key={_apiKey}&s={Uri.EscapeDataString(searchTerm)}"; + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var data = doc.RootElement.GetProperty("data"); + + if (data.ValueKind == JsonValueKind.Array && data.GetArrayLength() == 0) + { + return new GiphyEmptyResult(); + } + + var gifUrl = data.GetProperty("url").GetString(); + return string.IsNullOrEmpty(gifUrl) + ? new GiphyEmptyResult() + : new GiphySuccessResult(gifUrl); + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing Giphy search"); + return new GiphyErrorResult(ex.Message); } - - return data.GetProperty("url").GetString(); } -} \ No newline at end of file +} + +public abstract record GiphyResult; + +public record GiphyBadInputResult : GiphyResult; + +public record GiphyErrorResult(string Error) : GiphyResult; + +public record GiphyEmptyResult : GiphyResult; + +public record GiphySuccessResult(string Url) : GiphyResult; \ No newline at end of file diff --git a/Bottomly/Configuration/ServiceCollectionExtensions.cs b/Bottomly/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..f36e971 --- /dev/null +++ b/Bottomly/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bottomly.Configuration; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBottomlyConfiguration(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(opts => + { + opts.SlackBotToken = configuration["bottomly_slack_bot_token"] ?? string.Empty; + opts.SlackAppToken = configuration["bottomly_slack_app_token"] ?? string.Empty; + opts.GoogleApiKey = configuration["bottomly_google_api_key"] ?? string.Empty; + opts.GoogleCseId = configuration["bottomly_google_cse_id"] ?? string.Empty; + opts.Prefix = configuration["bottomly_prefix"] ?? "!"; + opts.GiphyApiKey = configuration["bottomly_giphy_api_key"] ?? string.Empty; + opts.Environment = configuration["bottomly_env"] ?? "live"; + opts.GitHubToken = configuration["bottomly_github_token"] ?? string.Empty; + opts.BraveApiKey = configuration["bottomly_brave_api_key"] ?? string.Empty; + opts.OllamaApiKey = configuration["bottomly_ollama_api_key"] ?? string.Empty; + }); + + return services; + } +} diff --git a/Bottomly/HostBuilderExtensions.cs b/Bottomly/HostBuilderExtensions.cs new file mode 100644 index 0000000..184e418 --- /dev/null +++ b/Bottomly/HostBuilderExtensions.cs @@ -0,0 +1,28 @@ +using System.Reflection; +using Bottomly.Commands; +using Bottomly.Slack.MessageEventHandlers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bottomly; + +public static class HostBuilderExtensions +{ + extension(HostApplicationBuilder builder) + { + public void RegisterEventHandlers(Assembly assembly, Type[] exclude) => + assembly.GetTypes() + .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && + t is { IsInterface: false, IsAbstract: false }) + .Where(t => t.Name != nameof(HelpHandler)) + .Where(t => !exclude.Contains(t)) + .ToList() + .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); + + public void RegisterCommands(Assembly assembly) => + assembly.GetTypes() + .Where(t => typeof(ICommand).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) + .ToList() + .ForEach(t => builder.Services.AddSingleton(t)); + } +} diff --git a/Bottomly/LlmBot/ServiceCollectionExtensions.cs b/Bottomly/LlmBot/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..7497075 --- /dev/null +++ b/Bottomly/LlmBot/ServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bottomly.LlmBot; + +public static class HostApplicationBuilderExtensions +{ + public static IHostApplicationBuilder AddBottomlyLlm(this IHostApplicationBuilder builder) + { + builder.AddOllamaApiClient("bottomlymodel", x => + { + x.Endpoint = new Uri("https://ollama.com"); + x.SelectedModel = "qwen3.5:cloud"; + }) + .AddChatClient(); + + var ollamaApiKey = builder.Configuration["bottomly_ollama_api_key"] ?? string.Empty; + + // 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("bottomlymodel_httpClient") + .ConfigureHttpClient(c => c.DefaultRequestHeaders.Add("Authorization", $"Bearer {ollamaApiKey}")) + .RemoveAllResilienceHandlers() +#pragma warning restore EXTEXP0001 + .AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(4); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); + }); + + builder.Services.AddTransient(); + + return builder; + } +} diff --git a/Bottomly/Program.cs b/Bottomly/Program.cs index f016622..824fc30 100644 --- a/Bottomly/Program.cs +++ b/Bottomly/Program.cs @@ -1,26 +1,18 @@ using System.Reflection; -using Bottomly.Commands; +using Bottomly; using Bottomly.Configuration; using Bottomly.LlmBot; using Bottomly.Repositories; using Bottomly.Seed; using Bottomly.Slack; -using Bottomly.Slack.MembershipEventHandlers; -using Bottomly.Slack.MessageEventHandlers; -using Bottomly.Slack.ReactionHandlers; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using MongoDB.Driver; -using SlackNet.Events; -using SlackNet.Extensions.DependencyInjection; var builder = Host.CreateApplicationBuilder(args); builder.AddServiceDefaults(); - builder.Configuration.AddUserSecrets(typeof(Program).Assembly).AddJsonFile("appsettings.json"); // MongoDB @@ -34,136 +26,19 @@ return client.GetDatabase(databaseName); }); -// Configuration -builder.Services.Configure(opts => -{ - opts.SlackBotToken = builder.Configuration["bottomly_slack_bot_token"] ?? string.Empty; - opts.SlackAppToken = builder.Configuration["bottomly_slack_app_token"] ?? string.Empty; - opts.GoogleApiKey = builder.Configuration["bottomly_google_api_key"] ?? string.Empty; - opts.GoogleCseId = builder.Configuration["bottomly_google_cse_id"] ?? string.Empty; - opts.Prefix = builder.Configuration["bottomly_prefix"] ?? "!"; - opts.GiphyApiKey = builder.Configuration["bottomly_giphy_api_key"] ?? string.Empty; - opts.Environment = builder.Configuration["bottomly_env"] ?? "live"; - opts.GitHubToken = builder.Configuration["bottomly_github_token"] ?? string.Empty; - opts.BraveApiKey = builder.Configuration["bottomly_brave_api_key"] ?? string.Empty; - opts.OllamaApiKey = builder.Configuration["bottomly_ollama_api_key"] ?? string.Empty; -}); - -var opts = builder.Services.BuildServiceProvider().GetRequiredService>(); - -// HTTP +builder.Services.AddBottomlyConfiguration(builder.Configuration); builder.Services.AddHttpClient(); +builder.Services.AddBottomlyRepositories(); -// Repositories -builder.Services.AddMemoryCache(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => - new CachingMemberRepository( - new MemberRepository(sp.GetRequiredService()), - sp.GetRequiredService())); -builder.Services.AddSingleton(); - -// Commands builder.RegisterCommands(Assembly.GetExecutingAssembly()); - -// Slack infrastructure -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// SlackNet -var slackBotToken = builder.Configuration["bottomly_slack_bot_token"] ?? string.Empty; -var slackAppToken = builder.Configuration["bottomly_slack_app_token"] ?? string.Empty; - -builder.Services.AddSlackNet(c => c - .UseApiToken(slackBotToken) - .UseAppLevelToken(slackAppToken) - .RegisterEventHandler() - .RegisterEventHandler() - .RegisterEventHandler()); - -// Event handlers (registered for IEventHandler collection, excluding Help which is separate) builder.RegisterEventHandlers(Assembly.GetExecutingAssembly(), []); -// Help handler (also registered as singleton for direct injection into SlackWorker) -builder.Services.AddSingleton(); - -// Reaction handlers -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// Membership handlers -builder.Services.AddSingleton(); - -// Slack dispatchers and worker -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(sp => sp.GetRequiredService()); - -// LLM Support -builder.AddOllamaApiClient("bottomlymodel", x => - { - x.Endpoint = new Uri("https://ollama.com"); - x.SelectedModel = "qwen3.5:cloud"; - }) - .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("bottomlymodel_httpClient") - .ConfigureHttpClient(c => c.DefaultRequestHeaders.Add("Authorization", $"Bearer {opts.Value.OllamaApiKey}")) - .RemoveAllResilienceHandlers() -#pragma warning restore EXTEXP0001 - .AddStandardResilienceHandler(options => - { - options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); - options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(4); - options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(8); - }); - -builder.Services.AddTransient(); - -// Seeding -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddBottomlySlack(builder.Configuration); +builder.AddBottomlyLlm(); +builder.Services.AddBottomlySeeding(); var app = builder.Build(); -var featureFlagRepository = app.Services.GetRequiredService(); -await featureFlagRepository.SeedAsync("EnableLlm", false); - -var populator = app.Services.GetRequiredService(); -await populator.PopulateMembers(); - -if (app.Services.GetRequiredService().GetValue("ImportMemberSeedData")) -{ - var importer = app.Services.GetRequiredService(); - await importer.ImportAsync(); -} - -app.Run(); - - -public static class HostBuilderExtensions -{ - extension(HostApplicationBuilder builder) - { - public void RegisterEventHandlers(Assembly assembly, Type[] exclude) => - assembly.GetTypes() - .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && - t is { IsInterface: false, IsAbstract: false }) - .Where(t => t.Name != nameof(HelpHandler)) - .Where(t => !exclude.Contains(t)) - .ToList() - .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); +await app.InitialiseAsync(); - public void RegisterCommands(Assembly assembly) => - assembly.GetTypes() - .Where(t => typeof(ICommand).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) - .ToList() - .ForEach(t => builder.Services.AddSingleton(t)); - } -} \ No newline at end of file +app.Run(); \ No newline at end of file diff --git a/Bottomly/Repositories/ServiceCollectionExtensions.cs b/Bottomly/Repositories/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..8c71e45 --- /dev/null +++ b/Bottomly/Repositories/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Bottomly.Repositories; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBottomlyRepositories(this IServiceCollection services) + { + services.AddMemoryCache(); + services.AddSingleton(); + services.AddSingleton(sp => + new CachingMemberRepository( + new MemberRepository(sp.GetRequiredService()), + sp.GetRequiredService())); + services.AddSingleton(); + + return services; + } +} diff --git a/Bottomly/Seed/ServiceCollectionExtensions.cs b/Bottomly/Seed/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..544c4de --- /dev/null +++ b/Bottomly/Seed/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Bottomly.Slack; +using Microsoft.Extensions.DependencyInjection; + +namespace Bottomly.Seed; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBottomlySeeding(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs b/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs index ba3825b..7e80e57 100644 --- a/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs @@ -2,6 +2,7 @@ using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SlackNet.Blocks; using SlackNet.Events; namespace Bottomly.Slack.MessageEventHandlers; @@ -22,7 +23,23 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var term = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(term); - var response = result ?? $"No gifs found for \"{term}\""; - await SendMessageResponseAsync(response, message); + + if (result is GiphySuccessResult success) + { + var blocks = new List + { + new ImageBlock + { + ImageUrl = success.Url, + AltText = term, + Title = new PlainText { Text = term } + } + }; + await SendBlocksResponseAsync(blocks, message, term); + } + else + { + await SendMessageResponseAsync($"No gifs found for \"{term}\"", message); + } } } \ No newline at end of file diff --git a/Bottomly/Slack/ServiceCollectionExtensions.cs b/Bottomly/Slack/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d39d11f --- /dev/null +++ b/Bottomly/Slack/ServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using Bottomly.Repositories; +using Bottomly.Slack.MembershipEventHandlers; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SlackNet.Events; +using SlackNet.Extensions.DependencyInjection; + +namespace Bottomly.Slack; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddBottomlySlack(this IServiceCollection services, IConfiguration configuration) + { + var slackBotToken = configuration["bottomly_slack_bot_token"] ?? string.Empty; + var slackAppToken = configuration["bottomly_slack_app_token"] ?? string.Empty; + + // Slack infrastructure + services.AddSingleton(); + services.AddSingleton(); + + // SlackNet + services.AddSlackNet(c => c + .UseApiToken(slackBotToken) + .UseAppLevelToken(slackAppToken) + .RegisterEventHandler() + .RegisterEventHandler() + .RegisterEventHandler()); + + // Help handler (also registered as singleton for direct injection into SlackWorker) + services.AddSingleton(); + + // Reaction handlers + services.AddSingleton(); + services.AddSingleton(); + + // Membership handlers + services.AddSingleton(); + + // Slack dispatchers and worker + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(sp => sp.GetRequiredService()); + + return services; + } +}