diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..3188b43 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,31 @@ +name: .NET 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/bottomly.net.slnx + + - name: Build + run: dotnet build bottomly.net/bottomly.net.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test bottomly.net/bottomly.net.slnx --no-build --configuration Release --verbosity normal diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore new file mode 100644 index 0000000..d79eb76 --- /dev/null +++ b/bottomly.net/.gitignore @@ -0,0 +1,518 @@ +# .NET and Visual Studio .gitignore + +# Planning documents +.plans/ + +# Confidential member seed data +MemberSeedData/ + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Mono auto generated files +mono_crash.* + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +# JetBrains IDEs +.idea/ +*.iml +*.ipr +*.iws + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.swo + +# Log files +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public + +# Storybook build outputs +.out +.storybook-out + +# Rollup.js default build output +dist/ + +# Uncomment the following line if you want to include yarn.lock +# yarn.lock + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file 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 new file mode 100644 index 0000000..a60a1f3 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +builder.Configuration.AddUserSecrets(); + +var mongo = builder.AddMongoDB("mongo") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent); + +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.AddOllamaLocal("ollama"); + +var bottomlyModel = ollama.AddModel("bottomlymodel", "qwen3.5:cloud"); + +var bottomly = builder.AddProject("bottomly") + .WaitFor(mongodb) + .WaitFor(ollama) + .WithReference(bottomlyModel) + .WithReference(mongodb) + ; + +var app = builder.Build(); + +app.Run(); \ No newline at end of file diff --git a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj new file mode 100644 index 0000000..ec78659 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + 5085e312-407d-49ea-8b7c-815877981360 + + + + + + + + + + + + + + + diff --git a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs new file mode 100644 index 0000000..ecedd3c --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs @@ -0,0 +1,47 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Options; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GiphyCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var mockFactory = new Mock(); + var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); + var command = new GiphyCommand(mockFactory.Object, options); + + var result = await command.ExecuteAsync(""); + + 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 new file mode 100644 index 0000000..0d1a09e --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs @@ -0,0 +1,42 @@ +using Bottomly.Commands; +using Bottomly.Tests.Helpers; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class UrbanSearchCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var mockFactory = new Mock(); + var command = new UrbanSearchCommand(mockFactory.Object); + + var result = await command.ExecuteAsync(""); + + 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 new file mode 100644 index 0000000..95f50dd --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs @@ -0,0 +1,44 @@ +using Bottomly.Commands; +using Bottomly.Tests.Helpers; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class WikipediaSearchCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var mockFactory = new Mock(); + var command = new WikipediaSearchCommand(mockFactory.Object); + + var result = await command.ExecuteAsync(""); + + 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 new file mode 100644 index 0000000..3579b90 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs @@ -0,0 +1,36 @@ +using System.Net; +using Bottomly.Configuration; +using Microsoft.Extensions.Options; +using Moq; + +namespace Bottomly.Tests.Helpers; + +internal static class TestHelpers +{ + public const string TestPrefix = "_"; + + 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..f0e06a3 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmBotTests.cs @@ -0,0 +1,207 @@ +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.IsError().ShouldBeFalse(); + } + + [Fact] + public void IsSuccess_LlmTimeoutResponse_ReturnsFalse() + { + LlmResponse response = new LlmTimeoutResponse(); + + response.IsError().ShouldBeTrue(); + } +} + +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"); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.cs new file mode 100644 index 0000000..8dfabed --- /dev/null +++ b/bottomly.net/Bottomly.Tests/LlmBot/LlmClientTests.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 LlmClientTests +{ + private readonly Mock _mockChatClient = new(); + private readonly LlmClient _broker; + + public LlmClientTests() + { + _broker = new LlmClient(_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/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.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs new file mode 100644 index 0000000..74ff5b2 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsHandlerTests.cs @@ -0,0 +1,101 @@ +using Bottomly.Models; +using Bottomly.Repositories; +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 GetCurrentKarmaReasonsHandlerTests +{ + private readonly GetCurrentKarmaReasonsHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public GetCurrentKarmaReasonsHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new GetCurrentKarmaReasonsHandler(_mockKarmaRepo.Object, parser, _mockBroker.Object, options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U_sender") => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_WithRecipient_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_reasons alice")).ShouldBeTrue(); + + [Fact] + public void CanHandle_NoRecipient_ReturnsTrue() => _handler.CanHandle(CreateMessage("_reasons")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("hello world")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_PlainRecipient_CallsCommandWithRecipient() + { + _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync( + new KarmaReasonsResult(0, new List().AsReadOnly())); + + await _handler.HandleAsync(CreateMessage("_reasons alice")); + + _mockKarmaRepo.Verify(r => r.GetKarmaReasonsAsync("alice"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_NoRecipient_CallsCommandWithMessageUser() + { + _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("U_sender")).ReturnsAsync( + new KarmaReasonsResult(0, new List().AsReadOnly())); + + await _handler.HandleAsync(CreateMessage("_reasons ")); + + _mockKarmaRepo.Verify(r => r.GetKarmaReasonsAsync("U_sender"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithResult_SendsFormattedResponse() + { + var reasoned = new List + { + new() + { + AwardedByUsername = "bob", AwardedToUsername = "alice", Reason = "great work", + KarmaType = KarmaType.PozzyPoz + }, + new() + { + AwardedByUsername = "carol", AwardedToUsername = "alice", Reason = "bad work", + KarmaType = KarmaType.NeggyNeg + } + }.AsReadOnly(); + _mockKarmaRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(new KarmaReasonsResult(1, reasoned)); + + await _handler.HandleAsync(CreateMessage("_reasons alice")); + + _mockBroker.Verify(b => b.SendDmAsync( + It.Is(s => + s.Contains("Recent Karma for alice") && + s.Contains("Recently awarded with no reason: 1") && + s.Contains("++ from bob for \"great work\"") && + s.Contains("-- from carol for \"bad work\"")), + "U_sender"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_reasons -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Karma Reasons")), "C1", null), + 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 new file mode 100644 index 0000000..8770bb4 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaHandlerTests.cs @@ -0,0 +1,87 @@ +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.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GetCurrentNetKarmaHandlerTests +{ + private readonly GetCurrentNetKarmaHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public GetCurrentNetKarmaHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new GetCurrentNetKarmaHandler( + _mockKarmaRepo.Object, + new KarmaReactionMap(), parser, + _mockBroker.Object, options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U_sender") => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_WithPlainRecipient_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_karma recipient")).ShouldBeTrue(); + + [Fact] + public void CanHandle_WithSlackIdRecipient_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_karma <@U12345>")).ShouldBeTrue(); + + [Fact] + public void CanHandle_NoRecipient_ReturnsTrue() => _handler.CanHandle(CreateMessage("_karma")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("hello world")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_PlainRecipient_CallsCommandWithRecipient() + { + _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(3); + + await _handler.HandleAsync(CreateMessage("_karma alice")); + + _mockKarmaRepo.Verify(r => r.GetCurrentNetKarmaAsync("alice"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_NoRecipient_CallsCommandWithMessageUser() + { + _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("U_sender")).ReturnsAsync(0); + + await _handler.HandleAsync(CreateMessage("_karma ")); + + _mockKarmaRepo.Verify(r => r.GetCurrentNetKarmaAsync("U_sender"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithResult_SendsFormattedResponse() + { + _mockKarmaRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(0); + + await _handler.HandleAsync(CreateMessage("_karma alice")); + + _mockBroker.Verify(b => b.SendMessageAsync("alice: 0", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_karma -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Current Karma")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs new file mode 100644 index 0000000..6886954 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardHandlerTests.cs @@ -0,0 +1,100 @@ +using Bottomly.Repositories; +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 GetLeaderBoardHandlerTests +{ + private readonly GetLeaderBoardHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + + public GetLeaderBoardHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _handler = new GetLeaderBoardHandler(_mockKarmaRepo.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_ValidEventNoSize_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_leaderboard")).ShouldBeTrue(); + + [Fact] + public void CanHandle_ValidEventWithSize_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_leaderboard 5")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_NoSize_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_leaderboard ")); + + _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithSize10_CallsCommandWithSize10() + { + _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(10)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_leaderboard 10")); + + _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(10), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithInvalidSize_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_leaderboard abc")); + + _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithSize0_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_leaderboard 0")); + + _mockKarmaRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithResult_SendsFormattedResponse() + { + var scores = new List { new("name1", 2), new("name2", 1) }.AsReadOnly(); + _mockKarmaRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(scores); + + await _handler.HandleAsync(CreateMessage("_leaderboard ")); + + _mockBroker.Verify(b => b.SendMessageAsync( + $"name1: 2{Environment.NewLine}name2: 1", + "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_leaderboard -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Leaderboard")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs new file mode 100644 index 0000000..264f80c --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardHandlerTests.cs @@ -0,0 +1,100 @@ +using Bottomly.Repositories; +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 GetLoserBoardHandlerTests +{ + private readonly GetLoserBoardHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + + public GetLoserBoardHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _handler = new GetLoserBoardHandler(_mockKarmaRepo.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_ValidEventNoSize_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_loserboard")).ShouldBeTrue(); + + [Fact] + public void CanHandle_ValidEventWithSize_ReturnsTrue() => + _handler.CanHandle(CreateMessage("_loserboard 5")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_NoSize_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_loserboard ")); + + _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithSize10_CallsCommandWithSize10() + { + _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(10)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_loserboard 10")); + + _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(10), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithInvalidSize_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_loserboard abc")); + + _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithSize0_CallsCommandWithDefaultSize3() + { + _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(new List().AsReadOnly()); + + await _handler.HandleAsync(CreateMessage("_loserboard 0")); + + _mockKarmaRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task HandleAsync_WithResult_SendsFormattedResponse() + { + var scores = new List { new("name1", -2), new("name2", -5) }.AsReadOnly(); + _mockKarmaRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(scores); + + await _handler.HandleAsync(CreateMessage("_loserboard ")); + + _mockBroker.Verify(b => b.SendMessageAsync( + $"name1: -2{Environment.NewLine}name2: -5", + "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_loserboard -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Get Loserboard")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs new file mode 100644 index 0000000..922eb83 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs @@ -0,0 +1,73 @@ +using Bottomly.Commands; +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 GiphyHandlerTests +{ + private readonly GiphyHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GiphyHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object, options); + _handler = new GiphyHandler(_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("_gif cats")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() + { + _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((string?)null); + + await _handler.HandleAsync(CreateMessage("_gif cats")); + + _mockCommand.Verify(c => c.ExecuteAsync("cats"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_WithResult_SendsResult() + { + _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync("https://giphy.com/cat.gif"); + + await _handler.HandleAsync(CreateMessage("_gif cats")); + + _mockBroker.Verify(b => b.SendMessageAsync("https://giphy.com/cat.gif", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_NullResult_SendsNoGifsMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null); + + await _handler.HandleAsync(CreateMessage("_gif xyz")); + + _mockBroker.Verify(b => b.SendMessageAsync("No gifs found for \"xyz\"", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_gif -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Giphy")), "C1", null), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs new file mode 100644 index 0000000..c6b6741 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs @@ -0,0 +1,75 @@ +using Bottomly.Commands; +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 GoogleHandlerTests +{ + private readonly GoogleHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GoogleHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _mockCommand = new Mock(options); + _handler = new GoogleHandler(_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("_g a valid google command")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() + { + _mockCommand.Setup(c => c.ExecuteAsync("some query")).ReturnsAsync((GoogleSearchResult?)null); + + await _handler.HandleAsync(CreateMessage("_g some query")); + + _mockCommand.Verify(c => c.ExecuteAsync("some query"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() + { + _mockCommand.Setup(c => c.ExecuteAsync("dotnet")) + .ReturnsAsync(new GoogleSearchResult("DotNet", "https://dotnet.microsoft.com")); + + await _handler.HandleAsync(CreateMessage("_g dotnet")); + + _mockBroker.Verify(b => b.SendMessageAsync("DotNet https://dotnet.microsoft.com", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null); + + await _handler.HandleAsync(CreateMessage("_g xyz")); + + _mockBroker.Verify(b => b.SendMessageAsync("No results found for \"xyz\"", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_g -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google")), "C1", null), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs new file mode 100644 index 0000000..0727841 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -0,0 +1,75 @@ +using Bottomly.Commands; +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 GoogleImageHandlerTests +{ + private readonly GoogleImageHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GoogleImageHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _mockCommand = new Mock(options); + _handler = new GoogleImageHandler(_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("_gi cats")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => + _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() + { + _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((GoogleSearchResult?)null); + + await _handler.HandleAsync(CreateMessage("_gi cats")); + + _mockCommand.Verify(c => c.ExecuteAsync("cats"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() + { + _mockCommand.Setup(c => c.ExecuteAsync("cats")) + .ReturnsAsync(new GoogleSearchResult("Cute Cat", "https://example.com/cat.jpg")); + + await _handler.HandleAsync(CreateMessage("_gi cats")); + + _mockBroker.Verify(b => b.SendMessageAsync("Cute Cat https://example.com/cat.jpg", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null); + + await _handler.HandleAsync(CreateMessage("_gi xyz")); + + _mockBroker.Verify(b => b.SendMessageAsync("No image results found for \"xyz\"", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_gi -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google Image")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs new file mode 100644 index 0000000..6ffb017 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpHandlerTests.cs @@ -0,0 +1,72 @@ +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 HelpHandlerTests +{ + private readonly Mock _mockBroker = new(); + + private HelpHandler CreateHandler(IEnumerable? handlers = null) + { + var options = TestHelpers.CreateOptions(); + return new HelpHandler( + handlers ?? Enumerable.Empty(), + _mockBroker.Object, + options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U1") => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1" }; + + [Theory] + [InlineData("_help")] + [InlineData("_?")] + [InlineData("_list")] + public void CanHandle_HelpEvent_ReturnsTrue(string text) + { + var handler = CreateHandler(); + handler.CanHandle(CreateMessage(text)).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() + { + var handler = CreateHandler(); + handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ValidEvent_SendsDmWithAllHandlerHelpMessages() + { + var mockH1 = new Mock(); + mockH1.Setup(h => h.BuildHelpMessage()).Returns("Handler1 Help"); + var mockH2 = new Mock(); + mockH2.Setup(h => h.BuildHelpMessage()).Returns("Handler2 Help"); + + var handler = CreateHandler(new[] { mockH1.Object, mockH2.Object }); + var message = CreateMessage("_help"); + + await handler.HandleAsync(message); + + _mockBroker.Verify(b => b.SendDmAsync( + It.Is(s => s.Contains("Handler1 Help") && s.Contains("Handler2 Help")), + "U1"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpForSelf() + { + var handler = CreateHandler(); + + await handler.HandleAsync(CreateMessage("_help -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Help")), "C1", null), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs new file mode 100644 index 0000000..439869b --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs @@ -0,0 +1,66 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; + +public class DecrementMessageKarmaEventHandlerTests +{ + private readonly DecrementMessageKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public DecrementMessageKarmaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new DecrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, + options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U_sender") => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("-- recipient")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_ExecutesCommandAndSendsReaction() + { + _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient")) + .ReturnsAsync(new Member { Username = "recipient" }); + _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _handler.HandleAsync(CreateMessage("-- recipient")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "recipient" && + k.KarmaType == KarmaType.NeggyNeg)), Times.Once()); + _mockBroker.Verify(b => b.SendReactionAsync("robot_face", "C1", "ts1"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + // Help event for karma handlers is "-- -?" (no prefix) + await _handler.HandleAsync(CreateMessage("-- -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Neggy-neg")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs new file mode 100644 index 0000000..7e99892 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs @@ -0,0 +1,66 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; + +public class IncrementMessageKarmaEventHandlerTests +{ + private readonly IncrementMessageKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public IncrementMessageKarmaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, + options, + NullLogger.Instance); + } + + private static MessageEvent CreateMessage(string text, string user = "U_sender") => + new() { Text = text, User = user, Channel = "C1", Ts = "ts1" }; + + [Fact] + public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("++ recipient")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_ExecutesCommandAndSendsReaction() + { + _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient")) + .ReturnsAsync(new Member { Username = "recipient" }); + _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await _handler.HandleAsync(CreateMessage("++ recipient")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "recipient" && + k.KarmaType == KarmaType.PozzyPoz)), Times.Once()); + _mockBroker.Verify(b => b.SendReactionAsync("robot_face", "C1", "ts1"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + // Help event for karma handlers is "++ -?" (no prefix) + await _handler.HandleAsync(CreateMessage("++ -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Pozzy-poz")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs new file mode 100644 index 0000000..120ca15 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs @@ -0,0 +1,137 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers.KarmaEventHandlers; + +public class KarmaHandlerCommandParsingTests +{ + private readonly IncrementMessageKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public KarmaHandlerCommandParsingTests() + { + var options = TestHelpers.CreateOptions(); + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _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())) + .Returns(Task.CompletedTask); + } + + private static MessageEvent CreateMessage(string text) => + new() { Text = text, User = "someuser", Channel = "C1", Ts = "ts1" }; + + [Fact] + public async Task ParseCommandText_ReasonlessOneWordRecipient() + { + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("recipient")) + .ReturnsAsync(new Member { Username = "recipient" }); + + await _handler.HandleAsync(CreateMessage("++ recipient")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "recipient" && k.Reason == "")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonlessOneWordSlackUserRecipient() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("SLACK1")) + .ReturnsAsync(new Member { Username = "slackuser", SlackId = "SLACK1" }); + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("slackuser")) + .ReturnsAsync(new Member { Username = "slackuser" }); + + await _handler.HandleAsync(CreateMessage("++ <@SLACK1>")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "slackuser" && k.Reason == "")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonlessMultiWordRecipient() + { + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("some")).ReturnsAsync((Member?)null); + + await _handler.HandleAsync(CreateMessage("++ some recipient")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "some recipient" && k.Reason == "")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonedOneWordRecipient() + { + // "++ recipient for some reason" - splits on " for " → recipient="recipient", reason="some reason" + await _handler.HandleAsync(CreateMessage("++ recipient for some reason")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "recipient" && k.Reason == "some reason")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonedMultiWordRecipient() + { + // "++ some recipient for some reason" - splits on " for " → recipient="some recipient", reason="some reason" + await _handler.HandleAsync(CreateMessage("++ some recipient for some reason")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "some recipient" && k.Reason == "some reason")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonedUserRecipientNoFor() + { + // "++ username is sexy" - no " for ", first word "username" is a known user → recipient="username", remaining="is sexy" + _mockMemberRepo.Setup(r => r.GetByUsernameAsync("username")).ReturnsAsync(new Member { Username = "username" }); + + await _handler.HandleAsync(CreateMessage("++ username is sexy")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "username" && k.Reason == "is sexy")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonedUserRecipientWithFor() + { + // "++ username for being sexy" - splits on " for " → recipient="username", reason="being sexy" + await _handler.HandleAsync(CreateMessage("++ username for being sexy")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "username" && k.Reason == "being sexy")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_ReasonedSlackIdRecipientWithFor() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("SLACK1")) + .ReturnsAsync(new Member { Username = "slackuser", SlackId = "SLACK1" }); + + await _handler.HandleAsync(CreateMessage("++ <@SLACK1> for being sexy")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "slackuser" && k.Reason == "being sexy")), Times.Once()); + } + + [Fact] + public async Task ParseCommandText_MultipleFor() + { + // "++ username for being sexy and for being nice" → reason="being sexy and for being nice" + await _handler.HandleAsync(CreateMessage("++ username for being sexy and for being nice")); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "username" && k.Reason == "being sexy and for being nice")), Times.Once()); + } +} \ No newline at end of file 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/EventHandlers/ReleaseHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs new file mode 100644 index 0000000..e63c524 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs @@ -0,0 +1,67 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class ReleaseHandlerTests +{ + private readonly ReleaseHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public ReleaseHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var releaseOptions = Options.Create(new BottomlyOptions { GitHubToken = "token" }); + _mockCommand = new Mock(releaseOptions, NullLogger.Instance); + _handler = new ReleaseHandler(_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("_release")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("hello")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_WithResult_SendsResult() + { + _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync("Latest Release: *v1.0* v1.0"); + + await _handler.HandleAsync(CreateMessage("_release")); + + _mockBroker.Verify(b => b.SendMessageAsync("Latest Release: *v1.0* v1.0", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_NullResult_SendsUnableMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync((string?)null); + + await _handler.HandleAsync(CreateMessage("_release")); + + _mockBroker.Verify(b => b.SendMessageAsync("Unable to retrieve latest release info.", "C1", null), + Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_release -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Release")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs new file mode 100644 index 0000000..d9987ba --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs @@ -0,0 +1,76 @@ +using Bottomly.Commands; +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 UrbanHandlerTests +{ + private readonly UrbanHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public UrbanHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object); + _handler = new UrbanHandler(_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("_ud whatever")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() + { + _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync((string?)null); + + await _handler.HandleAsync(CreateMessage("_ud hello")); + + _mockCommand.Verify(c => c.ExecuteAsync("hello"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_WithResult_SendsReplyResponse() + { + _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync("a greeting"); + var message = CreateMessage("_ud hello"); + + await _handler.HandleAsync(message); + + // asReply:true means threadTs = message.Ts = "ts1" + _mockBroker.Verify(b => b.SendMessageAsync("a greeting", "C1", "ts1"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_NullResult_SendsExerciseMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null); + + await _handler.HandleAsync(CreateMessage("_ud xyz")); + + _mockBroker.Verify(b => b.SendMessageAsync("Left as an exercise for the reader.", "C1", "ts1"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_ud -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Urban Dictionary")), "C1", null), + Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs new file mode 100644 index 0000000..f9e0257 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs @@ -0,0 +1,76 @@ +using Bottomly.Commands; +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 WikipediaHandlerTests +{ + private readonly WikipediaHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public WikipediaHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object); + _handler = new WikipediaHandler(_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("_wik octopus")).ShouldBeTrue(); + + [Fact] + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix")).ShouldBeFalse(); + + [Fact] + public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() + { + _mockCommand.Setup(c => c.ExecuteAsync("octopus")).ReturnsAsync((WikipediaResult?)null); + + await _handler.HandleAsync(CreateMessage("_wik octopus")); + + _mockCommand.Verify(c => c.ExecuteAsync("octopus"), Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() + { + _mockCommand.Setup(c => c.ExecuteAsync("octopus")) + .ReturnsAsync(new WikipediaResult("Octopus", "https://en.wikipedia.org/wiki/Octopus")); + + await _handler.HandleAsync(CreateMessage("_wik octopus")); + + _mockBroker.Verify(b => b.SendMessageAsync("Octopus https://en.wikipedia.org/wiki/Octopus", "C1", null), + Times.Once()); + } + + [Fact] + public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + { + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((WikipediaResult?)null); + + await _handler.HandleAsync(CreateMessage("_wik xyz")); + + _mockBroker.Verify(b => b.SendMessageAsync("No results found for \"xyz\"", "C1", null), Times.Once()); + } + + [Fact] + public async Task HandleAsync_HelpEvent_SendsHelpMessage() + { + await _handler.HandleAsync(CreateMessage("_wik -?")); + + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Wikipedia")), "C1", null), + Times.Once()); + } +} \ No newline at end of file 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..0f22447 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -0,0 +1,136 @@ +using Bottomly.LlmBot; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; +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 _mockLlmBroker = 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); + + _handler = new ConversationMessageHandler( + _mockLlmBroker.Object, + _mockSlackBroker.Object, + _mockApiClient.Object, + _mockMemberRepo.Object, + NullLogger.Instance); + } + + 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?")] + [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", []); + 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")); + + _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); + 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")); + + _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()); + } + + [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.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/ReactionHandlers/AddKarmaReactionHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs new file mode 100644 index 0000000..6189f32 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs @@ -0,0 +1,88 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.ReactionHandlers; + +public class AddKarmaReactionHandlerTests +{ + private readonly AddKarmaReactionHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public AddKarmaReactionHandlerTests() + { + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + _handler = new AddKarmaReactionHandler( + command, + new KarmaReactionMap(), + _mockMemberRepo.Object, + _mockBroker.Object, + NullLogger.Instance); + } + + [Fact] + public void ParseReaction_WithSkinTone_StripsSkinTone() + { + // Test via CanHandle: "+1::skin-tone-2" should resolve to "+1" which is in karma reactions + var ev = new ReactionAdded { Reaction = "+1::skin-tone-2" }; + _handler.CanHandle(ev).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_ValidReaction_ReturnsTrue() + { + var ev = new ReactionAdded { Reaction = "joy" }; + _handler.CanHandle(ev).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_ValidReactionWithSkinTone_ReturnsTrue() + { + var ev = new ReactionAdded { Reaction = "+1::skin-tone-2" }; + _handler.CanHandle(ev).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_InvalidReaction_ReturnsFalse() + { + var ev = new ReactionAdded { Reaction = "robot_face" }; + _handler.CanHandle(ev).ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ValidEvent_ExecutesCommandAndSendsReaction() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U_reactor")) + .ReturnsAsync(new Member { Username = "reactor", SlackId = "U_reactor" }); + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U_reactee")) + .ReturnsAsync(new Member { Username = "reactee", SlackId = "U_reactee" }); + _mockKarmaRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + _mockBroker.Setup(b => b.SendReactionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var ev = new ReactionAdded + { + Reaction = "joy", + User = "U_reactor", + ItemUser = "U_reactee", + Item = new ReactionMessage { Channel = "C1", Ts = "ts1" } + }; + + await _handler.HandleAsync(ev); + + _mockKarmaRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "reactee" && + k.AwardedByUsername == "reactor" && + k.KarmaType == KarmaType.PozzyPoz)), Times.Once()); + _mockBroker.Verify(b => b.SendReactionAsync("robot_face", "C1", "ts1"), Times.Once()); + } +} \ No newline at end of file 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.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" }); + } +} diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj new file mode 100644 index 0000000..8be6f38 --- /dev/null +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + 1954dce3-09ab-4d0a-8574-9e7abe8196df + + + + + + + + + + + + + + + + + + + + + + + <_Parameter1>Bottomly.Tests + + + + + + + PreserveNewest + + + + diff --git a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs new file mode 100644 index 0000000..73190a7 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs @@ -0,0 +1,29 @@ +using Bottomly.Models; +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class AddKarmaCommand(IKarmaRepository karmaRepository) : ICommand +{ + public string GetPurpose() => "Awards an imaginary internet point to someone/something."; + + public async Task ExecuteAsync(string awardedTo, string awardedBy, string reason, KarmaType karmaType) + { + if (karmaType == KarmaType.PozzyPoz && awardedBy.Equals(awardedTo, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Can't give yourself positive karma"); + } + + var karma = new Karma + { + AwardedToUsername = awardedTo, + AwardedByUsername = awardedBy, + Reason = reason, + Awarded = DateTime.UtcNow, + KarmaType = karmaType + }; + + await karmaRepository.AddAsync(karma); + return karma; + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GiphyCommand.cs b/bottomly.net/Bottomly/Commands/GiphyCommand.cs new file mode 100644 index 0000000..a15eeb1 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GiphyCommand.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using Bottomly.Configuration; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands; + +public class GiphyCommand(IHttpClientFactory httpClientFactory, IOptions options) + : 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 ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + 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 null; + } + + return data.GetProperty("url").GetString(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs b/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs new file mode 100644 index 0000000..51fa44a --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs @@ -0,0 +1,44 @@ +using Bottomly.Configuration; +using Google.Apis.CustomSearchAPI.v1; +using Google.Apis.Services; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands; + +public class GoogleImageSearchCommand : ICommand +{ + private readonly string _cseId; + private readonly CustomSearchAPIService _service; + + 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 virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return null; + } + + var request = _service.Cse.List(); + request.Q = searchTerm; + request.Cx = _cseId; + request.Num = 1; + request.SearchType = CseResource.ListRequest.SearchTypeEnum.Image; + + var result = await request.ExecuteAsync(); + if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) + { + return null; + } + + var top = result.Items[0]; + return new GoogleSearchResult(top.Title, top.Link); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs new file mode 100644 index 0000000..56e440e --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs @@ -0,0 +1,45 @@ +using Bottomly.Configuration; +using Google.Apis.CustomSearchAPI.v1; +using Google.Apis.Services; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands; + +public record GoogleSearchResult(string Title, string Link); + +public class GoogleSearchCommand : ICommand +{ + private readonly string _cseId; + private readonly CustomSearchAPIService _service; + + 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."; + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return null; + } + + var request = _service.Cse.List(); + request.Q = searchTerm; + request.Cx = _cseId; + request.Num = 1; + + var result = await request.ExecuteAsync(); + if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) + { + return null; + } + + var top = result.Items[0]; + return new GoogleSearchResult(top.Title, top.Link); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/ICommand.cs b/bottomly.net/Bottomly/Commands/ICommand.cs new file mode 100644 index 0000000..60c68cf --- /dev/null +++ b/bottomly.net/Bottomly/Commands/ICommand.cs @@ -0,0 +1,13 @@ +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/Commands/RegSearchCommand.cs b/bottomly.net/Bottomly/Commands/RegSearchCommand.cs new file mode 100644 index 0000000..008a40f --- /dev/null +++ b/bottomly.net/Bottomly/Commands/RegSearchCommand.cs @@ -0,0 +1,58 @@ +using HtmlAgilityPack; + +namespace Bottomly.Commands; + +public class RegSearchCommand(IHttpClientFactory httpClientFactory) : ICommand +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + + public string GetPurpose() => "AutoTrader reg lookup, because Jamie is lazy."; + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return "Registration missing"; + } + + searchTerm = searchTerm.Replace(" ", "").ToLower(); + + if (searchTerm.Length > 7) + { + return "Registration too long."; + } + + if (!searchTerm.All(char.IsLetterOrDigit)) + { + return "Registration should not contain special characters"; + } + + searchTerm = searchTerm.Replace("i", "1"); + + var url = $"https://www.vehiclecheck.co.uk/?vrm={searchTerm}"; + var response = await _httpClient.GetAsync(url); + var html = await response.Content.ReadAsStringAsync(); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + try + { + var colour = doc.GetElementbyId("VehicleColour")?.GetAttributeValue("value", "").Trim(); + var year = doc.GetElementbyId("RegistrationYear")?.GetAttributeValue("value", "").Trim(); + var make = doc.GetElementbyId("VehicleMake")?.GetAttributeValue("value", "").Trim(); + make = make is not null ? char.ToUpper(make[0]) + make[1..].ToLower() : make; + var model = doc.GetElementbyId("VehicleModel")?.GetAttributeValue("value", "").Trim(); + var imgSrc = doc.GetElementbyId("searchResultCarImage")?.GetAttributeValue("src", "").Trim(); + var image = imgSrc is not null ? "https://www.vehiclecheck.co.uk" + imgSrc : ""; + + return $"{colour} {make} {model} ({year}) {image}".Trim(); + } + catch + { + var errorDiv = doc.DocumentNode.SelectSingleNode("//div[contains(@class,'ErrorMessage')]"); + var errorText = errorDiv?.SelectSingleNode(".//h3")?.InnerText ?? "Unknown error"; + return errorText; + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs b/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs new file mode 100644 index 0000000..d870867 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs @@ -0,0 +1,34 @@ +using System.Text.Json; + +namespace Bottomly.Commands; + +public class UrbanSearchCommand(IHttpClientFactory httpClientFactory) : ICommand +{ + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + public string GetPurpose() => "Tells you what something _really_ means."; + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return null; + } + + var url = $"http://api.urbandictionary.com/v0/define?term={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 list = doc.RootElement.GetProperty("list"); + + if (list.GetArrayLength() == 0) + { + return null; + } + + 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 new file mode 100644 index 0000000..eee8d29 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace Bottomly.Commands; + +public record WikipediaResult(string Text, string Link); + +public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICommand +{ + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + public string GetPurpose() => "Performs a wikipedia search and returns the top hit."; + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return null; + } + + var url = + $"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={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 root = doc.RootElement; + + // Response is: [searchTerm, [titles], [descriptions], [links]] + var titles = root[1]; + var links = root[3]; + + if (titles.GetArrayLength() == 0) + { + return null; + } + + return new WikipediaResult(titles[0].GetString()!, links[0].GetString()!); + } +} \ No newline at end of file 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/LlmClient.cs b/bottomly.net/Bottomly/LlmBot/LlmClient.cs new file mode 100644 index 0000000..a555f1a --- /dev/null +++ b/bottomly.net/Bottomly/LlmBot/LlmClient.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Bottomly.LlmBot; + +public interface ILlmClient +{ + Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext); +} + +public class LlmClient(IChatClient chatClient, ILogger logger) : ILlmClient +{ + public async Task Respond(BottomlyInputMessage userPrompt, MessageHistoryContext historyContext) + { + var options = new ChatOptions + { + Temperature = 0.2f + }; + + var fullContext = FullPromptContext.Create(userPrompt, historyContext); + + logger.LogDebug("Sending prompt to LLM: {System}, {Context}, {Prompt}", + FullPromptContext.SystemPrompt.Text, + fullContext.HistoryContext.Text, + fullContext.PromptingMessage.Text); + + 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(string message) => new LlmMessageResponse(message); + 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 IsError(this LlmResponse response) => response is not LlmMessageResponse; +} \ 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/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 new file mode 100644 index 0000000..ecf780e --- /dev/null +++ b/bottomly.net/Bottomly/Models/Member.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Bottomly.Models; + +public class Member +{ + [BsonId] [BsonElement("_id")] public string Username { get; set; } = string.Empty; + + [BsonElement("slack_id")] public string SlackId { 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 +} diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs new file mode 100644 index 0000000..b441261 --- /dev/null +++ b/bottomly.net/Bottomly/Program.cs @@ -0,0 +1,151 @@ +using Bottomly.Seed; +using System.Reflection; +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.LlmBot; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.MembershipEventHandlers; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; +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 +builder.AddMongoDBClient("mongodb"); +builder.Services.AddSingleton(sp => +{ + var client = sp.GetRequiredService(); + return client.GetDatabase("bottomly"); +}); + +// 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; +}); + +var opts = builder.Services.BuildServiceProvider().GetRequiredService>(); + +// HTTP +builder.Services.AddHttpClient(); + +// Repositories +builder.Services.AddSingleton(); +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(sp => sp.GetRequiredService()); + +// LLM Support +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("bottomlymodel_httpClient") + .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(); + +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(); + + +public static class HostBuilderExtensions +{ + extension(HostApplicationBuilder builder) + { + public void RegisterEventHandlers(Assembly assembly) => + assembly.GetTypes() + .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)); + + 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 diff --git a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs new file mode 100644 index 0000000..a367107 --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs @@ -0,0 +1,13 @@ +using Bottomly.Models; + +namespace Bottomly.Repositories; + +public interface IMemberRepository +{ + Task GetByUsernameAsync(string username); + Task GetBySlackIdAsync(string slackId); + 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/KarmaRepository.cs b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs new file mode 100644 index 0000000..033bbc9 --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs @@ -0,0 +1,111 @@ +using Bottomly.Models; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Bottomly.Repositories; + +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.Count != 0 + ? scores[0].NetKarma + : 0; + } + + public async Task GetKarmaReasonsAsync(string recipient) + { + var lower = recipient.ToLower(); + + var pipeline = new[] + { + new BsonDocument("$project", new BsonDocument + { + { "awarded_to_username", new BsonDocument("$toLower", "$awarded_to_username") }, + { "awarded_by_username", new BsonDocument("$toLower", "$awarded_by_username") }, + { "karma_type", "$karma_type" }, + { "awarded", "$awarded" }, + { "reason", "$reason" } + }), + new BsonDocument("$match", new BsonDocument + { + { "awarded_to_username", lower }, + { "awarded", new BsonDocument("$gt", CutOffDate) } + }) + }; + + var results = await _collection.Aggregate(pipeline).ToListAsync(); + + var karmaList = results.Select(r => new Karma + { + AwardedToUsername = r["awarded_to_username"].AsString, + AwardedByUsername = r["awarded_by_username"].AsString, + KarmaTypeValue = r["karma_type"].AsString, + Awarded = r["awarded"].ToUniversalTime(), + Reason = r.Contains("reason") ? r["reason"].AsString : string.Empty + }).ToList(); + + var reasoned = karmaList.Where(k => !string.IsNullOrEmpty(k.Reason)).ToList(); + var reasonless = karmaList.Count(k => string.IsNullOrEmpty(k.Reason)); + + return new KarmaReasonsResult(reasonless, reasoned); + } + + public async Task> GetLeaderBoardAsync(int size = 3) => + await GetNetKarmaAggregateAsync(limit: size, sortOrder: SortOrder.Descending); + + public async Task> GetLoserBoardAsync(int size = 3) => + await GetNetKarmaAggregateAsync(limit: size, sortOrder: SortOrder.Ascending); + + private async Task> GetNetKarmaAggregateAsync( + string? recipient = null, int limit = 3, SortOrder sortOrder = SortOrder.Descending) + { + var matchFilter = new BsonDocument("awarded", new BsonDocument("$gt", CutOffDate)); + if (recipient != null) + { + matchFilter["recipient"] = recipient; + } + + var pipeline = new List + { + new("$project", new BsonDocument + { + { "recipient", new BsonDocument("$toLower", "$awarded_to_username") }, + { + "net_karma", new BsonDocument("$cond", new BsonArray + { + new BsonDocument("$eq", new BsonArray { "$karma_type", nameof(KarmaType.PozzyPoz) }), + 1, -1 + }) + }, + { "awarded", "$awarded" } + }), + new("$match", matchFilter), + new("$group", new BsonDocument + { + { "_id", "$recipient" }, + { "net_karma", new BsonDocument("$sum", "$net_karma") } + }), + new("$sort", new BsonDocument("net_karma", (int)sortOrder)) + }; + + var results = await _collection.Aggregate(pipeline).ToListAsync(); + + return results.Take(limit) + .Select(r => new KarmaScore(r["_id"].AsString, r["net_karma"].AsInt32)) + .ToList(); + } + + private enum SortOrder + { + Ascending = 1, + Descending = -1 + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/MemberRepository.cs b/bottomly.net/Bottomly/Repositories/MemberRepository.cs new file mode 100644 index 0000000..b6dca0f --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/MemberRepository.cs @@ -0,0 +1,31 @@ +using Bottomly.Models; +using MongoDB.Driver; + +namespace Bottomly.Repositories; + +public class MemberRepository(IMongoDatabase database) : IMemberRepository +{ + private readonly IMongoCollection _collection = database.GetCollection("member"); + + public async Task GetByUsernameAsync(string username) => + await _collection.Find(m => m.Username == username).FirstOrDefaultAsync(); + + 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); + + 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/ISlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs new file mode 100644 index 0000000..eb69374 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs @@ -0,0 +1,8 @@ +namespace Bottomly.Slack; + +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 username); +} \ 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..64f4136 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MemberlistPopulator.cs @@ -0,0 +1,27 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using SlackNet; + +namespace Bottomly.Slack; + +public class MemberlistPopulator(ISlackApiClient slack, IMemberRepository memberRepository) +{ + public async Task> PopulateMembers() + { + var owenExists = await memberRepository.GetByUsernameAsync("owen") != null; + if (owenExists) + { + return []; + } + + 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/MembershipEventHandlers/MemberJoinedEventHandler.cs b/bottomly.net/Bottomly/Slack/MembershipEventHandlers/MemberJoinedEventHandler.cs new file mode 100644 index 0000000..8954dbc --- /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/AbstractMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs new file mode 100644 index 0000000..30ffd15 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -0,0 +1,87 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public abstract class AbstractMessageEventHandler( + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : IMessageEventHandler +{ + protected readonly ISlackMessageBroker Broker = broker; + protected readonly ILogger Logger = logger; + protected readonly string Prefix = options.Value.Prefix; + + public abstract string Name { get; } + protected virtual ICommand Command => ICommand.None; + protected abstract string CommandSymbol { get; } + + protected string CommandTrigger => Prefix + CommandSymbol + " "; + public virtual bool CanHandle(MessageEvent message) => message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + public async Task HandleAsync(MessageEvent message) + { + Logger.LogInformation("{Handler} handling: {Text}", GetType().Name, message.Text); + try + { + if (IsHelpEvent(message)) + { + await HandleHelpEventAsync(message); + } + else + { + await InvokeHandlerLogicAsync(message); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error handling event: {Text}", message.Text); + } + } + + public string BuildHelpMessage() + { + var name = Name + Environment.NewLine; + 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(); + protected virtual string GetUsageAddendum() => string.Empty; + + protected virtual bool IsHelpEvent(MessageEvent message) => + message.Text?.Trim() == CommandTrigger.TrimEnd() + " -?"; + + private async Task HandleHelpEventAsync(MessageEvent message) => + await SendMessageResponseAsync(BuildHelpMessage(), message); + + protected async Task SendMessageResponseAsync(string text, MessageEvent message, bool asReply = false) + { + var replyTs = asReply ? message.TsForReply() : null; + + await Broker.SendMessageAsync(text, message.Channel, replyTs); + } + + protected Task SendReactionResponseAsync(MessageEvent message) => + Broker.SendReactionAsync("robot_face", message.Channel, message.Ts); + + 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 new file mode 100644 index 0000000..b3f5330 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -0,0 +1,48 @@ +using Bottomly.LlmBot; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers.ConversationMessageHandling; + +public class ConversationMessageHandler( + ILlmClient llmMessageBroker, + ISlackMessageBroker slackBroker, + ISlackApiClient apiClient, + 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(); + 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 = MessageHistoryContext.Create(contextMessages, userNotes); + + var response = await llmMessageBroker.Respond(mainPrompt, context); + + var replyToTs = response.IsError() ? message.TsForReply() : null; + + await slackBroker.SendMessageAsync(response.ToSlackResponse(), message.Channel, replyToTs); + } + + public string BuildHelpMessage() => string.Empty; +} \ 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..5f7ea55 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ResponseMessageFactory.cs @@ -0,0 +1,66 @@ +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.", + "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 = + [ + "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 = + [ + "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 + { + 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/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs new file mode 100644 index 0000000..fca883f --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentKarmaReasonsHandler.cs @@ -0,0 +1,62 @@ +using System.Text; +using Bottomly.Configuration; +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GetCurrentKarmaReasonsHandler( + IKarmaRepository repository, + SlackParser parser, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Karma Reasons"; + 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) + { + var text = await parser.ReplaceSlackIdTokensWithUsernamesAsync(message.Text!); + var recipient = text[CommandTrigger.Length..].Split(' ')[0]; + if (string.IsNullOrEmpty(recipient)) + { + recipient = message.User; + } + + var result = await repository.GetKarmaReasonsAsync(recipient); + var response = BuildResponse(result, recipient); + await SendDmResponseAsync(response, message); + } + + private static string BuildResponse(KarmaReasonsResult result, string recipient) + { + var sb = new StringBuilder($"Recent Karma for {recipient}:{Environment.NewLine}"); + sb.Append($"Recently awarded with no reason: {result.Reasonless}."); + + if (!result.Reasoned.Any()) + { + sb.AppendLine(); + sb.Append("None awarded with a reason given."); + return sb.ToString(); + } + + foreach (var k in result.Reasoned) + { + sb.AppendLine(); + var symbol = k.KarmaType == KarmaType.PozzyPoz ? "++" : "--"; + sb.Append($"{symbol} from {k.AwardedByUsername} for \"{k.Reason}\""); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs new file mode 100644 index 0000000..70a2daa --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetCurrentNetKarmaHandler.cs @@ -0,0 +1,40 @@ +using Bottomly.Configuration; +using Bottomly.Repositories; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GetCurrentNetKarmaHandler( + IKarmaRepository repository, + KarmaReactionMap reactionMap, + SlackParser parser, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Get Current Karma"; + 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."; + + protected override string GetUsageAddendum() => reactionMap.KarmaReactionDescriptions(); + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var text = await parser.ReplaceSlackIdTokensWithUsernamesAsync(message.Text!); + var recipient = text[CommandTrigger.Length..].Split(' ')[0]; + if (string.IsNullOrEmpty(recipient)) + { + recipient = message.User; + } + + 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 new file mode 100644 index 0000000..a607d19 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLeaderBoardHandler.cs @@ -0,0 +1,36 @@ +using System.Text; +using Bottomly.Configuration; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GetLeaderBoardHandler( + IKarmaRepository repository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Get Leaderboard"; + 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) + { + var sizeArg = message.Text![CommandTrigger.Length..]; + var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; + + var result = await repository.GetLeaderBoardAsync(size); + var sb = new StringBuilder(); + foreach (var entry in result) + { + sb.AppendLine($"{entry.Username}: {entry.NetKarma}"); + } + + await SendMessageResponseAsync(sb.ToString().Trim(), message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs new file mode 100644 index 0000000..36f32cb --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GetLoserBoardHandler.cs @@ -0,0 +1,36 @@ +using System.Text; +using Bottomly.Configuration; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GetLoserBoardHandler( + IKarmaRepository repository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Get Loserboard"; + 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) + { + var sizeArg = message.Text![CommandTrigger.Length..]; + var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; + + var result = await repository.GetLoserBoardAsync(size); + var sb = new StringBuilder(); + foreach (var entry in result) + { + sb.AppendLine($"{entry.Username}: {entry.NetKarma}"); + } + + await SendMessageResponseAsync(sb.ToString().Trim(), message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs new file mode 100644 index 0000000..ba3825b --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GiphyHandler.cs @@ -0,0 +1,28 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GiphyHandler( + GiphyCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Giphy"; + protected override ICommand Command => command; + protected override string CommandSymbol => "gif"; + protected override string GetUsage() => CommandTrigger + ""; + + 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); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs new file mode 100644 index 0000000..a9224ea --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs @@ -0,0 +1,30 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GoogleHandler( + GoogleSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Google"; + protected override ICommand Command => command; + protected override string CommandSymbol => "g"; + protected override string GetUsage() => CommandTrigger + ""; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var query = message.Text![CommandTrigger.Length..]; + var result = await command.ExecuteAsync(query); + var response = result is null + ? $"No results found for \"{query}\"" + : $"{result.Title} {result.Link}"; + await SendMessageResponseAsync(response, message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs new file mode 100644 index 0000000..e7610dc --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs @@ -0,0 +1,30 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class GoogleImageHandler( + GoogleImageSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Google Image"; + protected override ICommand Command => command; + protected override string CommandSymbol => "gi"; + protected override string GetUsage() => CommandTrigger + ""; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var query = message.Text![CommandTrigger.Length..]; + var result = await command.ExecuteAsync(query); + var response = result is null + ? $"No image results found for \"{query}\"" + : $"{result.Title} {result.Link}"; + await SendMessageResponseAsync(response, message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs new file mode 100644 index 0000000..8887f35 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/HelpHandler.cs @@ -0,0 +1,43 @@ +using System.Text; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class HelpHandler( + IEnumerable handlers, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + private static readonly string[] HelpSymbols = ["help", "?", "list"]; + + public override string Name => "Help"; + protected override string CommandSymbol => HelpSymbols[0]; + + protected override string GetUsage() + { + var parts = HelpSymbols.Select(s => $"`{Prefix}{s}`"); + return string.Join(" or ", parts); + } + + public override bool CanHandle(MessageEvent message) + { + var first = message.Text?.Split(' ')[0]; + return HelpSymbols.Any(s => first == Prefix + s); + } + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var sb = new StringBuilder(); + foreach (var handler in handlers) + { + sb.AppendLine(handler.BuildHelpMessage()); + } + + await SendDmResponseAsync(sb.ToString().Trim(), message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs new file mode 100644 index 0000000..0c61c00 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/IMessageEventHandler.cs @@ -0,0 +1,10 @@ +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public interface IMessageEventHandler +{ + bool CanHandle(MessageEvent message); + Task HandleAsync(MessageEvent message); + string BuildHelpMessage(); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs new file mode 100644 index 0000000..7675448 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs @@ -0,0 +1,84 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; + +public abstract class AbstractMessageKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + private const string ForString = " for "; + protected readonly AddKarmaCommand KarmaCommand = command; + + protected override ICommand Command => KarmaCommand; + public abstract override string Name { get; } + protected abstract KarmaType KarmaTypeValue { get; } + + protected override string GetUsage() => + CommandSymbol + " recipient [[for ] reason]"; + + protected override bool IsHelpEvent(MessageEvent message) => + message.Text?.Trim() == CommandSymbol + " -?"; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandSymbol) == true; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var args = await ParseCommandTextAsync(message.Text!); + await KarmaCommand.ExecuteAsync( + args.Recipient, + message.User, + args.Reason, + args.KarmaType); + await SendReactionResponseAsync(message); + } + + private async Task ParseCommandTextAsync(string commandText) + { + commandText = await parser.ReplaceSlackIdTokensWithUsernamesAsync(commandText); + commandText = commandText[CommandSymbol.Length..].TrimStart(); + + var split = commandText.Split(ForString, 2); + string recipient; + if (split.Length > 1) + { + recipient = split[0].Trim(); + } + else + { + recipient = await ParseRecipientAsync(commandText); + } + + var reason = commandText.Replace(recipient, string.Empty, StringComparison.Ordinal); + if (reason.StartsWith(ForString)) + { + reason = reason[ForString.Length..].TrimStart(); + } + else + { + reason = reason.TrimStart(); + } + + return new KarmaArgs(recipient, reason, KarmaTypeValue); + } + + private async Task ParseRecipientAsync(string commandText) + { + var possibleUsername = commandText.Split(' ')[0]; + var member = await memberRepository.GetByUsernameAsync(possibleUsername); + return member is not null ? possibleUsername : commandText; + } + + private record KarmaArgs(string Recipient, string Reason, KarmaType KarmaType); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs new file mode 100644 index 0000000..d8c781f --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandler.cs @@ -0,0 +1,22 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; + +public class DecrementMessageKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageKarmaEventHandler(command, parser, memberRepository, broker, options, logger) +{ + public override string Name => "Neggy-neg"; + protected override string CommandSymbol => "--"; + protected override KarmaType KarmaTypeValue => KarmaType.NeggyNeg; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs new file mode 100644 index 0000000..397f163 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandler.cs @@ -0,0 +1,22 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Models; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Slack.MessageEventHandlers.KarmaEventHandlers; + +public class IncrementMessageKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageKarmaEventHandler(command, parser, memberRepository, broker, options, logger) +{ + public override string Name => "Pozzy-poz"; + protected override string CommandSymbol => "++"; + protected override KarmaType KarmaTypeValue => KarmaType.PozzyPoz; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs new file mode 100644 index 0000000..f1069ec --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/RegHandler.cs @@ -0,0 +1,27 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class RegHandler( + RegSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Reg Lookup"; + protected override ICommand Command => command; + protected override string CommandSymbol => "reg"; + protected override string GetUsage() => CommandTrigger + ""; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var plate = message.Text![CommandTrigger.Length..]; + var result = await command.ExecuteAsync(plate); + await SendMessageResponseAsync(result, message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs new file mode 100644 index 0000000..ec8cc69 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs @@ -0,0 +1,27 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class ReleaseHandler( + ReleaseCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Release"; + protected override ICommand Command => command; + protected override string CommandSymbol => "release"; + protected override string GetUsage() => CommandTrigger.TrimEnd(); + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var result = await command.ExecuteAsync(); + var response = result ?? "Unable to retrieve latest release info."; + await SendMessageResponseAsync(response, message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs new file mode 100644 index 0000000..fed0ccb --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/TestHandler.cs @@ -0,0 +1,19 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class TestHandler( + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Test"; + 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/MessageEventHandlers/UrbanHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs new file mode 100644 index 0000000..7745bfb --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs @@ -0,0 +1,28 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class UrbanHandler( + UrbanSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Urban Dictionary"; + protected override ICommand Command => command; + protected override string CommandSymbol => "ud"; + protected override string GetUsage() => CommandTrigger + ""; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var term = message.Text![CommandTrigger.Length..]; + var result = await command.ExecuteAsync(term); + var response = result ?? "Left as an exercise for the reader."; + await SendMessageResponseAsync(response, message, true); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs new file mode 100644 index 0000000..db76922 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs @@ -0,0 +1,30 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.MessageEventHandlers; + +public class WikipediaHandler( + WikipediaSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractMessageEventHandler(broker, options, logger) +{ + public override string Name => "Wikipedia"; + protected override ICommand Command => command; + protected override string CommandSymbol => "wik"; + protected override string GetUsage() => CommandTrigger + ""; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var term = message.Text![CommandTrigger.Length..]; + var result = await command.ExecuteAsync(term); + var response = result is null + ? $"No results found for \"{term}\"" + : $"{result.Text} {result.Link}"; + await SendMessageResponseAsync(response, message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs new file mode 100644 index 0000000..55a11a7 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs @@ -0,0 +1,57 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Slack.ReactionHandlers; + +public class AddKarmaReactionHandler( + AddKarmaCommand command, + KarmaReactionMap reactionMap, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + ILogger logger) + : IReactionHandler +{ + public bool CanHandle(ReactionAdded reactionEvent) + { + var reaction = ParseReaction(reactionEvent.Reaction); + return reactionMap.IsKarmaReaction(reaction); + } + + public async Task HandleAsync(ReactionAdded reactionEvent) + { + try + { + var reaction = ParseReaction(reactionEvent.Reaction); + + var reactor = await memberRepository.GetBySlackIdAsync(reactionEvent.User); + var reactee = await memberRepository.GetBySlackIdAsync(reactionEvent.ItemUser); + + if (reactor is null || reactee is null) + { + logger.LogWarning("Could not resolve reactor ({ReactorId}) or reactee ({ReacteeId})", + reactionEvent.User, reactionEvent.ItemUser); + return; + } + + await command.ExecuteAsync( + reactee.Username, + reactor.Username, + $"Reacted with {reaction}", + reactionMap.GetKarmaType(reaction)); + + if (reactionEvent.Item is ReactionMessage messageItem) + { + await broker.SendReactionAsync("robot_face", messageItem.Channel, messageItem.Ts); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling reaction {Reaction}", reactionEvent.Reaction); + } + } + + private static string ParseReaction(string rawReaction) => rawReaction.Split("::")[0]; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs new file mode 100644 index 0000000..3bce6cc --- /dev/null +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/KarmaReactionMap.cs @@ -0,0 +1,48 @@ +using System.Text; +using Bottomly.Models; + +namespace Bottomly.Slack.ReactionHandlers; + +public class KarmaReactionMap +{ + private Dictionary Map { get; } = 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 + }; + + + 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/SlackEventDispatchers.cs b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs new file mode 100644 index 0000000..49dd968 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs @@ -0,0 +1,27 @@ +using Bottomly.Slack.MembershipEventHandlers; +using SlackNet; +using SlackNet.Events; + +namespace Bottomly.Slack; + +/// +/// Bridges SlackNet's IEventHandler<MessageEvent> to our SlackWorker message processing pipeline. +/// +public class SlackMessageEventDispatcher(SlackWorker worker) : IEventHandler +{ + public Task Handle(MessageEvent slackEvent) => worker.ProcessMessageAsync(slackEvent); +} + +/// +/// Bridges SlackNet's IEventHandler<ReactionAdded> to our SlackWorker reaction processing pipeline. +/// +public class SlackReactionEventDispatcher(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/SlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs new file mode 100644 index 0000000..582f412 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs @@ -0,0 +1,77 @@ +using Bottomly.Configuration; +using Bottomly.Repositories; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet; +using SlackNet.WebApi; + +namespace Bottomly.Slack; + +public class SlackMessageBroker( + IMemberRepository repository, + ISlackApiClient slack, + IOptions options, + ILogger logger) + : ISlackMessageBroker +{ + private readonly BottomlyOptions _options = options.Value; + + public async Task SendMessageAsync(string text, string channel, string? replyToTs = null) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + if (_options.IsDebug) + { + text = $"[{_options.Environment}] {text}"; + } + + try + { + var message = new Message + { + Channel = channel, + Text = text, + ThreadTs = replyToTs + }; + await slack.Chat.PostMessage(message); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending message to channel {Channel}", channel); + } + } + + public async Task SendReactionAsync(string emoji, string channel, string timestamp) + { + try + { + await slack.Reactions.AddToMessage(emoji, channel, timestamp); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending reaction to channel {Channel}", channel); + } + } + + public async Task SendDmAsync(string text, string username) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + try + { + 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}", username); + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackParser.cs b/bottomly.net/Bottomly/Slack/SlackParser.cs new file mode 100644 index 0000000..a37d3fe --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackParser.cs @@ -0,0 +1,33 @@ +using System.Text.RegularExpressions; +using Bottomly.Repositories; + +namespace Bottomly.Slack; + +public partial class SlackParser(IMemberRepository memberRepository) +{ + private static readonly Regex SlackIdPattern = SlackIdRegex(); + + public async Task ReplaceSlackIdTokensWithUsernamesAsync(string message) + { + var matches = SlackIdPattern.Matches(message); + if (matches.Count == 0) + { + return message; + } + + foreach (Match match in matches) + { + var slackId = match.Groups[1].Value; + var member = await memberRepository.GetBySlackIdAsync(slackId); + if (member is not null) + { + message = message.Replace(match.Value, member.Username); + } + } + + 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 new file mode 100644 index 0000000..444d4b1 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -0,0 +1,105 @@ +using Bottomly.LlmBot; +using Bottomly.Repositories; +using Bottomly.Slack.MessageEventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SlackNet; +using SlackNet.Events; +using SlackNet.SocketMode; + +namespace Bottomly.Slack; + +public class SlackWorker( + ISlackSocketModeClient socketClient, + IEnumerable eventHandlers, + HelpHandler helpMessageHandler, + IEnumerable reactionHandlers, + IMemberRepository memberRepository, + ILogger logger) + : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Starting Slack Socket Mode connection..."); + + await socketClient.Connect(new SocketModeConnectionOptions(), stoppingToken); + + logger.LogInformation("Connected to Slack."); + + // Keep alive until cancellation + await Task.Delay(Timeout.Infinite, stoppingToken); + } + + public async Task ProcessMessageAsync(MessageEvent message) + { + try + { + if (!IsSubscribedMessage(message)) + { + return; + } + + await ResolveUsernameAsync(message); + + // Help handler takes priority + if (helpMessageHandler.CanHandle(message)) + { + await helpMessageHandler.HandleAsync(message); + return; + } + + foreach (var handler in eventHandlers) + { + if (!handler.CanHandle(message)) + { + continue; + } + + await handler.HandleAsync(message); + return; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing Slack message: {Text}", message.Text); + } + } + + public async Task ProcessReactionAsync(ReactionAdded reaction) + { + try + { + foreach (var handler in reactionHandlers) + { + if (handler.CanHandle(reaction)) + { + await handler.HandleAsync(reaction); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing reaction: {Reaction}", reaction.Reaction); + } + } + + private static bool IsSubscribedMessage(MessageEvent message) => + !string.IsNullOrEmpty(message.Text) && string.IsNullOrEmpty(message.BotId); + + private async Task ResolveUsernameAsync(MessageEvent message) + { + try + { + var member = await memberRepository.GetBySlackIdAsync(message.User); + if (member is not null) + { + message.User = member.Username; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not resolve username for Slack ID {UserId}", message.User); + } + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/appsettings.json b/bottomly.net/Bottomly/appsettings.json new file mode 100644 index 0000000..ea4dd90 --- /dev/null +++ b/bottomly.net/Bottomly/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "bottomly_env": "debug", + "ImportMemberSeedData": true +} \ No newline at end of file diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx new file mode 100644 index 0000000..87d8a19 --- /dev/null +++ b/bottomly.net/bottomly.net.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + +