Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions Bottomly.Tests/Commands/GiphyCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<IHttpClientFactory>();
var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" });
var command = new GiphyCommand(mockFactory.Object, options);
var command = new GiphyCommand(mockFactory.Object, options, NullLogger<GiphyCommand>.Instance);

var result = await command.ExecuteAsync("");

result.ShouldBeNull();
result.ShouldBeOfType<GiphyBadInputResult>();
}

[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<GiphyCommand>.Instance);

var result = await command.ExecuteAsync("cat");

result.ShouldBe("https://giphy.com/gifs/funny-cat");
var successResult = result.ShouldBeOfType<GiphySuccessResult>();
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<GiphyCommand>.Instance);

var result = await command.ExecuteAsync("obscuresearch");

result.ShouldBeNull();
result.ShouldBeOfType<GiphyEmptyResult>();
}
}
28 changes: 21 additions & 7 deletions Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Shouldly;
using SlackNet.Blocks;
using SlackNet.Events;

namespace Bottomly.Tests.Slack.EventHandlers;
Expand All @@ -19,7 +20,7 @@ public GiphyHandlerTests()
{
var options = TestHelpers.CreateOptions();
var mockFactory = new Mock<IHttpClientFactory>();
_mockCommand = new Mock<GiphyCommand>(mockFactory.Object, options);
_mockCommand = new Mock<GiphyCommand>(mockFactory.Object, options, NullLogger<GiphyCommand>.Instance);
_handler = new GiphyHandler(_mockCommand.Object, _mockBroker.Object, options,
NullLogger<GiphyHandler>.Instance);
}
Expand All @@ -36,27 +37,40 @@ 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"));

_mockCommand.Verify(c => c.ExecuteAsync("cats"), Times.Once());
}

[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<Block>? capturedBlocks = null;
_mockBroker
.Setup(b => b.SendBlocksMessageAsync(It.IsAny<IReadOnlyList<Block>>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
.Callback<IReadOnlyList<Block>, 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<IReadOnlyList<Block>>(), "C1", "cats", null), Times.Once());
capturedBlocks.ShouldNotBeNull();
capturedBlocks.Count.ShouldBe(1);
var imageBlock = capturedBlocks[0].ShouldBeOfType<ImageBlock>();
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"));

Expand Down
26 changes: 26 additions & 0 deletions Bottomly/AppInitialisation.cs
Original file line number Diff line number Diff line change
@@ -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<IFeatureFlagRepository>();
await featureFlagRepository.SeedAsync("EnableLlm", false);

var populator = app.Services.GetRequiredService<MemberlistPopulator>();
await populator.PopulateMembers();

if (app.Services.GetRequiredService<IConfiguration>().GetValue<bool>("ImportMemberSeedData"))
{
var importer = app.Services.GetRequiredService<MemberSeedDataImporter>();
await importer.ImportAsync();
}
}
}
59 changes: 42 additions & 17 deletions Bottomly/Commands/GiphyCommand.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,63 @@
using System.Text.Json;
using Bottomly.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Bottomly.Commands;

public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions<BottomlyOptions> options)
public class GiphyCommand(
IHttpClientFactory httpClientFactory,
IOptions<BottomlyOptions> options,
ILogger<GiphyCommand> logger)
: ICommand
{
private readonly string _apiKey = options.Value.GiphyApiKey;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;

public string GetPurpose() => "Uses Giphy to find a gif matching the given search term";

public virtual async Task<string?> ExecuteAsync(string searchTerm)
public virtual async Task<GiphyResult> 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();
}
}
}

public abstract record GiphyResult;

public record GiphyBadInputResult : GiphyResult;

public record GiphyErrorResult(string Error) : GiphyResult;

public record GiphyEmptyResult : GiphyResult;

public record GiphySuccessResult(string Url) : GiphyResult;
26 changes: 26 additions & 0 deletions Bottomly/Configuration/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<BottomlyOptions>(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;
}
}
28 changes: 28 additions & 0 deletions Bottomly/HostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
37 changes: 37 additions & 0 deletions Bottomly/LlmBot/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<ILlmClient, LlmClient>();

return builder;
}
}
Loading
Loading