From 3b95790c55570a346f5de9c9499cf1247a89b7a0 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Thu, 19 Mar 2026 10:23:11 +0000 Subject: [PATCH] Adds message formatting for slack commands. Commands that return URLs or still images will now properly format them, --- .../EventHandlers/ImageSearchHandlerTests.cs | 17 +++++++- .../Slack/EventHandlers/SearchHandlerTests.cs | 2 +- .../EventHandlers/WikipediaHandlerTests.cs | 2 +- .../Slack/SlackMessageBrokerTests.cs | 40 +++++++++++++++++++ Bottomly/Slack/ISlackMessageBroker.cs | 3 ++ .../AbstractMessageEventHandler.cs | 7 ++++ .../ImageSearchHandler.cs | 23 ++++++++--- .../MessageEventHandlers/SearchHandler.cs | 2 +- .../MessageEventHandlers/WikipediaHandler.cs | 2 +- Bottomly/Slack/SlackMessageBroker.cs | 20 ++++++++++ 10 files changed, 107 insertions(+), 11 deletions(-) diff --git a/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs index 7383431..e1ef848 100644 --- a/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.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; @@ -52,14 +53,26 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() } [Fact] - public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() + public async Task HandleAsync_ValidEvent_WithResult_SendsImageBlock() { _mockCommand.Setup(c => c.ExecuteAsync("cats")) .ReturnsAsync(new SearchResult("Cute Cat", "https://example.com/cat.jpg")); + 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("_gi cats")); - _mockBroker.Verify(b => b.SendMessageAsync("Cute Cat https://example.com/cat.jpg", "C1", null), Times.Once()); + _mockBroker.Verify(b => b.SendBlocksMessageAsync( + It.IsAny>(), "C1", "Cute Cat", null), Times.Once()); + capturedBlocks.ShouldNotBeNull(); + capturedBlocks.Count.ShouldBe(1); + var imageBlock = capturedBlocks[0].ShouldBeOfType(); + imageBlock.ImageUrl.ShouldBe("https://example.com/cat.jpg"); + imageBlock.AltText.ShouldBe("Cute Cat"); } [Fact] diff --git a/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs index a73f15a..eaf358b 100644 --- a/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs @@ -59,7 +59,7 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() await _handler.HandleAsync(CreateMessage("_g dotnet")); - _mockBroker.Verify(b => b.SendMessageAsync("DotNet https://dotnet.microsoft.com", "C1", null), Times.Once()); + _mockBroker.Verify(b => b.SendMessageAsync("", "C1", null), Times.Once()); } [Fact] diff --git a/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs index f9e0257..0f736a3 100644 --- a/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs @@ -51,7 +51,7 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() await _handler.HandleAsync(CreateMessage("_wik octopus")); - _mockBroker.Verify(b => b.SendMessageAsync("Octopus https://en.wikipedia.org/wiki/Octopus", "C1", null), + _mockBroker.Verify(b => b.SendMessageAsync("", "C1", null), Times.Once()); } diff --git a/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs b/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs index d25d21f..ba8837b 100644 --- a/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs +++ b/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using Moq; using SlackNet; +using SlackNet.Blocks; using SlackNet.WebApi; namespace Bottomly.Tests.Slack; @@ -76,6 +77,45 @@ public async Task SendMessageAsync_DebugMode_PrependsPrefixToText() m.Text!.StartsWith("[Dev]"))), Times.Once()); } + [Fact] + public async Task SendBlocksMessageAsync_PostsBlocksToChannel() + { + var broker = CreateBroker(); + var blocks = new List { new ImageBlock { ImageUrl = "https://example.com/img.jpg", AltText = "test" } }; + + await broker.SendBlocksMessageAsync(blocks, "C1", "fallback text"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.Channel == "C1" && + m.Text == "fallback text" && + m.Blocks.Count == 1 && + m.Blocks[0] is ImageBlock)), Times.Once()); + } + + [Fact] + public async Task SendBlocksMessageAsync_WithReplyTs_SetsThreadTs() + { + var broker = CreateBroker(); + var blocks = new List { new ImageBlock { ImageUrl = "https://example.com/img.jpg", AltText = "test" } }; + + await broker.SendBlocksMessageAsync(blocks, "C1", replyToTs: "ts123"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.ThreadTs == "ts123")), Times.Once()); + } + + [Fact] + public async Task SendBlocksMessageAsync_DebugMode_PrependsPrefixToText() + { + var broker = CreateBroker(environment: "Dev"); + var blocks = new List { new ImageBlock { ImageUrl = "https://example.com/img.jpg", AltText = "test" } }; + + await broker.SendBlocksMessageAsync(blocks, "C1", "fallback"); + + _mockChat.Verify(c => c.PostMessage(It.Is(m => + m.Text!.StartsWith("[Dev]"))), Times.Once()); + } + [Fact] public async Task SendReactionAsync_CallsSlackReactions() { diff --git a/Bottomly/Slack/ISlackMessageBroker.cs b/Bottomly/Slack/ISlackMessageBroker.cs index eb69374..2d33984 100644 --- a/Bottomly/Slack/ISlackMessageBroker.cs +++ b/Bottomly/Slack/ISlackMessageBroker.cs @@ -1,8 +1,11 @@ +using SlackNet.Blocks; + namespace Bottomly.Slack; public interface ISlackMessageBroker { Task SendMessageAsync(string text, string channel, string? replyToTs = null); + Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, string? replyToTs = null); Task SendReactionAsync(string emoji, string channel, string timestamp); Task SendDmAsync(string text, string username); } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs b/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index 30ffd15..0d1f14b 100644 --- a/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.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; @@ -73,6 +74,12 @@ protected async Task SendMessageResponseAsync(string text, MessageEvent message, await Broker.SendMessageAsync(text, message.Channel, replyTs); } + protected async Task SendBlocksResponseAsync(IReadOnlyList blocks, MessageEvent message, string? text = null, bool asReply = false) + { + var replyTs = asReply ? message.TsForReply() : null; + await Broker.SendBlocksMessageAsync(blocks, message.Channel, text, replyTs); + } + protected Task SendReactionResponseAsync(MessageEvent message) => Broker.SendReactionAsync("robot_face", message.Channel, message.Ts); diff --git a/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs index c3ef1df..26bb5a8 100644 --- a/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs @@ -3,6 +3,7 @@ using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SlackNet.Blocks; using SlackNet.Events; namespace Bottomly.Slack.MessageEventHandlers; @@ -27,11 +28,23 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var query = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(query); - var response = result switch + + if (result is SearchResult success) + { + var blocks = new List + { + new ImageBlock + { + ImageUrl = success.Link, + AltText = success.Title, + Title = new PlainText { Text = success.Title } + } + }; + await SendBlocksResponseAsync(blocks, message, success.Title); + } + else { - SearchResult success => $"{success.Title} {success.Link}", - _ => $"No image results found for \"{query}\"" - }; - await SendMessageResponseAsync(response, message); + await SendMessageResponseAsync($"No image results found for \"{query}\"", message); + } } } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs index c0ae2a5..ea89fe4 100644 --- a/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs @@ -30,7 +30,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var response = result switch { - SearchResult success => $"{success.Title} {success.Link}", + SearchResult success => $"<{success.Link}|{success.Title}>", EmptySearchTermErrorResult => $"No results found for \"{query}\"", _ => "Left as an exercise for the reader." }; diff --git a/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs b/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs index db76922..95f4ef4 100644 --- a/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs @@ -24,7 +24,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var result = await command.ExecuteAsync(term); var response = result is null ? $"No results found for \"{term}\"" - : $"{result.Text} {result.Link}"; + : $"<{result.Link}|{result.Text}>"; await SendMessageResponseAsync(response, message); } } \ No newline at end of file diff --git a/Bottomly/Slack/SlackMessageBroker.cs b/Bottomly/Slack/SlackMessageBroker.cs index 582f412..27fc152 100644 --- a/Bottomly/Slack/SlackMessageBroker.cs +++ b/Bottomly/Slack/SlackMessageBroker.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SlackNet; +using SlackNet.Blocks; using SlackNet.WebApi; namespace Bottomly.Slack; @@ -44,6 +45,25 @@ public async Task SendMessageAsync(string text, string channel, string? replyToT } } + public async Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, string? replyToTs = null) + { + try + { + var message = new Message + { + Channel = channel, + Text = _options.IsDebug && text is not null ? $"[{_options.Environment}] {text}" : text ?? string.Empty, + Blocks = [..blocks], + ThreadTs = replyToTs + }; + await slack.Chat.PostMessage(message); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending blocks message to channel {Channel}", channel); + } + } + public async Task SendReactionAsync(string emoji, string channel, string timestamp) { try