From 4e4001eedb18d929e405bc1f8374a305f160bf38 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Sat, 28 Feb 2026 15:01:25 +0000 Subject: [PATCH 01/13] Adds .net gitignore --- bottomly.net/.gitignore | 512 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 bottomly.net/.gitignore diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore new file mode 100644 index 0000000..434bfbe --- /dev/null +++ b/bottomly.net/.gitignore @@ -0,0 +1,512 @@ +# .NET and Visual Studio .gitignore + +# 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 From 5199c661e1d42f7ce26706741057f638da357c4c Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Sat, 28 Feb 2026 15:01:29 +0000 Subject: [PATCH 02/13] Adds initial aspire scaffolding --- bottomly.net/.aspire/settings.json | 3 + bottomly.net/.editorconfig | 95 +++++++++++++ .../Bottomly.AppHost/.aspire/settings.json | 3 + bottomly.net/Bottomly.AppHost/AppHost.cs | 17 +++ .../Bottomly.AppHost/Bottomly.AppHost.csproj | 20 +++ .../Properties/launchSettings.json | 31 +++++ .../Bottomly.AppHost/apphost.run.json | 31 +++++ .../appsettings.Development.json | 8 ++ .../Bottomly.AppHost/appsettings.json | 9 ++ .../Bottomly.ServiceDefaults.csproj | 22 +++ .../Bottomly.ServiceDefaults/Extensions.cs | 127 ++++++++++++++++++ bottomly.net/Bottomly/Bottomly.csproj | 18 +++ bottomly.net/Bottomly/Program.cs | 8 ++ bottomly.net/bottomly.net.slnx | 9 ++ 14 files changed, 401 insertions(+) create mode 100644 bottomly.net/.aspire/settings.json create mode 100644 bottomly.net/.editorconfig create mode 100644 bottomly.net/Bottomly.AppHost/.aspire/settings.json create mode 100644 bottomly.net/Bottomly.AppHost/AppHost.cs create mode 100644 bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj create mode 100644 bottomly.net/Bottomly.AppHost/Properties/launchSettings.json create mode 100644 bottomly.net/Bottomly.AppHost/apphost.run.json create mode 100644 bottomly.net/Bottomly.AppHost/appsettings.Development.json create mode 100644 bottomly.net/Bottomly.AppHost/appsettings.json create mode 100644 bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj create mode 100644 bottomly.net/Bottomly.ServiceDefaults/Extensions.cs create mode 100644 bottomly.net/Bottomly/Bottomly.csproj create mode 100644 bottomly.net/Bottomly/Program.cs create mode 100644 bottomly.net/bottomly.net.slnx diff --git a/bottomly.net/.aspire/settings.json b/bottomly.net/.aspire/settings.json new file mode 100644 index 0000000..1b8fbc8 --- /dev/null +++ b/bottomly.net/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../Bottomly.AppHost/Bottomly.AppHost.csproj" +} \ No newline at end of file diff --git a/bottomly.net/.editorconfig b/bottomly.net/.editorconfig new file mode 100644 index 0000000..072884d --- /dev/null +++ b/bottomly.net/.editorconfig @@ -0,0 +1,95 @@ +[*] +charset = utf-8 +end_of_line = crlf +trim_trailing_whitespace = false +insert_final_newline = false +indent_style = space +indent_size = 4 + +# Microsoft .NET properties +csharp_new_line_before_members_in_object_initializers = false +csharp_prefer_braces = true:warning +csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_using_directive_placement = outside_namespace:silent +csharp_style_expression_bodied_methods = true:warning +csharp_style_expression_bodied_constructors = true:warning +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning +dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True +dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field +dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef +dotnet_naming_rule.unity_serialized_field_rule.severity = warning +dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style +dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols +dotnet_naming_style.lower_camel_case_style.capitalization = camel_case +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * +dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field +dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# ReSharper properties +resharper_apply_auto_detected_rules = false +resharper_autodetect_indent_settings = true +resharper_braces_for_for = required +resharper_braces_for_foreach = required +resharper_braces_for_ifelse = required +resharper_braces_for_while = required +resharper_formatter_off_tag = @formatter:off +resharper_formatter_on_tag = @formatter:on +resharper_formatter_tags_enabled = true +resharper_show_autodetect_configure_formatting_tip = false +resharper_use_indent_from_vs = false + +# ReSharper inspection severities +resharper_arrange_redundant_parentheses_highlighting = hint +resharper_arrange_this_qualifier_highlighting = hint +resharper_arrange_type_member_modifiers_highlighting = hint +resharper_arrange_type_modifiers_highlighting = hint +resharper_built_in_type_reference_style_for_member_access_highlighting = hint +resharper_built_in_type_reference_style_highlighting = hint +resharper_mvc_action_not_resolved_highlighting = warning +resharper_mvc_area_not_resolved_highlighting = warning +resharper_mvc_controller_not_resolved_highlighting = warning +resharper_mvc_masterpage_not_resolved_highlighting = warning +resharper_mvc_partial_view_not_resolved_highlighting = warning +resharper_mvc_template_not_resolved_highlighting = warning +resharper_mvc_view_component_not_resolved_highlighting = warning +resharper_mvc_view_component_view_not_resolved_highlighting = warning +resharper_mvc_view_not_resolved_highlighting = warning +resharper_razor_assembly_not_resolved_highlighting = warning +resharper_redundant_base_qualifier_highlighting = warning +resharper_suggest_var_or_type_built_in_types_highlighting = hint +resharper_suggest_var_or_type_elsewhere_highlighting = hint +resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning + +[{*.yaml,*.yml}] +indent_style = space +indent_size = 2 + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] +indent_style = space +indent_size = 2 + +[*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,build,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,feature,fs,fsi,fsscript,fsx,fx,fxh,h,h++,hh,hlsl,hlsli,hlslinc,hp,hpp,hxx,icc,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,razor,resw,resx,shader,shaderFoundry,skin,tcc,tpp,urtshader,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] +indent_style = space +indent_size = 4 +tab_width = 4 diff --git a/bottomly.net/Bottomly.AppHost/.aspire/settings.json b/bottomly.net/Bottomly.AppHost/.aspire/settings.json new file mode 100644 index 0000000..283e445 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../Bottomly.AppHost.csproj" +} \ 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..9eb9c71 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Hosting; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var mongo = builder.AddMongoDB("mongo") + .WithLifetime(ContainerLifetime.Persistent); + +var mongodb = mongo.AddDatabase("mongodb"); + +var bottomly = builder.AddProject("bottomly") + .WaitFor(mongodb) + .WithReference(mongodb); + +var app = builder.Build(); + +app.Run(); diff --git a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj new file mode 100644 index 0000000..31e4056 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + 5085e312-407d-49ea-8b7c-815877981360 + + + + + + + + + + + + diff --git a/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json b/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..abda127 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17281;http://localhost:15221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23133", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22202" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19053", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18014", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20264" + } + } + } +} diff --git a/bottomly.net/Bottomly.AppHost/apphost.run.json b/bottomly.net/Bottomly.AppHost/apphost.run.json new file mode 100644 index 0000000..f293972 --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/apphost.run.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17219;http://localhost:15148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21009", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23079", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22198" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15148", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19120", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18106", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20045" + } + } + } +} diff --git a/bottomly.net/Bottomly.AppHost/appsettings.Development.json b/bottomly.net/Bottomly.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/bottomly.net/Bottomly.AppHost/appsettings.json b/bottomly.net/Bottomly.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/bottomly.net/Bottomly.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj b/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj new file mode 100644 index 0000000..eeb71e3 --- /dev/null +++ b/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs b/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000..b72c875 --- /dev/null +++ b/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj new file mode 100644 index 0000000..efc357b --- /dev/null +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs new file mode 100644 index 0000000..cc1a152 --- /dev/null +++ b/bottomly.net/Bottomly/Program.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(); + +builder.AddServiceDefaults(); + +builder.Build().Run(); + diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx new file mode 100644 index 0000000..6fb4ddd --- /dev/null +++ b/bottomly.net/bottomly.net.slnx @@ -0,0 +1,9 @@ + + + + + + + + + From ce48582df735e3f93a3f6cdc9bf4059c8ec89c28 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Sat, 28 Feb 2026 17:54:29 +0000 Subject: [PATCH 03/13] Initial port. Yes this was done by AI. --- bottomly.net/Bottomly.AppHost/AppHost.cs | 6 +- .../Bottomly.AppHost/Bottomly.AppHost.csproj | 27 ++-- .../Bottomly.ServiceDefaults.csproj | 32 ++--- .../Bottomly.ServiceDefaults/Extensions.cs | 12 +- .../Bottomly.Tests/Bottomly.Tests.csproj | 29 ++++ .../Commands/AddKarmaCommandTests.cs | 42 ++++++ .../GetCurrentKarmaReasonsCommandTests.cs | 24 ++++ .../GetCurrentNetKarmaCommandTests.cs | 22 +++ .../Commands/GetLeaderBoardCommandTests.cs | 38 +++++ .../Commands/GetLoserBoardCommandTests.cs | 38 +++++ .../Commands/GiphyCommandTests.cs | 22 +++ .../Commands/GoogleImageSearchCommandTests.cs | 20 +++ .../Commands/GoogleSearchCommandTests.cs | 20 +++ .../Commands/UrbanSearchCommandTests.cs | 19 +++ .../Commands/WikipediaSearchCommandTests.cs | 19 +++ .../Bottomly.Tests/Helpers/TestHelpers.cs | 12 ++ ...GetCurrentKarmaReasonsEventHandlerTests.cs | 103 +++++++++++++ .../GetCurrentNetKarmaEventHandlerTests.cs | 85 +++++++++++ .../GetLeaderBoardEventHandlerTests.cs | 102 +++++++++++++ .../GetLoserBoardEventHandlerTests.cs | 102 +++++++++++++ .../EventHandlers/GiphyEventHandlerTests.cs | 73 ++++++++++ .../EventHandlers/GoogleEventHandlerTests.cs | 75 ++++++++++ .../GoogleImageEventHandlerTests.cs | 75 ++++++++++ .../EventHandlers/HelpEventHandlerTests.cs | 72 ++++++++++ .../DecrementKarmaEventHandlerTests.cs | 65 +++++++++ .../IncrementKarmaEventHandlerTests.cs | 65 +++++++++ .../KarmaHandlerCommandParsingTests.cs | 136 ++++++++++++++++++ .../EventHandlers/ReleaseEventHandlerTests.cs | 67 +++++++++ .../EventHandlers/UrbanEventHandlerTests.cs | 76 ++++++++++ .../WikipediaEventHandlerTests.cs | 76 ++++++++++ .../AddKarmaReactionHandlerTests.cs | 84 +++++++++++ .../Bottomly.Tests/Slack/SlackParserTests.cs | 47 ++++++ bottomly.net/Bottomly/Bottomly.csproj | 12 +- .../Bottomly/Commands/AddKarmaCommand.cs | 53 +++++++ .../Commands/GetCurrentKarmaReasonsCommand.cs | 12 ++ .../Commands/GetCurrentNetKarmaCommand.cs | 11 ++ .../Commands/GetLeaderBoardCommand.cs | 11 ++ .../Bottomly/Commands/GetLoserBoardCommand.cs | 11 ++ .../Bottomly/Commands/GiphyCommand.cs | 37 +++++ .../Commands/GoogleImageSearchCommand.cs | 38 +++++ .../Bottomly/Commands/GoogleSearchCommand.cs | 39 +++++ bottomly.net/Bottomly/Commands/ICommand.cs | 6 + .../Bottomly/Commands/RegSearchCommand.cs | 58 ++++++++ .../Bottomly/Commands/ReleaseCommand.cs | 42 ++++++ .../Bottomly/Commands/UrbanSearchCommand.cs | 34 +++++ .../Commands/WikipediaSearchCommand.cs | 39 +++++ .../Bottomly/Configuration/BottomlyOptions.cs | 15 ++ bottomly.net/Bottomly/Models/Karma.cs | 30 ++++ bottomly.net/Bottomly/Models/KarmaType.cs | 7 + bottomly.net/Bottomly/Models/Member.cs | 10 ++ bottomly.net/Bottomly/Program.cs | 105 +++++++++++++- .../Bottomly/Repositories/IKarmaRepository.cs | 16 +++ .../Repositories/IMemberRepository.cs | 10 ++ .../Bottomly/Repositories/KarmaRepository.cs | 107 ++++++++++++++ .../Bottomly/Repositories/MemberRepository.cs | 23 +++ .../EventHandlers/AbstractEventHandler.cs | 85 +++++++++++ .../GetCurrentKarmaReasonsEventHandler.cs | 63 ++++++++ .../GetCurrentNetKarmaEventHandler.cs | 51 +++++++ .../GetLeaderBoardEventHandler.cs | 39 +++++ .../GetLoserBoardEventHandler.cs | 39 +++++ .../Slack/EventHandlers/GiphyEventHandler.cs | 31 ++++ .../Slack/EventHandlers/GoogleEventHandler.cs | 33 +++++ .../EventHandlers/GoogleImageEventHandler.cs | 33 +++++ .../Slack/EventHandlers/HelpEventHandler.cs | 45 ++++++ .../Slack/EventHandlers/IEventHandler.cs | 10 ++ .../AbstractKarmaEventHandler.cs | 83 +++++++++++ .../DecrementKarmaEventHandler.cs | 22 +++ .../IncrementKarmaEventHandler.cs | 22 +++ .../Slack/EventHandlers/RegEventHandler.cs | 30 ++++ .../EventHandlers/ReleaseEventHandler.cs | 30 ++++ .../Slack/EventHandlers/UrbanEventHandler.cs | 31 ++++ .../EventHandlers/WikipediaEventHandler.cs | 33 +++++ .../Bottomly/Slack/ISlackMessageBroker.cs | 8 ++ .../AddKarmaReactionHandler.cs | 57 ++++++++ .../ReactionHandlers/IReactionHandler.cs | 9 ++ .../Bottomly/Slack/SlackEventDispatchers.cs | 20 +++ .../Bottomly/Slack/SlackMessageBroker.cs | 74 ++++++++++ bottomly.net/Bottomly/Slack/SlackParser.cs | 30 ++++ bottomly.net/Bottomly/Slack/SlackWorker.cs | 102 +++++++++++++ bottomly.net/Bottomly/appsettings.json | 10 ++ bottomly.net/bottomly.net.slnx | 15 +- 81 files changed, 3367 insertions(+), 49 deletions(-) create mode 100644 bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj create mode 100644 bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs create mode 100644 bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs create mode 100644 bottomly.net/Bottomly/Commands/AddKarmaCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GiphyCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/ICommand.cs create mode 100644 bottomly.net/Bottomly/Commands/RegSearchCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/ReleaseCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs create mode 100644 bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs create mode 100644 bottomly.net/Bottomly/Configuration/BottomlyOptions.cs create mode 100644 bottomly.net/Bottomly/Models/Karma.cs create mode 100644 bottomly.net/Bottomly/Models/KarmaType.cs create mode 100644 bottomly.net/Bottomly/Models/Member.cs create mode 100644 bottomly.net/Bottomly/Repositories/IKarmaRepository.cs create mode 100644 bottomly.net/Bottomly/Repositories/IMemberRepository.cs create mode 100644 bottomly.net/Bottomly/Repositories/KarmaRepository.cs create mode 100644 bottomly.net/Bottomly/Repositories/MemberRepository.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs create mode 100644 bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs create mode 100644 bottomly.net/Bottomly/Slack/SlackMessageBroker.cs create mode 100644 bottomly.net/Bottomly/Slack/SlackParser.cs create mode 100644 bottomly.net/Bottomly/Slack/SlackWorker.cs create mode 100644 bottomly.net/Bottomly/appsettings.json diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs index 9eb9c71..b20a925 100644 --- a/bottomly.net/Bottomly.AppHost/AppHost.cs +++ b/bottomly.net/Bottomly.AppHost/AppHost.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Configuration; using Projects; var builder = DistributedApplication.CreateBuilder(args); +builder.Configuration.AddUserSecrets(); + var mongo = builder.AddMongoDB("mongo") .WithLifetime(ContainerLifetime.Persistent); @@ -14,4 +16,4 @@ var app = builder.Build(); -app.Run(); +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 index 31e4056..dbd9fc3 100644 --- a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj +++ b/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj @@ -1,20 +1,19 @@ - - Exe - net10.0 - enable - enable - 5085e312-407d-49ea-8b7c-815877981360 - + + Exe + net10.0 + enable + enable + 5085e312-407d-49ea-8b7c-815877981360 + - - - - + + + - - - + + + diff --git a/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj b/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj index eeb71e3..938db20 100644 --- a/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj +++ b/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj @@ -1,22 +1,22 @@ - - net10.0 - enable - enable - true - + + net10.0 + enable + enable + true + - - + + - - - - - - - - + + + + + + + + diff --git a/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs b/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs index b72c875..b3f7b27 100644 --- a/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs +++ b/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -44,7 +43,8 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where return builder; } - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { builder.Logging.AddOpenTelemetry(logging => { @@ -78,7 +78,8 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w return builder; } - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); @@ -97,7 +98,8 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde return builder; } - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder { builder.Services.AddHealthChecks() // Add a default liveness check to ensure app is responsive @@ -124,4 +126,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) return app; } -} +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj b/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj new file mode 100644 index 0000000..3d5b464 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + daba8c84-3cbb-425d-9103-50c95448cdb3 + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs new file mode 100644 index 0000000..87850ec --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs @@ -0,0 +1,42 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class AddKarmaCommandTests +{ + private readonly AddKarmaCommand _command; + private readonly Mock _mockRepo = new(); + + public AddKarmaCommandTests() => _command = new AddKarmaCommand(_mockRepo.Object); + + [Fact] + public async Task ExecuteAsync_AwardsKarma_PersistsToRepository() + { + _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + await _command.ExecuteAsync("alice", "bob", "great job", KarmaType.PozzyPoz); + + _mockRepo.Verify(r => r.AddAsync(It.Is(k => + k.AwardedToUsername == "alice" && + k.AwardedByUsername == "bob" && + k.Reason == "great job" && + k.KarmaType == KarmaType.PozzyPoz)), Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_SelfPositiveKarma_ThrowsInvalidOperation() => + await Should.ThrowAsync(() => + _command.ExecuteAsync("alice", "alice", "", KarmaType.PozzyPoz)); + + [Fact] + public async Task ExecuteAsync_SelfNegativeKarma_DoesNotThrow() + { + _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + + await Should.NotThrowAsync(() => _command.ExecuteAsync("alice", "alice", "", KarmaType.NeggyNeg)); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs new file mode 100644 index 0000000..fafdc0f --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs @@ -0,0 +1,24 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GetCurrentKarmaReasonsCommandTests +{ + [Fact] + public async Task ExecuteAsync_DelegatesToRepository() + { + var mockRepo = new Mock(); + var expected = new KarmaReasonsResult(2, new List().AsReadOnly()); + mockRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(expected); + var command = new GetCurrentKarmaReasonsCommand(mockRepo.Object); + + var result = await command.ExecuteAsync("alice"); + + result.ShouldBe(expected); + mockRepo.Verify(r => r.GetKarmaReasonsAsync("alice"), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs new file mode 100644 index 0000000..61698cd --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs @@ -0,0 +1,22 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GetCurrentNetKarmaCommandTests +{ + [Fact] + public async Task ExecuteAsync_DelegatesToRepository() + { + var mockRepo = new Mock(); + mockRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(5); + var command = new GetCurrentNetKarmaCommand(mockRepo.Object); + + var result = await command.ExecuteAsync("alice"); + + result.ShouldBe(5); + mockRepo.Verify(r => r.GetCurrentNetKarmaAsync("alice"), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs new file mode 100644 index 0000000..8fd2711 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs @@ -0,0 +1,38 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GetLeaderBoardCommandTests +{ + private readonly GetLeaderBoardCommand _command; + private readonly Mock _mockRepo = new(); + + public GetLeaderBoardCommandTests() => _command = new GetLeaderBoardCommand(_mockRepo.Object); + + [Fact] + public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() + { + var scores = new List { new("alice", 5) }.AsReadOnly(); + _mockRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(scores); + + var result = await _command.ExecuteAsync(); + + result.ShouldBe(scores); + _mockRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() + { + var scores = new List { new("alice", 5) }.AsReadOnly(); + _mockRepo.Setup(r => r.GetLeaderBoardAsync(10)).ReturnsAsync(scores); + + var result = await _command.ExecuteAsync(10); + + result.ShouldBe(scores); + _mockRepo.Verify(r => r.GetLeaderBoardAsync(10), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs new file mode 100644 index 0000000..23ea503 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs @@ -0,0 +1,38 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GetLoserBoardCommandTests +{ + private readonly GetLoserBoardCommand _command; + private readonly Mock _mockRepo = new(); + + public GetLoserBoardCommandTests() => _command = new GetLoserBoardCommand(_mockRepo.Object); + + [Fact] + public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() + { + var scores = new List { new("alice", -5) }.AsReadOnly(); + _mockRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(scores); + + var result = await _command.ExecuteAsync(); + + result.ShouldBe(scores); + _mockRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); + } + + [Fact] + public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() + { + var scores = new List { new("alice", -5) }.AsReadOnly(); + _mockRepo.Setup(r => r.GetLoserBoardAsync(10)).ReturnsAsync(scores); + + var result = await _command.ExecuteAsync(10); + + result.ShouldBe(scores); + _mockRepo.Verify(r => r.GetLoserBoardAsync(10), Times.Once()); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs new file mode 100644 index 0000000..1684912 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs @@ -0,0 +1,22 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +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(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs new file mode 100644 index 0000000..6b23fe4 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs @@ -0,0 +1,20 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GoogleImageSearchCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); + var command = new GoogleImageSearchCommand(options); + + var result = await command.ExecuteAsync(""); + + result.ShouldBeNull(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs new file mode 100644 index 0000000..e5b8761 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs @@ -0,0 +1,20 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class GoogleSearchCommandTests +{ + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); + var command = new GoogleSearchCommand(options); + + var result = await command.ExecuteAsync(""); + + result.ShouldBeNull(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs new file mode 100644 index 0000000..d6f3680 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs @@ -0,0 +1,19 @@ +using Bottomly.Commands; +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(); + } +} \ 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..6830330 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs @@ -0,0 +1,19 @@ +using Bottomly.Commands; +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(); + } +} \ 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..0334a01 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs @@ -0,0 +1,12 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Options; + +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 }); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs new file mode 100644 index 0000000..b6d71e0 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs @@ -0,0 +1,103 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GetCurrentKarmaReasonsEventHandlerTests +{ + private readonly GetCurrentKarmaReasonsEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public GetCurrentKarmaReasonsEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new GetCurrentKarmaReasonsCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new GetCurrentKarmaReasonsEventHandler(command, parser, _mockBroker.Object, options, + NullLogger.Instance); + } + + 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/GetCurrentNetKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs new file mode 100644 index 0000000..d61c11a --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs @@ -0,0 +1,85 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GetCurrentNetKarmaEventHandlerTests +{ + private readonly GetCurrentNetKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public GetCurrentNetKarmaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new GetCurrentNetKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new GetCurrentNetKarmaEventHandler(command, parser, _mockBroker.Object, options, + NullLogger.Instance); + } + + 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/GetLeaderBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs new file mode 100644 index 0000000..a5dffda --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs @@ -0,0 +1,102 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GetLeaderBoardEventHandlerTests +{ + private readonly GetLeaderBoardEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + + public GetLeaderBoardEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new GetLeaderBoardCommand(_mockKarmaRepo.Object); + _handler = new GetLeaderBoardEventHandler(command, _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/GetLoserBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs new file mode 100644 index 0000000..2d1cd51 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs @@ -0,0 +1,102 @@ +using Bottomly.Commands; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GetLoserBoardEventHandlerTests +{ + private readonly GetLoserBoardEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + + public GetLoserBoardEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new GetLoserBoardCommand(_mockKarmaRepo.Object); + _handler = new GetLoserBoardEventHandler(command, _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/GiphyEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs new file mode 100644 index 0000000..8e0694e --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs @@ -0,0 +1,73 @@ +using Bottomly.Commands; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GiphyEventHandlerTests +{ + private readonly GiphyEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GiphyEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object, options); + _handler = new GiphyEventHandler(_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/GoogleEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs new file mode 100644 index 0000000..9b329d4 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs @@ -0,0 +1,75 @@ +using Bottomly.Commands; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GoogleEventHandlerTests +{ + private readonly GoogleEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GoogleEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _mockCommand = new Mock(options); + _handler = new GoogleEventHandler(_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/GoogleImageEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs new file mode 100644 index 0000000..3bbd8d4 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs @@ -0,0 +1,75 @@ +using Bottomly.Commands; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class GoogleImageEventHandlerTests +{ + private readonly GoogleImageEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public GoogleImageEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + _mockCommand = new Mock(options); + _handler = new GoogleImageEventHandler(_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/HelpEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs new file mode 100644 index 0000000..3be0f07 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs @@ -0,0 +1,72 @@ +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class HelpEventHandlerTests +{ + private readonly Mock _mockBroker = new(); + + private HelpEventHandler CreateHandler(IEnumerable? handlers = null) + { + var options = TestHelpers.CreateOptions(); + return new HelpEventHandler( + 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/DecrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs new file mode 100644 index 0000000..e76389a --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs @@ -0,0 +1,65 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers.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 DecrementKarmaEventHandlerTests +{ + private readonly DecrementKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public DecrementKarmaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new DecrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, + NullLogger.Instance); + } + + 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/IncrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs new file mode 100644 index 0000000..7d16217 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs @@ -0,0 +1,65 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers.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 IncrementKarmaEventHandlerTests +{ + private readonly IncrementKarmaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockKarmaRepo = new(); + private readonly Mock _mockMemberRepo = new(); + + public IncrementKarmaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var parser = new SlackParser(_mockMemberRepo.Object); + _handler = new IncrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, + NullLogger.Instance); + } + + 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..b86cae7 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs @@ -0,0 +1,136 @@ +using Bottomly.Commands; +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers.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 IncrementKarmaEventHandler _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 IncrementKarmaEventHandler(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/ReleaseEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs new file mode 100644 index 0000000..a5580b4 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs @@ -0,0 +1,67 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +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 ReleaseEventHandlerTests +{ + private readonly ReleaseEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public ReleaseEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var releaseOptions = Options.Create(new BottomlyOptions { GitHubToken = "token" }); + _mockCommand = new Mock(releaseOptions, NullLogger.Instance); + _handler = new ReleaseEventHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); + } + + 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/UrbanEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs new file mode 100644 index 0000000..2bad731 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs @@ -0,0 +1,76 @@ +using Bottomly.Commands; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class UrbanEventHandlerTests +{ + private readonly UrbanEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public UrbanEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object); + _handler = new UrbanEventHandler(_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/WikipediaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs new file mode 100644 index 0000000..65c0212 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs @@ -0,0 +1,76 @@ +using Bottomly.Commands; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using SlackNet.Events; + +namespace Bottomly.Tests.Slack.EventHandlers; + +public class WikipediaEventHandlerTests +{ + private readonly WikipediaEventHandler _handler; + private readonly Mock _mockBroker = new(); + private readonly Mock _mockCommand; + + public WikipediaEventHandlerTests() + { + var options = TestHelpers.CreateOptions(); + var mockFactory = new Mock(); + _mockCommand = new Mock(mockFactory.Object); + _handler = new WikipediaEventHandler(_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/ReactionHandlers/AddKarmaReactionHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs new file mode 100644 index 0000000..44eda26 --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs @@ -0,0 +1,84 @@ +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, _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/SlackParserTests.cs b/bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs new file mode 100644 index 0000000..cf99c7b --- /dev/null +++ b/bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs @@ -0,0 +1,47 @@ +using Bottomly.Models; +using Bottomly.Repositories; +using Bottomly.Slack; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Slack; + +public class SlackParserTests +{ + private readonly Mock _mockMemberRepo = new(); + private readonly SlackParser _parser; + + public SlackParserTests() => _parser = new SlackParser(_mockMemberRepo.Object); + + [Fact] + public async Task ReplaceSlackIdTokensWithUsernamesAsync_NoSlackIds_ReturnsUnchanged() + { + var message = "hello world"; + + var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync(message); + + result.ShouldBe("hello world"); + } + + [Fact] + public async Task ReplaceSlackIdTokensWithUsernamesAsync_WithSlackId_ReplacesWithUsername() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U12345")) + .ReturnsAsync(new Member { Username = "alice", SlackId = "U12345" }); + + var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync("hello <@U12345>"); + + result.ShouldBe("hello alice"); + } + + [Fact] + public async Task ReplaceSlackIdTokensWithUsernamesAsync_SlackIdInLongMessage_ReplacesCorrectly() + { + _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U99999")) + .ReturnsAsync(new Member { Username = "bob", SlackId = "U99999" }); + + var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync("good work <@U99999> you did it"); + + result.ShouldBe("good work bob you did it"); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index efc357b..a6317f2 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -5,14 +5,22 @@ net10.0 enable enable + 1954dce3-09ab-4d0a-8574-9e7abe8196df - + + + + + + + + - + diff --git a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs new file mode 100644 index 0000000..8b725af --- /dev/null +++ b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs @@ -0,0 +1,53 @@ +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; + } + + public static Dictionary GetKarmaReactions() => new() + { + ["+1"] = KarmaType.PozzyPoz, + ["arrow_up"] = KarmaType.PozzyPoz, + ["clap"] = KarmaType.PozzyPoz, + ["heart"] = KarmaType.PozzyPoz, + ["heart_eyes"] = KarmaType.PozzyPoz, + ["heavy_plus_sign"] = KarmaType.PozzyPoz, + ["heavy_tick"] = KarmaType.PozzyPoz, + ["joy"] = KarmaType.PozzyPoz, + ["party_parrot"] = KarmaType.PozzyPoz, + ["raised_hands"] = KarmaType.PozzyPoz, + ["smile"] = KarmaType.PozzyPoz, + ["thumbsup"] = KarmaType.PozzyPoz, + ["-1"] = KarmaType.NeggyNeg, + ["arrow_down"] = KarmaType.NeggyNeg, + ["hankey"] = KarmaType.NeggyNeg, + ["heavy_minus_sign"] = KarmaType.NeggyNeg, + ["poo"] = KarmaType.NeggyNeg, + ["poop"] = KarmaType.NeggyNeg, + ["shit"] = KarmaType.NeggyNeg, + ["thumbsdown"] = KarmaType.NeggyNeg + }; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs new file mode 100644 index 0000000..8efbf76 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs @@ -0,0 +1,12 @@ +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class GetCurrentKarmaReasonsCommand(IKarmaRepository karmaRepository) : ICommand +{ + public string GetPurpose() => + "Returns the justifications for someone's/something's current score of imaginary internet points"; + + public Task ExecuteAsync(string recipient) => + karmaRepository.GetKarmaReasonsAsync(recipient); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs new file mode 100644 index 0000000..faf5ac8 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs @@ -0,0 +1,11 @@ +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class GetCurrentNetKarmaCommand(IKarmaRepository karmaRepository) : ICommand +{ + public string GetPurpose() => "Returns someone's/something's current score of imaginary internet points"; + + public Task ExecuteAsync(string recipient) => + karmaRepository.GetCurrentNetKarmaAsync(recipient); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs new file mode 100644 index 0000000..7279f76 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs @@ -0,0 +1,11 @@ +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class GetLeaderBoardCommand(IKarmaRepository karmaRepository) : ICommand +{ + public string GetPurpose() => "Shows the best of the best!"; + + public Task> ExecuteAsync(int size = 3) => + karmaRepository.GetLeaderBoardAsync(size); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs new file mode 100644 index 0000000..aa112fb --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs @@ -0,0 +1,11 @@ +using Bottomly.Repositories; + +namespace Bottomly.Commands; + +public class GetLoserBoardCommand(IKarmaRepository karmaRepository) : ICommand +{ + public string GetPurpose() => "Shows the worst of the worst!"; + + public Task> ExecuteAsync(int size = 3) => + karmaRepository.GetLoserBoardAsync(size); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GiphyCommand.cs b/bottomly.net/Bottomly/Commands/GiphyCommand.cs new file mode 100644 index 0000000..f49477d --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GiphyCommand.cs @@ -0,0 +1,37 @@ +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 HttpClient _httpClient = httpClientFactory.CreateClient(); + + 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 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..371fa52 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs @@ -0,0 +1,38 @@ +using Bottomly.Configuration; +using Google.Apis.CustomSearchAPI.v1; +using Google.Apis.Services; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands; + +public sealed class GoogleImageSearchCommand(IOptions options) : ICommand +{ + private readonly string _apiKey = options.Value.GoogleApiKey; + private readonly string _cseId = options.Value.GoogleCseId; + + public string GetPurpose() => "Performs a google image search and returns the top hit."; + + public async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return null; + } + + var service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); + var request = service.Cse.List(); + 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..f808f0f --- /dev/null +++ b/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs @@ -0,0 +1,39 @@ +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(IOptions options) : ICommand +{ + private readonly string _apiKey = options.Value.GoogleApiKey; + private readonly string _cseId = options.Value.GoogleCseId; + + 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 service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); + 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..5525426 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/ICommand.cs @@ -0,0 +1,6 @@ +namespace Bottomly.Commands; + +public interface ICommand +{ + string GetPurpose(); +} \ 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..9ba4597 --- /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 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/ReleaseCommand.cs b/bottomly.net/Bottomly/Commands/ReleaseCommand.cs new file mode 100644 index 0000000..124b3fd --- /dev/null +++ b/bottomly.net/Bottomly/Commands/ReleaseCommand.cs @@ -0,0 +1,42 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Octokit; + +namespace Bottomly.Commands; + +public class ReleaseCommand(IOptions options, ILogger logger) + : ICommand +{ + private readonly string _token = options.Value.GitHubToken; + + public string GetPurpose() => "Describes the latest release of bottomly"; + + public virtual async Task ExecuteAsync() + { + try + { + var client = new GitHubClient(new ProductHeaderValue("bottomly")) + { + Credentials = new Credentials(_token) + }; + + var user = await client.User.Current(); + var release = await client.Repository.Release.GetLatest(user.Login, "bottomly"); + + var desc = $"Latest Release: *{release.Name}* v{release.TagName}"; + desc += $"\n_Published at {release.PublishedAt}_"; + if (!string.IsNullOrEmpty(release.Body)) + { + desc += $"\n{release.Body}"; + } + + return desc; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving release info from Github"); + return null; + } + } +} \ 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..3606f92 --- /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 static readonly Random _random = new(); + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + + 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 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.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..9d9fcaf --- /dev/null +++ b/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace Bottomly.Commands; + +public record WikipediaResult(string Text, string Link); + +public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICommand +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); + + 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 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/Configuration/BottomlyOptions.cs b/bottomly.net/Bottomly/Configuration/BottomlyOptions.cs new file mode 100644 index 0000000..0afe660 --- /dev/null +++ b/bottomly.net/Bottomly/Configuration/BottomlyOptions.cs @@ -0,0 +1,15 @@ +namespace Bottomly.Configuration; + +public class BottomlyOptions +{ + public string SlackBotToken { get; set; } = string.Empty; + public string SlackAppToken { get; set; } = string.Empty; + public string GoogleApiKey { get; set; } = string.Empty; + public string GoogleCseId { get; set; } = string.Empty; + public string Prefix { get; set; } = "!"; + public string GiphyApiKey { get; set; } = string.Empty; + public string Environment { get; set; } = "live"; + public string GitHubToken { get; set; } = string.Empty; + + public bool IsDebug => Environment != "live"; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/Karma.cs b/bottomly.net/Bottomly/Models/Karma.cs new file mode 100644 index 0000000..cff481c --- /dev/null +++ b/bottomly.net/Bottomly/Models/Karma.cs @@ -0,0 +1,30 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Bottomly.Models; + +public class Karma +{ + public const int ExpiryDays = 30; + + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("awarded_to_username")] public string AwardedToUsername { get; set; } = string.Empty; + + [BsonElement("awarded_by_username")] public string AwardedByUsername { get; set; } = string.Empty; + + [BsonElement("reason")] public string Reason { get; set; } = string.Empty; + + [BsonElement("awarded")] public DateTime Awarded { get; set; } + + [BsonElement("karma_type")] public string KarmaTypeValue { get; set; } = string.Empty; + + [BsonIgnore] + public KarmaType KarmaType + { + get => KarmaTypeValue == nameof(KarmaType.PozzyPoz) ? KarmaType.PozzyPoz : KarmaType.NeggyNeg; + set => KarmaTypeValue = value.ToString(); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/KarmaType.cs b/bottomly.net/Bottomly/Models/KarmaType.cs new file mode 100644 index 0000000..7f6bb15 --- /dev/null +++ b/bottomly.net/Bottomly/Models/KarmaType.cs @@ -0,0 +1,7 @@ +namespace Bottomly.Models; + +public enum KarmaType +{ + PozzyPoz = 1, + NeggyNeg = -1 +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/Member.cs b/bottomly.net/Bottomly/Models/Member.cs new file mode 100644 index 0000000..9d0633c --- /dev/null +++ b/bottomly.net/Bottomly/Models/Member.cs @@ -0,0 +1,10 @@ +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; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index cc1a152..7a9451a 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -1,8 +1,107 @@ -using Microsoft.Extensions.Hosting; +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Repositories; +using Bottomly.Slack; +using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.EventHandlers.KarmaEventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using MongoDB.Driver; +using SlackNet.Events; +using SlackNet.Extensions.DependencyInjection; -var builder = Host.CreateApplicationBuilder(); +var builder = Host.CreateApplicationBuilder(args); builder.AddServiceDefaults(); -builder.Build().Run(); +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.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 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()); + +// Event handlers (registered for IEventHandler collection, excluding Help which is separate) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Help handler (also registered as singleton for direct injection into SlackWorker) +builder.Services.AddSingleton(); + +// Reaction handlers +builder.Services.AddSingleton(); + +// Slack dispatchers and worker +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +builder.Build().Run(); \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs b/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs new file mode 100644 index 0000000..da71998 --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs @@ -0,0 +1,16 @@ +using Bottomly.Models; + +namespace Bottomly.Repositories; + +public interface IKarmaRepository +{ + Task AddAsync(Karma karma); + Task GetCurrentNetKarmaAsync(string recipient); + Task GetKarmaReasonsAsync(string recipient); + Task> GetLeaderBoardAsync(int size = 3); + Task> GetLoserBoardAsync(int size = 3); +} + +public record KarmaScore(string Username, int NetKarma); + +public record KarmaReasonsResult(int Reasonless, IReadOnlyList Reasoned); \ 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..5a00b0e --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs @@ -0,0 +1,10 @@ +using Bottomly.Models; + +namespace Bottomly.Repositories; + +public interface IMemberRepository +{ + Task GetByUsernameAsync(string username); + Task GetBySlackIdAsync(string slackId); + Task AddAsync(Member member); +} \ 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..df8f7fd --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs @@ -0,0 +1,107 @@ +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"); + + public async Task AddAsync(Karma karma) => await _collection.InsertOneAsync(karma); + + public async Task GetCurrentNetKarmaAsync(string recipient) + { + var scores = await GetNetKarmaAggregateAsync(recipient.ToLower(), 1); + return scores.FirstOrDefault()?.NetKarma ?? 0; + } + + public async Task GetKarmaReasonsAsync(string recipient) + { + var cutOff = CutOffDate(); + 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", cutOff) } + }) + }; + + 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, ascending: false); + + public async Task> GetLoserBoardAsync(int size = 3) => + await GetNetKarmaAggregateAsync(limit: size, ascending: true); + + private async Task> GetNetKarmaAggregateAsync( + string? recipient = null, int limit = 3, bool ascending = false) + { + var cutOff = CutOffDate(); + var sortDirection = ascending ? 1 : -1; + + var matchFilter = new BsonDocument("awarded", new BsonDocument("$gt", cutOff)); + 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", sortDirection)) + }; + + var results = await _collection.Aggregate(pipeline).ToListAsync(); + + return results.Take(limit) + .Select(r => new KarmaScore(r["_id"].AsString, r["net_karma"].AsInt32)) + .ToList(); + } + + private static DateTime CutOffDate() => DateTime.UtcNow.AddDays(-Karma.ExpiryDays); +} \ 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..4d157c9 --- /dev/null +++ b/bottomly.net/Bottomly/Repositories/MemberRepository.cs @@ -0,0 +1,23 @@ +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) + { + var result = await _collection.Find(m => m.Username == username).FirstOrDefaultAsync(); + return result; + } + + public async Task GetBySlackIdAsync(string slackId) + { + var result = await _collection.Find(m => m.SlackId == slackId).FirstOrDefaultAsync(); + return result; + } + + public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs new file mode 100644 index 0000000..212a0ce --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs @@ -0,0 +1,85 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public abstract class AbstractEventHandler( + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : IEventHandler +{ + protected readonly ISlackMessageBroker Broker = broker; + protected readonly ILogger Logger = logger; + protected readonly string Prefix = options.Value.Prefix; + + public abstract string Name { get; } + public abstract ICommand? Command { get; } + protected abstract string CommandSymbol { get; } + + protected string CommandTrigger => Prefix + CommandSymbol + " "; + public abstract bool CanHandle(MessageEvent message); + + 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 = Command is not null ? Command.GetPurpose() + Environment.NewLine : string.Empty; + var usage = $"Usage: `{GetUsage()}`"; + return name + purpose + usage + GetUsageAddendum(); + } + + protected abstract Task InvokeHandlerLogicAsync(MessageEvent message); + public abstract string GetUsage(); + public 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) + { + string? replyTs = null; + if (asReply) + { + replyTs = message.Ts; + } + + if (message.ThreadTs is not null) + { + replyTs = message.ThreadTs; + } + + 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); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs new file mode 100644 index 0000000..27a6a6e --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs @@ -0,0 +1,63 @@ +using System.Text; +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.EventHandlers; + +public class GetCurrentKarmaReasonsEventHandler( + GetCurrentKarmaReasonsCommand command, + SlackParser parser, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Karma Reasons"; + public override ICommand Command => command; + protected override string CommandSymbol => "reasons"; + public override string GetUsage() => CommandTrigger + "[recipient ]"; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + protected override 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 command.ExecuteAsync(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/EventHandlers/GetCurrentNetKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs new file mode 100644 index 0000000..6d178eb --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs @@ -0,0 +1,51 @@ +using System.Text; +using Bottomly.Commands; +using Bottomly.Configuration; +using Bottomly.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GetCurrentNetKarmaEventHandler( + GetCurrentNetKarmaCommand command, + SlackParser parser, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Get Current Karma"; + public override ICommand Command => command; + protected override string CommandSymbol => "karma"; + public override string GetUsage() => CommandTrigger + "[recipient ]"; + + public override string GetUsageAddendum() + { + var reactions = AddKarmaCommand.GetKarmaReactions(); + var lines = new StringBuilder($"{Environment.NewLine}Giving Karma with reactions:{Environment.NewLine}"); + foreach (var kvp in reactions) + { + lines.AppendLine($":{kvp.Key}: will {(kvp.Value == KarmaType.PozzyPoz ? "PozzyPoz" : "NeggyNeg")}"); + } + + return lines.ToString(); + } + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + 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 command.ExecuteAsync(recipient); + await SendMessageResponseAsync($"{recipient}: {result}", message); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs new file mode 100644 index 0000000..134d246 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs @@ -0,0 +1,39 @@ +using System.Text; +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GetLeaderBoardEventHandler( + GetLeaderBoardCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Get Leaderboard"; + public override ICommand Command => command; + protected override string CommandSymbol => "leaderboard"; + public override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var sizeArg = message.Text![CommandTrigger.Length..]; + var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; + + var result = await command.ExecuteAsync(size); + var 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/EventHandlers/GetLoserBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs new file mode 100644 index 0000000..e2aa195 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs @@ -0,0 +1,39 @@ +using System.Text; +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GetLoserBoardEventHandler( + GetLoserBoardCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Get Loserboard"; + public override ICommand Command => command; + protected override string CommandSymbol => "loserboard"; + public override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + protected override async Task InvokeHandlerLogicAsync(MessageEvent message) + { + var sizeArg = message.Text![CommandTrigger.Length..]; + var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; + + var result = await command.ExecuteAsync(size); + var 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/EventHandlers/GiphyEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs new file mode 100644 index 0000000..1b7ab10 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs @@ -0,0 +1,31 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GiphyEventHandler( + GiphyCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Giphy"; + public override ICommand Command => command; + protected override string CommandSymbol => "gif"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/EventHandlers/GoogleEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs new file mode 100644 index 0000000..5211aa9 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs @@ -0,0 +1,33 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GoogleEventHandler( + GoogleSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Google"; + public override ICommand Command => command; + protected override string CommandSymbol => "g"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/EventHandlers/GoogleImageEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs new file mode 100644 index 0000000..ce4d18f --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs @@ -0,0 +1,33 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class GoogleImageEventHandler( + GoogleImageSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Google Image"; + public override ICommand Command => command; + protected override string CommandSymbol => "gi"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/EventHandlers/HelpEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs new file mode 100644 index 0000000..cedc5c2 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs @@ -0,0 +1,45 @@ +using System.Text; +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class HelpEventHandler( + IEnumerable handlers, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + private static readonly string[] HelpSymbols = ["help", "?", "list"]; + + public override string Name => "Help"; + public override ICommand? Command => null; + protected override string CommandSymbol => HelpSymbols[0]; + + public 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/EventHandlers/IEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs new file mode 100644 index 0000000..6c257a5 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs @@ -0,0 +1,10 @@ +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public interface IEventHandler +{ + bool CanHandle(MessageEvent message); + Task HandleAsync(MessageEvent message); + string BuildHelpMessage(); +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs new file mode 100644 index 0000000..5517121 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs @@ -0,0 +1,83 @@ +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.EventHandlers.KarmaEventHandlers; + +public abstract class AbstractKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + private const string ForString = " for "; + protected readonly AddKarmaCommand KarmaCommand = command; + + public override ICommand Command => KarmaCommand; + public abstract override string Name { get; } + protected abstract KarmaType KarmaTypeValue { get; } + + public 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/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs new file mode 100644 index 0000000..41b544a --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.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.EventHandlers.KarmaEventHandlers; + +public class DecrementKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractKarmaEventHandler(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/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs new file mode 100644 index 0000000..c2ff576 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.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.EventHandlers.KarmaEventHandlers; + +public class IncrementKarmaEventHandler( + AddKarmaCommand command, + SlackParser parser, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractKarmaEventHandler(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/EventHandlers/RegEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs new file mode 100644 index 0000000..b2256ee --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.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.EventHandlers; + +public class RegEventHandler( + RegSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Reg Lookup"; + public override ICommand Command => command; + protected override string CommandSymbol => "reg"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/EventHandlers/ReleaseEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs new file mode 100644 index 0000000..391b5e1 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.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.EventHandlers; + +public class ReleaseEventHandler( + ReleaseCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Release"; + public override ICommand Command => command; + protected override string CommandSymbol => "release"; + public override string GetUsage() => CommandTrigger.TrimEnd(); + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; + + protected override 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/EventHandlers/UrbanEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs new file mode 100644 index 0000000..a112428 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs @@ -0,0 +1,31 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class UrbanEventHandler( + UrbanSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Urban Dictionary"; + public override ICommand Command => command; + protected override string CommandSymbol => "ud"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/EventHandlers/WikipediaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs new file mode 100644 index 0000000..2d564f9 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs @@ -0,0 +1,33 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class WikipediaEventHandler( + WikipediaSearchCommand command, + ISlackMessageBroker broker, + IOptions options, + ILogger logger) + : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Wikipedia"; + public override ICommand Command => command; + protected override string CommandSymbol => "wik"; + public override string GetUsage() => CommandTrigger + ""; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger) == true; + + protected override 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/ISlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs new file mode 100644 index 0000000..8632459 --- /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 userSlackId); +} \ 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..ab0c2f0 --- /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, + IMemberRepository memberRepository, + ISlackMessageBroker broker, + ILogger logger) + : IReactionHandler +{ + public bool CanHandle(ReactionAdded reactionEvent) + { + var reaction = ParseReaction(reactionEvent.Reaction); + return AddKarmaCommand.GetKarmaReactions().ContainsKey(reaction); + } + + public async Task HandleAsync(ReactionAdded reactionEvent) + { + try + { + var reaction = ParseReaction(reactionEvent.Reaction); + var reactions = AddKarmaCommand.GetKarmaReactions(); + + var reactor = await memberRepository.GetBySlackIdAsync(reactionEvent.User); + var reactee = await memberRepository.GetBySlackIdAsync(reactionEvent.ItemUser); + + 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}", + reactions[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/IReactionHandler.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs new file mode 100644 index 0000000..9b2e301 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs @@ -0,0 +1,9 @@ +using SlackNet.Events; + +namespace Bottomly.Slack.ReactionHandlers; + +public interface IReactionHandler +{ + bool CanHandle(ReactionAdded reactionEvent); + Task HandleAsync(ReactionAdded reactionEvent); +} \ 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..1af4b41 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs @@ -0,0 +1,20 @@ +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); +} \ 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..44a3257 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs @@ -0,0 +1,74 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet; +using SlackNet.WebApi; + +namespace Bottomly.Slack; + +public class SlackMessageBroker( + 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 userSlackId) + { + if (string.IsNullOrEmpty(text)) + { + return; + } + + try + { + var channelId = await slack.Conversations.Open([userSlackId]); + await SendMessageAsync(text, channelId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending DM to user {UserId}", userSlackId); + } + } +} \ 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..9a8a0de --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackParser.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; +using Bottomly.Repositories; + +namespace Bottomly.Slack; + +public class SlackParser(IMemberRepository memberRepository) +{ + private static readonly Regex SlackIdPattern = new(@"<@([A-Z0-9]+)>", RegexOptions.Compiled); + + 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; + } +} \ 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..7933033 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -0,0 +1,102 @@ +using Bottomly.Repositories; +using Bottomly.Slack.EventHandlers; +using Bottomly.Slack.ReactionHandlers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SlackNet; +using SlackNet.Events; +using SlackNet.SocketMode; +using IEventHandler = Bottomly.Slack.EventHandlers.IEventHandler; + +namespace Bottomly.Slack; + +public class SlackWorker( + ISlackSocketModeClient socketClient, + IEnumerable eventHandlers, + HelpEventHandler helpHandler, + IEnumerable 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 (helpHandler.CanHandle(message)) + { + await helpHandler.HandleAsync(message); + return; + } + + foreach (var handler in eventHandlers) + { + if (handler.CanHandle(message)) + { + await handler.HandleAsync(message); + } + } + } + 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..400dff5 --- /dev/null +++ b/bottomly.net/Bottomly/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "bottomly_env": "debug" +} \ No newline at end of file diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx index 6fb4ddd..9eb5410 100644 --- a/bottomly.net/bottomly.net.slnx +++ b/bottomly.net/bottomly.net.slnx @@ -1,9 +1,10 @@ - - - - - - - + + + + + + + + From d181654987788ffc636209f83314f5f112498d2d Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 08:41:30 +0000 Subject: [PATCH 04/13] migrates python impl to use websockets api --- bottomly.net/Bottomly/Bottomly.csproj | 7 ++++++ .../Bottomly/Commands/AddKarmaCommand.cs | 2 +- bottomly.net/Bottomly/Commands/NoneCommand.cs | 7 ++++++ bottomly.net/Bottomly/Program.cs | 11 +++++++- .../Slack/EventHandlers/TestEventHandler.cs | 25 +++++++++++++++++++ .../Bottomly/Slack/PingSlashCommandHandler.cs | 18 +++++++++++++ .../Bottomly/Slack/SlackEventDispatchers.cs | 19 +++++++++++--- bottomly.net/Bottomly/Slack/SlackWorker.cs | 13 +++++++--- 8 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 bottomly.net/Bottomly/Commands/NoneCommand.cs create mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs create mode 100644 bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj index a6317f2..fff2e56 100644 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ b/bottomly.net/Bottomly/Bottomly.csproj @@ -23,4 +23,11 @@ + + + + PreserveNewest + + + diff --git a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs index 8b725af..b340f69 100644 --- a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs +++ b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs @@ -7,7 +7,7 @@ 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) + public async Task ExecuteAsync(string awardedTo, string awardedBy, string reason, KarmaType karmaType) { if (karmaType == KarmaType.PozzyPoz && awardedBy.Equals(awardedTo, StringComparison.OrdinalIgnoreCase)) { diff --git a/bottomly.net/Bottomly/Commands/NoneCommand.cs b/bottomly.net/Bottomly/Commands/NoneCommand.cs new file mode 100644 index 0000000..49e9566 --- /dev/null +++ b/bottomly.net/Bottomly/Commands/NoneCommand.cs @@ -0,0 +1,7 @@ +namespace Bottomly.Commands; + +public class NoneCommand : ICommand +{ + public static ICommand None { get; } = new NoneCommand(); + public string GetPurpose() => "Does nothing."; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs index 7a9451a..ecd63c3 100644 --- a/bottomly.net/Bottomly/Program.cs +++ b/bottomly.net/Bottomly/Program.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MongoDB.Driver; using SlackNet.Events; @@ -74,6 +75,7 @@ builder.Services.AddSlackNet(c => c .UseApiToken(slackBotToken) .UseAppLevelToken(slackAppToken) + .RegisterSlashCommandHandler("/ping") .RegisterEventHandler() .RegisterEventHandler()); @@ -91,6 +93,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Help handler (also registered as singleton for direct injection into SlackWorker) builder.Services.AddSingleton(); @@ -104,4 +107,10 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); -builder.Build().Run(); \ No newline at end of file +var app = builder.Build(); + +var logger = app.Services.GetRequiredService>(); + +logger.LogInformation("Starting Bottomly..."); + +await app.RunAsync(); \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs new file mode 100644 index 0000000..5bfba1c --- /dev/null +++ b/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs @@ -0,0 +1,25 @@ +using Bottomly.Commands; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SlackNet.Events; + +namespace Bottomly.Slack.EventHandlers; + +public class TestEventHandler( + ISlackMessageBroker broker, + IOptions options, + ILogger logger) : AbstractEventHandler(broker, options, logger) +{ + public override string Name => "Test"; + public override ICommand? Command => NoneCommand.None; + protected override string CommandSymbol => "test"; + + public override bool CanHandle(MessageEvent message) => + message.Text?.StartsWith(CommandTrigger.TrimEnd()) ?? false; + + protected override Task InvokeHandlerLogicAsync(MessageEvent message) => + Broker.SendMessageAsync("OK", message.Channel); + + public override string GetUsage() => CommandTrigger; +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs b/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs new file mode 100644 index 0000000..5901372 --- /dev/null +++ b/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using SlackNet.Interaction; +using SlackNet.WebApi; + +namespace Bottomly.Slack; + +public class PingSlashCommandHandler(ILogger logger) : ISlashCommandHandler +{ + public Task Handle(SlashCommand command) + { + logger.LogInformation("Received slash command: {Command}", command); + return Task.FromResult(new SlashCommandResponse + { + Message = new Message { Text = "Pong!" }, + ResponseType = ResponseType.InChannel + }); + } +} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs index 1af4b41..634cc7f 100644 --- a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs +++ b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using SlackNet; using SlackNet.Events; @@ -6,15 +7,25 @@ namespace Bottomly.Slack; /// /// Bridges SlackNet's IEventHandler<MessageEvent> to our SlackWorker message processing pipeline. /// -public class SlackMessageEventDispatcher(SlackWorker worker) : IEventHandler +public class SlackMessageEventDispatcher(SlackWorker worker, ILogger logger) + : IEventHandler { - public Task Handle(MessageEvent slackEvent) => worker.ProcessMessageAsync(slackEvent); + public async Task Handle(MessageEvent slackEvent) + { + logger.LogDebug("Received message event: {Event}", slackEvent); + await worker.ProcessMessageAsync(slackEvent); + } } /// /// Bridges SlackNet's IEventHandler<ReactionAdded> to our SlackWorker reaction processing pipeline. /// -public class SlackReactionEventDispatcher(SlackWorker worker) : IEventHandler +public class SlackReactionEventDispatcher(SlackWorker worker, ILogger logger) + : IEventHandler { - public Task Handle(ReactionAdded slackEvent) => worker.ProcessReactionAsync(slackEvent); + public async Task Handle(ReactionAdded slackEvent) + { + logger.LogDebug("Received reaction event: {Event}", slackEvent); + await worker.ProcessReactionAsync(slackEvent); + } } \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs index 7933033..57a59da 100644 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ b/bottomly.net/Bottomly/Slack/SlackWorker.cs @@ -21,11 +21,18 @@ public class SlackWorker( { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - logger.LogInformation("Starting Slack Socket Mode connection..."); + try + { + logger.LogInformation("Starting Slack Socket Mode connection..."); - await socketClient.Connect(new SocketModeConnectionOptions(), stoppingToken); + await socketClient.Connect(new SocketModeConnectionOptions(), stoppingToken); - logger.LogInformation("Connected to Slack."); + logger.LogInformation("Connected to Slack."); + } + catch (Exception ex) + { + logger.LogError(ex, "Error connecting to Slack."); + } // Keep alive until cancellation await Task.Delay(Timeout.Infinite, stoppingToken); From b53ea0b8b1d87f46b9e50b4106af0667d4caf050 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 08:41:39 +0000 Subject: [PATCH 05/13] migrates python impl to use websockets api --- .devcontainer/devcontainer.json | 50 ++++++++-------- config.py | 6 +- main.py | 6 +- slack_channel/slack_event_handler.py | 87 ++++++++++++++++++++++++++-- 4 files changed, 120 insertions(+), 29 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2b708b3..f276fbc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,32 +15,36 @@ } }, + "runArgs": ["--env-file", "${localWorkspaceFolder}/.env"], + // Set *default* container specific settings.json values on container create. - "settings": { - "terminal.integrated.shell.linux": "/bin/bash", - "python.pythonPath": "/usr/local/bin/python", - "python.languageServer": "Pylance", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", - "python.formatting.blackPath": "/usr/local/py-utils/bin/black", - "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + "customizations": { + "vscode": { + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/local/bin/python", + "python.languageServer": "Pylance", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" + }, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-azuretools.vscode-docker", + "eamodio.gitlens" + ] + } }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "ms-azuretools.vscode-docker", - "eamodio.gitlens", - ], - // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], diff --git a/config.py b/config.py index 5678740..f90b757 100644 --- a/config.py +++ b/config.py @@ -8,10 +8,12 @@ class ConfigKeys(Enum): google_cse_id = "bottomly_google_cse_id" mongo_conn_st = "bottomly_mongo_conn_str" slack_bot_token = "bottomly_slack_bot_token" + slack_app_token = "bottomly_slack_app_token" prefix = "bottomly_prefix" giphy_api_key = "bottomly_giphy_api_key" env_key = "bottomly_env" github_token = "bottomly_github_token" + slack_connection_mode = "bottomly_slack_connection_mode" class Config(object): @@ -23,10 +25,12 @@ class Config(object): ConfigKeys.google_cse_id: 'Google custom search engine ID is not configured', ConfigKeys.mongo_conn_st: 'MongoDB connection string is not configured', ConfigKeys.slack_bot_token: 'Slack bot auth token is not configured', + ConfigKeys.slack_app_token: 'Slack app auth token is not configured', ConfigKeys.prefix: "Standard command prefix is not configured", ConfigKeys.giphy_api_key: "Giphy API key is not configured", ConfigKeys.env_key: "Environment mode is not configured", - ConfigKeys.github_token: "Github token is not configured" + ConfigKeys.github_token: "Github token is not configured", + ConfigKeys.slack_connection_mode: "Slack connection mode is not configured" } # Functions diff --git a/main.py b/main.py index 575ac66..ea92359 100644 --- a/main.py +++ b/main.py @@ -6,4 +6,8 @@ if __name__ == '__main__': is_debug = Config().get_config_value(ConfigKeys.env_key) != live_mode - slack_channel.slack_event_handler.handle_slack_context() + connection_mode = Config().get_config_value(ConfigKeys.slack_connection_mode) + # if connection_mode == "RTM": + # slack_channel.slack_event_handler.run_slack_rtm_client() + # else: + slack_channel.slack_event_handler.run_slack_socket_mode_client() diff --git a/slack_channel/slack_event_handler.py b/slack_channel/slack_event_handler.py index 20beb8d..c967d41 100644 --- a/slack_channel/slack_event_handler.py +++ b/slack_channel/slack_event_handler.py @@ -3,10 +3,16 @@ import logging import logging.config +from threading import Event + from slack_sdk import WebClient from slack_sdk.rtm import RTMClient +from slack_sdk.socket_mode import SocketModeClient from slack_sdk.errors import SlackApiError +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.socket_mode.request import SocketModeRequest + from config import Config, ConfigKeys from model.member import Member from slack_channel import * @@ -15,6 +21,7 @@ config = Config() token = config.get_config_value(ConfigKeys.slack_bot_token) +app_token = config.get_config_value(ConfigKeys.slack_app_token) debug = Config().get_config_value(ConfigKeys.env_key) != live_mode logging.config.fileConfig('logging.conf') @@ -54,6 +61,7 @@ def _is_subscribed_message(slack_event): try: subscribed = True + subscribed = subscribed and slack_event["type"] == "message" subscribed = subscribed and "text" in slack_event subscribed = subscribed and "bot_id" not in slack_event return subscribed @@ -66,7 +74,7 @@ def _is_subscribed_message(slack_event): def _is_subscribed_reaction(slack_event): try: subscribed = True - # subscribed = subscribed and "bot_id" not in slack_event + subscribed = subscribed and slack_event["type"] == "reaction_added" return subscribed except Exception as ex: logger.warning("Error determining if reaction is subscribed: " + str(ex)) @@ -74,14 +82,25 @@ def _is_subscribed_reaction(slack_event): return False -def handle_slack_context(): +def run_slack_rtm_client(): try: + logger.info("Opening RTM connection to slack.") rtm_client = RTMClient(token=token) - logger.info("Opening connection to slack.") rtm_client.start() except SlackApiError: - logger.exception("Error establishing connection to slack.") + logger.exception("Error establishing RTM connection to slack.") +def run_slack_socket_mode_client(): + try: + logger.info("Opening Socket Mode connection to slack.") + socket_mode_client = SocketModeClient(app_token=app_token, web_client=WebClient(token=token)) + socket_mode_client.socket_mode_request_listeners.append(_process_slack_socket_message) + socket_mode_client.socket_mode_request_listeners.append(_process_slack_socket_reaction) + socket_mode_client.connect() + logger.info("Successfully connected to slack via Socket Mode.") + Event().wait() + except SlackApiError: + logger.exception("Error establishing Socket Mode connection to slack.") @RTMClient.run_on(event="message") def _process_slack_message(**e): @@ -114,6 +133,44 @@ def _process_slack_message(**e): except SlackApiError: logger.exception("Error processing slack message") +def _process_slack_socket_message(client: SocketModeClient, req: SocketModeRequest): + try: + slack_event = req.payload["event"] + if not _is_subscribed_message(slack_event): + _acknowledge_message(client, req) + return + + logger.info("Subscribing to: " + str(slack_event)) + + _insert_channel_id_to_message(slack_event) + _insert_username(slack_event) + + handled = False + + help_handler = HelpEventHandler(debug, _command_handlers) + if help_handler.can_handle(slack_event): + handled = help_handler.handle(slack_event) + + if handled: + _acknowledge_message(client, req) + return + + handled = _execute_command_handlers(slack_event) + + if handled: + _acknowledge_message(client, req) + return + + # TODO: Dynamic response handler + + except SlackApiError: + logger.exception("Error processing slack message") + _acknowledge_message(client, req) + + +def _acknowledge_message(client: SocketModeClient, req: SocketModeRequest): + response = SocketModeResponse(envelope_id=req.envelope_id) + client.send_socket_mode_response(response) @RTMClient.run_on(event="reaction_added") def _process_slack_reaction(**e): @@ -136,6 +193,28 @@ def _process_slack_reaction(**e): except Exception as ex: logger.exception("General error processing slack reaction: " + str(ex)) +def _process_slack_socket_reaction(client: SocketModeClient, req: SocketModeRequest): + try: + slack_event = req.payload["event"] + if not _is_subscribed_reaction(slack_event): + _acknowledge_message(client, req) + return + slack_event["channel"] = slack_event["item"]["channel"] + + slack_event["reactor"] = Member.get_member_by_slack_id(slack_event["user"]).username + slack_event["reactee"] = Member.get_member_by_slack_id(slack_event["item_user"]).username + + logger.info(str(slack_event)) + + _reaction_handlers[0].handle(slack_event) + _acknowledge_message(client, req) + + except SlackApiError: + logger.exception("Slack API Error processing slack reaction") + + except Exception as ex: + logger.exception("General error processing slack reaction: " + str(ex)) + def _execute_command_handlers(slack_event): handled = False From fd184a2b007ea4371855953bb33495ad41259711 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 08:42:41 +0000 Subject: [PATCH 06/13] migrates python impl to use websockets api --- bottomly.net/.aspire/settings.json | 3 - bottomly.net/.editorconfig | 95 ---- bottomly.net/.gitignore | 512 ------------------ .../Bottomly.AppHost/.aspire/settings.json | 3 - bottomly.net/Bottomly.AppHost/AppHost.cs | 19 - .../Bottomly.AppHost/Bottomly.AppHost.csproj | 19 - .../Properties/launchSettings.json | 31 -- .../Bottomly.AppHost/apphost.run.json | 31 -- .../appsettings.Development.json | 8 - .../Bottomly.AppHost/appsettings.json | 9 - .../Bottomly.ServiceDefaults.csproj | 22 - .../Bottomly.ServiceDefaults/Extensions.cs | 129 ----- .../Bottomly.Tests/Bottomly.Tests.csproj | 29 - .../Commands/AddKarmaCommandTests.cs | 42 -- .../GetCurrentKarmaReasonsCommandTests.cs | 24 - .../GetCurrentNetKarmaCommandTests.cs | 22 - .../Commands/GetLeaderBoardCommandTests.cs | 38 -- .../Commands/GetLoserBoardCommandTests.cs | 38 -- .../Commands/GiphyCommandTests.cs | 22 - .../Commands/GoogleImageSearchCommandTests.cs | 20 - .../Commands/GoogleSearchCommandTests.cs | 20 - .../Commands/UrbanSearchCommandTests.cs | 19 - .../Commands/WikipediaSearchCommandTests.cs | 19 - .../Bottomly.Tests/Helpers/TestHelpers.cs | 12 - ...GetCurrentKarmaReasonsEventHandlerTests.cs | 103 ---- .../GetCurrentNetKarmaEventHandlerTests.cs | 85 --- .../GetLeaderBoardEventHandlerTests.cs | 102 ---- .../GetLoserBoardEventHandlerTests.cs | 102 ---- .../EventHandlers/GiphyEventHandlerTests.cs | 73 --- .../EventHandlers/GoogleEventHandlerTests.cs | 75 --- .../GoogleImageEventHandlerTests.cs | 75 --- .../EventHandlers/HelpEventHandlerTests.cs | 72 --- .../DecrementKarmaEventHandlerTests.cs | 65 --- .../IncrementKarmaEventHandlerTests.cs | 65 --- .../KarmaHandlerCommandParsingTests.cs | 136 ----- .../EventHandlers/ReleaseEventHandlerTests.cs | 67 --- .../EventHandlers/UrbanEventHandlerTests.cs | 76 --- .../WikipediaEventHandlerTests.cs | 76 --- .../AddKarmaReactionHandlerTests.cs | 84 --- .../Bottomly.Tests/Slack/SlackParserTests.cs | 47 -- bottomly.net/Bottomly/Bottomly.csproj | 33 -- .../Bottomly/Commands/AddKarmaCommand.cs | 53 -- .../Commands/GetCurrentKarmaReasonsCommand.cs | 12 - .../Commands/GetCurrentNetKarmaCommand.cs | 11 - .../Commands/GetLeaderBoardCommand.cs | 11 - .../Bottomly/Commands/GetLoserBoardCommand.cs | 11 - .../Bottomly/Commands/GiphyCommand.cs | 37 -- .../Commands/GoogleImageSearchCommand.cs | 38 -- .../Bottomly/Commands/GoogleSearchCommand.cs | 39 -- bottomly.net/Bottomly/Commands/ICommand.cs | 6 - bottomly.net/Bottomly/Commands/NoneCommand.cs | 7 - .../Bottomly/Commands/RegSearchCommand.cs | 58 -- .../Bottomly/Commands/ReleaseCommand.cs | 42 -- .../Bottomly/Commands/UrbanSearchCommand.cs | 34 -- .../Commands/WikipediaSearchCommand.cs | 39 -- .../Bottomly/Configuration/BottomlyOptions.cs | 15 - bottomly.net/Bottomly/Models/Karma.cs | 30 - bottomly.net/Bottomly/Models/KarmaType.cs | 7 - bottomly.net/Bottomly/Models/Member.cs | 10 - bottomly.net/Bottomly/Program.cs | 116 ---- .../Bottomly/Repositories/IKarmaRepository.cs | 16 - .../Repositories/IMemberRepository.cs | 10 - .../Bottomly/Repositories/KarmaRepository.cs | 107 ---- .../Bottomly/Repositories/MemberRepository.cs | 23 - .../EventHandlers/AbstractEventHandler.cs | 85 --- .../GetCurrentKarmaReasonsEventHandler.cs | 63 --- .../GetCurrentNetKarmaEventHandler.cs | 51 -- .../GetLeaderBoardEventHandler.cs | 39 -- .../GetLoserBoardEventHandler.cs | 39 -- .../Slack/EventHandlers/GiphyEventHandler.cs | 31 -- .../Slack/EventHandlers/GoogleEventHandler.cs | 33 -- .../EventHandlers/GoogleImageEventHandler.cs | 33 -- .../Slack/EventHandlers/HelpEventHandler.cs | 45 -- .../Slack/EventHandlers/IEventHandler.cs | 10 - .../AbstractKarmaEventHandler.cs | 83 --- .../DecrementKarmaEventHandler.cs | 22 - .../IncrementKarmaEventHandler.cs | 22 - .../Slack/EventHandlers/RegEventHandler.cs | 30 - .../EventHandlers/ReleaseEventHandler.cs | 30 - .../Slack/EventHandlers/TestEventHandler.cs | 25 - .../Slack/EventHandlers/UrbanEventHandler.cs | 31 -- .../EventHandlers/WikipediaEventHandler.cs | 33 -- .../Bottomly/Slack/ISlackMessageBroker.cs | 8 - .../Bottomly/Slack/PingSlashCommandHandler.cs | 18 - .../AddKarmaReactionHandler.cs | 57 -- .../ReactionHandlers/IReactionHandler.cs | 9 - .../Bottomly/Slack/SlackEventDispatchers.cs | 31 -- .../Bottomly/Slack/SlackMessageBroker.cs | 74 --- bottomly.net/Bottomly/Slack/SlackParser.cs | 30 - bottomly.net/Bottomly/Slack/SlackWorker.cs | 109 ---- bottomly.net/Bottomly/appsettings.json | 10 - bottomly.net/bottomly.net.slnx | 10 - 92 files changed, 4315 deletions(-) delete mode 100644 bottomly.net/.aspire/settings.json delete mode 100644 bottomly.net/.editorconfig delete mode 100644 bottomly.net/.gitignore delete mode 100644 bottomly.net/Bottomly.AppHost/.aspire/settings.json delete mode 100644 bottomly.net/Bottomly.AppHost/AppHost.cs delete mode 100644 bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj delete mode 100644 bottomly.net/Bottomly.AppHost/Properties/launchSettings.json delete mode 100644 bottomly.net/Bottomly.AppHost/apphost.run.json delete mode 100644 bottomly.net/Bottomly.AppHost/appsettings.Development.json delete mode 100644 bottomly.net/Bottomly.AppHost/appsettings.json delete mode 100644 bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj delete mode 100644 bottomly.net/Bottomly.ServiceDefaults/Extensions.cs delete mode 100644 bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj delete mode 100644 bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs delete mode 100644 bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs delete mode 100644 bottomly.net/Bottomly/Bottomly.csproj delete mode 100644 bottomly.net/Bottomly/Commands/AddKarmaCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GiphyCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/ICommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/NoneCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/RegSearchCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/ReleaseCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs delete mode 100644 bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs delete mode 100644 bottomly.net/Bottomly/Configuration/BottomlyOptions.cs delete mode 100644 bottomly.net/Bottomly/Models/Karma.cs delete mode 100644 bottomly.net/Bottomly/Models/KarmaType.cs delete mode 100644 bottomly.net/Bottomly/Models/Member.cs delete mode 100644 bottomly.net/Bottomly/Program.cs delete mode 100644 bottomly.net/Bottomly/Repositories/IKarmaRepository.cs delete mode 100644 bottomly.net/Bottomly/Repositories/IMemberRepository.cs delete mode 100644 bottomly.net/Bottomly/Repositories/KarmaRepository.cs delete mode 100644 bottomly.net/Bottomly/Repositories/MemberRepository.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs delete mode 100644 bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs delete mode 100644 bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs delete mode 100644 bottomly.net/Bottomly/Slack/SlackMessageBroker.cs delete mode 100644 bottomly.net/Bottomly/Slack/SlackParser.cs delete mode 100644 bottomly.net/Bottomly/Slack/SlackWorker.cs delete mode 100644 bottomly.net/Bottomly/appsettings.json delete mode 100644 bottomly.net/bottomly.net.slnx diff --git a/bottomly.net/.aspire/settings.json b/bottomly.net/.aspire/settings.json deleted file mode 100644 index 1b8fbc8..0000000 --- a/bottomly.net/.aspire/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "appHostPath": "../Bottomly.AppHost/Bottomly.AppHost.csproj" -} \ No newline at end of file diff --git a/bottomly.net/.editorconfig b/bottomly.net/.editorconfig deleted file mode 100644 index 072884d..0000000 --- a/bottomly.net/.editorconfig +++ /dev/null @@ -1,95 +0,0 @@ -[*] -charset = utf-8 -end_of_line = crlf -trim_trailing_whitespace = false -insert_final_newline = false -indent_style = space -indent_size = 4 - -# Microsoft .NET properties -csharp_new_line_before_members_in_object_initializers = false -csharp_prefer_braces = true:warning -csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion -csharp_style_var_elsewhere = true:suggestion -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_using_directive_placement = outside_namespace:silent -csharp_style_expression_bodied_methods = true:warning -csharp_style_expression_bodied_constructors = true:warning -csharp_style_expression_bodied_operators = true:warning -csharp_style_expression_bodied_properties = true:warning -csharp_style_expression_bodied_indexers = true:warning -csharp_style_expression_bodied_accessors = true:warning -dotnet_naming_rule.unity_serialized_field_rule.import_to_resharper = True -dotnet_naming_rule.unity_serialized_field_rule.resharper_description = Unity serialized field -dotnet_naming_rule.unity_serialized_field_rule.resharper_guid = 5f0fdb63-c892-4d2c-9324-15c80b22a7ef -dotnet_naming_rule.unity_serialized_field_rule.severity = warning -dotnet_naming_rule.unity_serialized_field_rule.style = lower_camel_case_style -dotnet_naming_rule.unity_serialized_field_rule.symbols = unity_serialized_field_symbols -dotnet_naming_style.lower_camel_case_style.capitalization = camel_case -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_accessibilities = * -dotnet_naming_symbols.unity_serialized_field_symbols.applicable_kinds = -dotnet_naming_symbols.unity_serialized_field_symbols.resharper_applicable_kinds = unity_serialised_field -dotnet_naming_symbols.unity_serialized_field_symbols.resharper_required_modifiers = instance -dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none -dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion -dotnet_style_qualification_for_event = false:suggestion -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion - -# ReSharper properties -resharper_apply_auto_detected_rules = false -resharper_autodetect_indent_settings = true -resharper_braces_for_for = required -resharper_braces_for_foreach = required -resharper_braces_for_ifelse = required -resharper_braces_for_while = required -resharper_formatter_off_tag = @formatter:off -resharper_formatter_on_tag = @formatter:on -resharper_formatter_tags_enabled = true -resharper_show_autodetect_configure_formatting_tip = false -resharper_use_indent_from_vs = false - -# ReSharper inspection severities -resharper_arrange_redundant_parentheses_highlighting = hint -resharper_arrange_this_qualifier_highlighting = hint -resharper_arrange_type_member_modifiers_highlighting = hint -resharper_arrange_type_modifiers_highlighting = hint -resharper_built_in_type_reference_style_for_member_access_highlighting = hint -resharper_built_in_type_reference_style_highlighting = hint -resharper_mvc_action_not_resolved_highlighting = warning -resharper_mvc_area_not_resolved_highlighting = warning -resharper_mvc_controller_not_resolved_highlighting = warning -resharper_mvc_masterpage_not_resolved_highlighting = warning -resharper_mvc_partial_view_not_resolved_highlighting = warning -resharper_mvc_template_not_resolved_highlighting = warning -resharper_mvc_view_component_not_resolved_highlighting = warning -resharper_mvc_view_component_view_not_resolved_highlighting = warning -resharper_mvc_view_not_resolved_highlighting = warning -resharper_razor_assembly_not_resolved_highlighting = warning -resharper_redundant_base_qualifier_highlighting = warning -resharper_suggest_var_or_type_built_in_types_highlighting = hint -resharper_suggest_var_or_type_elsewhere_highlighting = hint -resharper_suggest_var_or_type_simple_types_highlighting = hint -resharper_web_config_module_not_resolved_highlighting = warning -resharper_web_config_type_not_resolved_highlighting = warning -resharper_web_config_wrong_module_highlighting = warning - -[{*.yaml,*.yml}] -indent_style = space -indent_size = 2 - -[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config}] -indent_style = space -indent_size = 2 - -[*.{appxmanifest,asax,ascx,aspx,axaml,blockshader,build,c,c++,c++m,cc,ccm,cginc,compute,cp,cpp,cppm,cs,cshtml,cu,cuh,cxx,cxxm,dtd,feature,fs,fsi,fsscript,fsx,fx,fxh,h,h++,hh,hlsl,hlsli,hlslinc,hp,hpp,hxx,icc,inc,inl,ino,ipp,ixx,master,ml,mli,mpp,mq4,mq5,mqh,mxx,nuspec,paml,razor,resw,resx,shader,shaderFoundry,skin,tcc,tpp,urtshader,usf,ush,uxml,vb,xaml,xamlx,xoml,xsd}] -indent_style = space -indent_size = 4 -tab_width = 4 diff --git a/bottomly.net/.gitignore b/bottomly.net/.gitignore deleted file mode 100644 index 434bfbe..0000000 --- a/bottomly.net/.gitignore +++ /dev/null @@ -1,512 +0,0 @@ -# .NET and Visual Studio .gitignore - -# 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/Bottomly.AppHost/.aspire/settings.json b/bottomly.net/Bottomly.AppHost/.aspire/settings.json deleted file mode 100644 index 283e445..0000000 --- a/bottomly.net/Bottomly.AppHost/.aspire/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "appHostPath": "../Bottomly.AppHost.csproj" -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.AppHost/AppHost.cs b/bottomly.net/Bottomly.AppHost/AppHost.cs deleted file mode 100644 index b20a925..0000000 --- a/bottomly.net/Bottomly.AppHost/AppHost.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Projects; - -var builder = DistributedApplication.CreateBuilder(args); - -builder.Configuration.AddUserSecrets(); - -var mongo = builder.AddMongoDB("mongo") - .WithLifetime(ContainerLifetime.Persistent); - -var mongodb = mongo.AddDatabase("mongodb"); - -var bottomly = builder.AddProject("bottomly") - .WaitFor(mongodb) - .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 deleted file mode 100644 index dbd9fc3..0000000 --- a/bottomly.net/Bottomly.AppHost/Bottomly.AppHost.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - net10.0 - enable - enable - 5085e312-407d-49ea-8b7c-815877981360 - - - - - - - - - - - diff --git a/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json b/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json deleted file mode 100644 index abda127..0000000 --- a/bottomly.net/Bottomly.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17281;http://localhost:15221", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23133", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22202" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15221", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19053", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18014", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20264" - } - } - } -} diff --git a/bottomly.net/Bottomly.AppHost/apphost.run.json b/bottomly.net/Bottomly.AppHost/apphost.run.json deleted file mode 100644 index f293972..0000000 --- a/bottomly.net/Bottomly.AppHost/apphost.run.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17219;http://localhost:15148", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21009", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23079", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22198" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15148", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19120", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18106", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20045" - } - } - } -} diff --git a/bottomly.net/Bottomly.AppHost/appsettings.Development.json b/bottomly.net/Bottomly.AppHost/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/bottomly.net/Bottomly.AppHost/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/bottomly.net/Bottomly.AppHost/appsettings.json b/bottomly.net/Bottomly.AppHost/appsettings.json deleted file mode 100644 index 31c092a..0000000 --- a/bottomly.net/Bottomly.AppHost/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} diff --git a/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj b/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj deleted file mode 100644 index 938db20..0000000 --- a/bottomly.net/Bottomly.ServiceDefaults/Bottomly.ServiceDefaults.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - - - diff --git a/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs b/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs deleted file mode 100644 index b3f7b27..0000000 --- a/bottomly.net/Bottomly.ServiceDefaults/Extensions.cs +++ /dev/null @@ -1,129 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace Microsoft.Extensions.Hosting; - -// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -public static class Extensions -{ - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) - ) - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) - where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj b/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj deleted file mode 100644 index 3d5b464..0000000 --- a/bottomly.net/Bottomly.Tests/Bottomly.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - daba8c84-3cbb-425d-9103-50c95448cdb3 - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs deleted file mode 100644 index 87850ec..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/AddKarmaCommandTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class AddKarmaCommandTests -{ - private readonly AddKarmaCommand _command; - private readonly Mock _mockRepo = new(); - - public AddKarmaCommandTests() => _command = new AddKarmaCommand(_mockRepo.Object); - - [Fact] - public async Task ExecuteAsync_AwardsKarma_PersistsToRepository() - { - _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - - await _command.ExecuteAsync("alice", "bob", "great job", KarmaType.PozzyPoz); - - _mockRepo.Verify(r => r.AddAsync(It.Is(k => - k.AwardedToUsername == "alice" && - k.AwardedByUsername == "bob" && - k.Reason == "great job" && - k.KarmaType == KarmaType.PozzyPoz)), Times.Once()); - } - - [Fact] - public async Task ExecuteAsync_SelfPositiveKarma_ThrowsInvalidOperation() => - await Should.ThrowAsync(() => - _command.ExecuteAsync("alice", "alice", "", KarmaType.PozzyPoz)); - - [Fact] - public async Task ExecuteAsync_SelfNegativeKarma_DoesNotThrow() - { - _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - - await Should.NotThrowAsync(() => _command.ExecuteAsync("alice", "alice", "", KarmaType.NeggyNeg)); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs deleted file mode 100644 index fafdc0f..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetCurrentKarmaReasonsCommandTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetCurrentKarmaReasonsCommandTests -{ - [Fact] - public async Task ExecuteAsync_DelegatesToRepository() - { - var mockRepo = new Mock(); - var expected = new KarmaReasonsResult(2, new List().AsReadOnly()); - mockRepo.Setup(r => r.GetKarmaReasonsAsync("alice")).ReturnsAsync(expected); - var command = new GetCurrentKarmaReasonsCommand(mockRepo.Object); - - var result = await command.ExecuteAsync("alice"); - - result.ShouldBe(expected); - mockRepo.Verify(r => r.GetKarmaReasonsAsync("alice"), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs deleted file mode 100644 index 61698cd..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetCurrentNetKarmaCommandTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetCurrentNetKarmaCommandTests -{ - [Fact] - public async Task ExecuteAsync_DelegatesToRepository() - { - var mockRepo = new Mock(); - mockRepo.Setup(r => r.GetCurrentNetKarmaAsync("alice")).ReturnsAsync(5); - var command = new GetCurrentNetKarmaCommand(mockRepo.Object); - - var result = await command.ExecuteAsync("alice"); - - result.ShouldBe(5); - mockRepo.Verify(r => r.GetCurrentNetKarmaAsync("alice"), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs deleted file mode 100644 index 8fd2711..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetLeaderBoardCommandTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetLeaderBoardCommandTests -{ - private readonly GetLeaderBoardCommand _command; - private readonly Mock _mockRepo = new(); - - public GetLeaderBoardCommandTests() => _command = new GetLeaderBoardCommand(_mockRepo.Object); - - [Fact] - public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() - { - var scores = new List { new("alice", 5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLeaderBoardAsync(3)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLeaderBoardAsync(3), Times.Once()); - } - - [Fact] - public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() - { - var scores = new List { new("alice", 5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLeaderBoardAsync(10)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(10); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLeaderBoardAsync(10), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs deleted file mode 100644 index 23ea503..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GetLoserBoardCommandTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GetLoserBoardCommandTests -{ - private readonly GetLoserBoardCommand _command; - private readonly Mock _mockRepo = new(); - - public GetLoserBoardCommandTests() => _command = new GetLoserBoardCommand(_mockRepo.Object); - - [Fact] - public async Task ExecuteAsync_DefaultSize_CallsRepositoryWithSize3() - { - var scores = new List { new("alice", -5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLoserBoardAsync(3)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLoserBoardAsync(3), Times.Once()); - } - - [Fact] - public async Task ExecuteAsync_SpecifiedSize_CallsRepositoryWithSpecifiedSize() - { - var scores = new List { new("alice", -5) }.AsReadOnly(); - _mockRepo.Setup(r => r.GetLoserBoardAsync(10)).ReturnsAsync(scores); - - var result = await _command.ExecuteAsync(10); - - result.ShouldBe(scores); - _mockRepo.Verify(r => r.GetLoserBoardAsync(10), Times.Once()); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs deleted file mode 100644 index 1684912..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GiphyCommandTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -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(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs deleted file mode 100644 index 6b23fe4..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Options; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GoogleImageSearchCommandTests -{ - [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() - { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleImageSearchCommand(options); - - var result = await command.ExecuteAsync(""); - - result.ShouldBeNull(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs deleted file mode 100644 index e5b8761..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Options; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GoogleSearchCommandTests -{ - [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() - { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleSearchCommand(options); - - var result = await command.ExecuteAsync(""); - - result.ShouldBeNull(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs deleted file mode 100644 index d6f3680..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Bottomly.Commands; -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(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs b/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs deleted file mode 100644 index 6830330..0000000 --- a/bottomly.net/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Bottomly.Commands; -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(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs b/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs deleted file mode 100644 index 0334a01..0000000 --- a/bottomly.net/Bottomly.Tests/Helpers/TestHelpers.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bottomly.Configuration; -using Microsoft.Extensions.Options; - -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 }); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs deleted file mode 100644 index b6d71e0..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandlerTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GetCurrentKarmaReasonsEventHandlerTests -{ - private readonly GetCurrentKarmaReasonsEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - private readonly Mock _mockMemberRepo = new(); - - public GetCurrentKarmaReasonsEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new GetCurrentKarmaReasonsCommand(_mockKarmaRepo.Object); - var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentKarmaReasonsEventHandler(command, parser, _mockBroker.Object, options, - NullLogger.Instance); - } - - 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/GetCurrentNetKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs deleted file mode 100644 index d61c11a..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetCurrentNetKarmaEventHandlerTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GetCurrentNetKarmaEventHandlerTests -{ - private readonly GetCurrentNetKarmaEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - private readonly Mock _mockMemberRepo = new(); - - public GetCurrentNetKarmaEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new GetCurrentNetKarmaCommand(_mockKarmaRepo.Object); - var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new GetCurrentNetKarmaEventHandler(command, parser, _mockBroker.Object, options, - NullLogger.Instance); - } - - 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/GetLeaderBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs deleted file mode 100644 index a5dffda..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLeaderBoardEventHandlerTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GetLeaderBoardEventHandlerTests -{ - private readonly GetLeaderBoardEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - - public GetLeaderBoardEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new GetLeaderBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLeaderBoardEventHandler(command, _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/GetLoserBoardEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs deleted file mode 100644 index 2d1cd51..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GetLoserBoardEventHandlerTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GetLoserBoardEventHandlerTests -{ - private readonly GetLoserBoardEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - - public GetLoserBoardEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new GetLoserBoardCommand(_mockKarmaRepo.Object); - _handler = new GetLoserBoardEventHandler(command, _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/GiphyEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs deleted file mode 100644 index 8e0694e..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GiphyEventHandlerTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GiphyEventHandlerTests -{ - private readonly GiphyEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public GiphyEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object, options); - _handler = new GiphyEventHandler(_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/GoogleEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs deleted file mode 100644 index 9b329d4..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleEventHandlerTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GoogleEventHandlerTests -{ - private readonly GoogleEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public GoogleEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); - _handler = new GoogleEventHandler(_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/GoogleImageEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs deleted file mode 100644 index 3bbd8d4..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/GoogleImageEventHandlerTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class GoogleImageEventHandlerTests -{ - private readonly GoogleImageEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public GoogleImageEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); - _handler = new GoogleImageEventHandler(_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/HelpEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs deleted file mode 100644 index 3be0f07..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/HelpEventHandlerTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class HelpEventHandlerTests -{ - private readonly Mock _mockBroker = new(); - - private HelpEventHandler CreateHandler(IEnumerable? handlers = null) - { - var options = TestHelpers.CreateOptions(); - return new HelpEventHandler( - 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/DecrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs deleted file mode 100644 index e76389a..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandlerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.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 DecrementKarmaEventHandlerTests -{ - private readonly DecrementKarmaEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - private readonly Mock _mockMemberRepo = new(); - - public DecrementKarmaEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new AddKarmaCommand(_mockKarmaRepo.Object); - var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new DecrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, - NullLogger.Instance); - } - - 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/IncrementKarmaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs deleted file mode 100644 index 7d16217..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandlerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.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 IncrementKarmaEventHandlerTests -{ - private readonly IncrementKarmaEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockKarmaRepo = new(); - private readonly Mock _mockMemberRepo = new(); - - public IncrementKarmaEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var command = new AddKarmaCommand(_mockKarmaRepo.Object); - var parser = new SlackParser(_mockMemberRepo.Object); - _handler = new IncrementKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, - NullLogger.Instance); - } - - 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 deleted file mode 100644 index b86cae7..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Models; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers.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 IncrementKarmaEventHandler _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 IncrementKarmaEventHandler(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/ReleaseEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs deleted file mode 100644 index a5580b4..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/ReleaseEventHandlerTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -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 ReleaseEventHandlerTests -{ - private readonly ReleaseEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public ReleaseEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var releaseOptions = Options.Create(new BottomlyOptions { GitHubToken = "token" }); - _mockCommand = new Mock(releaseOptions, NullLogger.Instance); - _handler = new ReleaseEventHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); - } - - 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/UrbanEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs deleted file mode 100644 index 2bad731..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/UrbanEventHandlerTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class UrbanEventHandlerTests -{ - private readonly UrbanEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public UrbanEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object); - _handler = new UrbanEventHandler(_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/WikipediaEventHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs deleted file mode 100644 index 65c0212..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/EventHandlers/WikipediaEventHandlerTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Tests.Helpers; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using Shouldly; -using SlackNet.Events; - -namespace Bottomly.Tests.Slack.EventHandlers; - -public class WikipediaEventHandlerTests -{ - private readonly WikipediaEventHandler _handler; - private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; - - public WikipediaEventHandlerTests() - { - var options = TestHelpers.CreateOptions(); - var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object); - _handler = new WikipediaEventHandler(_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/ReactionHandlers/AddKarmaReactionHandlerTests.cs b/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs deleted file mode 100644 index 44eda26..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -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, _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/SlackParserTests.cs b/bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs deleted file mode 100644 index cf99c7b..0000000 --- a/bottomly.net/Bottomly.Tests/Slack/SlackParserTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Bottomly.Models; -using Bottomly.Repositories; -using Bottomly.Slack; -using Moq; -using Shouldly; - -namespace Bottomly.Tests.Slack; - -public class SlackParserTests -{ - private readonly Mock _mockMemberRepo = new(); - private readonly SlackParser _parser; - - public SlackParserTests() => _parser = new SlackParser(_mockMemberRepo.Object); - - [Fact] - public async Task ReplaceSlackIdTokensWithUsernamesAsync_NoSlackIds_ReturnsUnchanged() - { - var message = "hello world"; - - var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync(message); - - result.ShouldBe("hello world"); - } - - [Fact] - public async Task ReplaceSlackIdTokensWithUsernamesAsync_WithSlackId_ReplacesWithUsername() - { - _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U12345")) - .ReturnsAsync(new Member { Username = "alice", SlackId = "U12345" }); - - var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync("hello <@U12345>"); - - result.ShouldBe("hello alice"); - } - - [Fact] - public async Task ReplaceSlackIdTokensWithUsernamesAsync_SlackIdInLongMessage_ReplacesCorrectly() - { - _mockMemberRepo.Setup(r => r.GetBySlackIdAsync("U99999")) - .ReturnsAsync(new Member { Username = "bob", SlackId = "U99999" }); - - var result = await _parser.ReplaceSlackIdTokensWithUsernamesAsync("good work <@U99999> you did it"); - - result.ShouldBe("good work bob you did it"); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Bottomly.csproj b/bottomly.net/Bottomly/Bottomly.csproj deleted file mode 100644 index fff2e56..0000000 --- a/bottomly.net/Bottomly/Bottomly.csproj +++ /dev/null @@ -1,33 +0,0 @@ - - - - Exe - net10.0 - enable - enable - 1954dce3-09ab-4d0a-8574-9e7abe8196df - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs b/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs deleted file mode 100644 index b340f69..0000000 --- a/bottomly.net/Bottomly/Commands/AddKarmaCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -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; - } - - public static Dictionary GetKarmaReactions() => new() - { - ["+1"] = KarmaType.PozzyPoz, - ["arrow_up"] = KarmaType.PozzyPoz, - ["clap"] = KarmaType.PozzyPoz, - ["heart"] = KarmaType.PozzyPoz, - ["heart_eyes"] = KarmaType.PozzyPoz, - ["heavy_plus_sign"] = KarmaType.PozzyPoz, - ["heavy_tick"] = KarmaType.PozzyPoz, - ["joy"] = KarmaType.PozzyPoz, - ["party_parrot"] = KarmaType.PozzyPoz, - ["raised_hands"] = KarmaType.PozzyPoz, - ["smile"] = KarmaType.PozzyPoz, - ["thumbsup"] = KarmaType.PozzyPoz, - ["-1"] = KarmaType.NeggyNeg, - ["arrow_down"] = KarmaType.NeggyNeg, - ["hankey"] = KarmaType.NeggyNeg, - ["heavy_minus_sign"] = KarmaType.NeggyNeg, - ["poo"] = KarmaType.NeggyNeg, - ["poop"] = KarmaType.NeggyNeg, - ["shit"] = KarmaType.NeggyNeg, - ["thumbsdown"] = KarmaType.NeggyNeg - }; -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs deleted file mode 100644 index 8efbf76..0000000 --- a/bottomly.net/Bottomly/Commands/GetCurrentKarmaReasonsCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetCurrentKarmaReasonsCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => - "Returns the justifications for someone's/something's current score of imaginary internet points"; - - public Task ExecuteAsync(string recipient) => - karmaRepository.GetKarmaReasonsAsync(recipient); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs b/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs deleted file mode 100644 index faf5ac8..0000000 --- a/bottomly.net/Bottomly/Commands/GetCurrentNetKarmaCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetCurrentNetKarmaCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Returns someone's/something's current score of imaginary internet points"; - - public Task ExecuteAsync(string recipient) => - karmaRepository.GetCurrentNetKarmaAsync(recipient); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs deleted file mode 100644 index 7279f76..0000000 --- a/bottomly.net/Bottomly/Commands/GetLeaderBoardCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetLeaderBoardCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Shows the best of the best!"; - - public Task> ExecuteAsync(int size = 3) => - karmaRepository.GetLeaderBoardAsync(size); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs b/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs deleted file mode 100644 index aa112fb..0000000 --- a/bottomly.net/Bottomly/Commands/GetLoserBoardCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bottomly.Repositories; - -namespace Bottomly.Commands; - -public class GetLoserBoardCommand(IKarmaRepository karmaRepository) : ICommand -{ - public string GetPurpose() => "Shows the worst of the worst!"; - - public Task> ExecuteAsync(int size = 3) => - karmaRepository.GetLoserBoardAsync(size); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/GiphyCommand.cs b/bottomly.net/Bottomly/Commands/GiphyCommand.cs deleted file mode 100644 index f49477d..0000000 --- a/bottomly.net/Bottomly/Commands/GiphyCommand.cs +++ /dev/null @@ -1,37 +0,0 @@ -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 HttpClient _httpClient = httpClientFactory.CreateClient(); - - 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 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 deleted file mode 100644 index 371fa52..0000000 --- a/bottomly.net/Bottomly/Commands/GoogleImageSearchCommand.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Bottomly.Configuration; -using Google.Apis.CustomSearchAPI.v1; -using Google.Apis.Services; -using Microsoft.Extensions.Options; - -namespace Bottomly.Commands; - -public sealed class GoogleImageSearchCommand(IOptions options) : ICommand -{ - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; - - public string GetPurpose() => "Performs a google image search and returns the top hit."; - - public async Task ExecuteAsync(string searchTerm) - { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return null; - } - - var service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); - var request = service.Cse.List(); - 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 deleted file mode 100644 index f808f0f..0000000 --- a/bottomly.net/Bottomly/Commands/GoogleSearchCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -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(IOptions options) : ICommand -{ - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; - - 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 service = new CustomSearchAPIService(new BaseClientService.Initializer { ApiKey = _apiKey }); - 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 deleted file mode 100644 index 5525426..0000000 --- a/bottomly.net/Bottomly/Commands/ICommand.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bottomly.Commands; - -public interface ICommand -{ - string GetPurpose(); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/NoneCommand.cs b/bottomly.net/Bottomly/Commands/NoneCommand.cs deleted file mode 100644 index 49e9566..0000000 --- a/bottomly.net/Bottomly/Commands/NoneCommand.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bottomly.Commands; - -public class NoneCommand : ICommand -{ - public static ICommand None { get; } = new NoneCommand(); - 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 deleted file mode 100644 index 9ba4597..0000000 --- a/bottomly.net/Bottomly/Commands/RegSearchCommand.cs +++ /dev/null @@ -1,58 +0,0 @@ -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 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/ReleaseCommand.cs b/bottomly.net/Bottomly/Commands/ReleaseCommand.cs deleted file mode 100644 index 124b3fd..0000000 --- a/bottomly.net/Bottomly/Commands/ReleaseCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Octokit; - -namespace Bottomly.Commands; - -public class ReleaseCommand(IOptions options, ILogger logger) - : ICommand -{ - private readonly string _token = options.Value.GitHubToken; - - public string GetPurpose() => "Describes the latest release of bottomly"; - - public virtual async Task ExecuteAsync() - { - try - { - var client = new GitHubClient(new ProductHeaderValue("bottomly")) - { - Credentials = new Credentials(_token) - }; - - var user = await client.User.Current(); - var release = await client.Repository.Release.GetLatest(user.Login, "bottomly"); - - var desc = $"Latest Release: *{release.Name}* v{release.TagName}"; - desc += $"\n_Published at {release.PublishedAt}_"; - if (!string.IsNullOrEmpty(release.Body)) - { - desc += $"\n{release.Body}"; - } - - return desc; - } - catch (Exception ex) - { - logger.LogError(ex, "Error retrieving release info from Github"); - return null; - } - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs b/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs deleted file mode 100644 index 3606f92..0000000 --- a/bottomly.net/Bottomly/Commands/UrbanSearchCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json; - -namespace Bottomly.Commands; - -public class UrbanSearchCommand(IHttpClientFactory httpClientFactory) : ICommand -{ - private static readonly Random _random = new(); - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); - - 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 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.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 deleted file mode 100644 index 9d9fcaf..0000000 --- a/bottomly.net/Bottomly/Commands/WikipediaSearchCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text.Json; - -namespace Bottomly.Commands; - -public record WikipediaResult(string Text, string Link); - -public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICommand -{ - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(); - - 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 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/Configuration/BottomlyOptions.cs b/bottomly.net/Bottomly/Configuration/BottomlyOptions.cs deleted file mode 100644 index 0afe660..0000000 --- a/bottomly.net/Bottomly/Configuration/BottomlyOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Bottomly.Configuration; - -public class BottomlyOptions -{ - public string SlackBotToken { get; set; } = string.Empty; - public string SlackAppToken { get; set; } = string.Empty; - public string GoogleApiKey { get; set; } = string.Empty; - public string GoogleCseId { get; set; } = string.Empty; - public string Prefix { get; set; } = "!"; - public string GiphyApiKey { get; set; } = string.Empty; - public string Environment { get; set; } = "live"; - public string GitHubToken { get; set; } = string.Empty; - - public bool IsDebug => Environment != "live"; -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/Karma.cs b/bottomly.net/Bottomly/Models/Karma.cs deleted file mode 100644 index cff481c..0000000 --- a/bottomly.net/Bottomly/Models/Karma.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace Bottomly.Models; - -public class Karma -{ - public const int ExpiryDays = 30; - - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string? Id { get; set; } - - [BsonElement("awarded_to_username")] public string AwardedToUsername { get; set; } = string.Empty; - - [BsonElement("awarded_by_username")] public string AwardedByUsername { get; set; } = string.Empty; - - [BsonElement("reason")] public string Reason { get; set; } = string.Empty; - - [BsonElement("awarded")] public DateTime Awarded { get; set; } - - [BsonElement("karma_type")] public string KarmaTypeValue { get; set; } = string.Empty; - - [BsonIgnore] - public KarmaType KarmaType - { - get => KarmaTypeValue == nameof(KarmaType.PozzyPoz) ? KarmaType.PozzyPoz : KarmaType.NeggyNeg; - set => KarmaTypeValue = value.ToString(); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/KarmaType.cs b/bottomly.net/Bottomly/Models/KarmaType.cs deleted file mode 100644 index 7f6bb15..0000000 --- a/bottomly.net/Bottomly/Models/KarmaType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bottomly.Models; - -public enum KarmaType -{ - PozzyPoz = 1, - NeggyNeg = -1 -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Models/Member.cs b/bottomly.net/Bottomly/Models/Member.cs deleted file mode 100644 index 9d0633c..0000000 --- a/bottomly.net/Bottomly/Models/Member.cs +++ /dev/null @@ -1,10 +0,0 @@ -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; -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Program.cs b/bottomly.net/Bottomly/Program.cs deleted file mode 100644 index ecd63c3..0000000 --- a/bottomly.net/Bottomly/Program.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Bottomly.Repositories; -using Bottomly.Slack; -using Bottomly.Slack.EventHandlers; -using Bottomly.Slack.EventHandlers.KarmaEventHandlers; -using Bottomly.Slack.ReactionHandlers; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -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.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// 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) - .RegisterSlashCommandHandler("/ping") - .RegisterEventHandler() - .RegisterEventHandler()); - -// Event handlers (registered for IEventHandler collection, excluding Help which is separate) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// Help handler (also registered as singleton for direct injection into SlackWorker) -builder.Services.AddSingleton(); - -// Reaction handlers -builder.Services.AddSingleton(); - -// Slack dispatchers and worker -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(sp => sp.GetRequiredService()); - -var app = builder.Build(); - -var logger = app.Services.GetRequiredService>(); - -logger.LogInformation("Starting Bottomly..."); - -await app.RunAsync(); \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs b/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs deleted file mode 100644 index da71998..0000000 --- a/bottomly.net/Bottomly/Repositories/IKarmaRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bottomly.Models; - -namespace Bottomly.Repositories; - -public interface IKarmaRepository -{ - Task AddAsync(Karma karma); - Task GetCurrentNetKarmaAsync(string recipient); - Task GetKarmaReasonsAsync(string recipient); - Task> GetLeaderBoardAsync(int size = 3); - Task> GetLoserBoardAsync(int size = 3); -} - -public record KarmaScore(string Username, int NetKarma); - -public record KarmaReasonsResult(int Reasonless, IReadOnlyList Reasoned); \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs b/bottomly.net/Bottomly/Repositories/IMemberRepository.cs deleted file mode 100644 index 5a00b0e..0000000 --- a/bottomly.net/Bottomly/Repositories/IMemberRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Bottomly.Models; - -namespace Bottomly.Repositories; - -public interface IMemberRepository -{ - Task GetByUsernameAsync(string username); - Task GetBySlackIdAsync(string slackId); - Task AddAsync(Member member); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/KarmaRepository.cs b/bottomly.net/Bottomly/Repositories/KarmaRepository.cs deleted file mode 100644 index df8f7fd..0000000 --- a/bottomly.net/Bottomly/Repositories/KarmaRepository.cs +++ /dev/null @@ -1,107 +0,0 @@ -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"); - - public async Task AddAsync(Karma karma) => await _collection.InsertOneAsync(karma); - - public async Task GetCurrentNetKarmaAsync(string recipient) - { - var scores = await GetNetKarmaAggregateAsync(recipient.ToLower(), 1); - return scores.FirstOrDefault()?.NetKarma ?? 0; - } - - public async Task GetKarmaReasonsAsync(string recipient) - { - var cutOff = CutOffDate(); - 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", cutOff) } - }) - }; - - 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, ascending: false); - - public async Task> GetLoserBoardAsync(int size = 3) => - await GetNetKarmaAggregateAsync(limit: size, ascending: true); - - private async Task> GetNetKarmaAggregateAsync( - string? recipient = null, int limit = 3, bool ascending = false) - { - var cutOff = CutOffDate(); - var sortDirection = ascending ? 1 : -1; - - var matchFilter = new BsonDocument("awarded", new BsonDocument("$gt", cutOff)); - 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", sortDirection)) - }; - - var results = await _collection.Aggregate(pipeline).ToListAsync(); - - return results.Take(limit) - .Select(r => new KarmaScore(r["_id"].AsString, r["net_karma"].AsInt32)) - .ToList(); - } - - private static DateTime CutOffDate() => DateTime.UtcNow.AddDays(-Karma.ExpiryDays); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Repositories/MemberRepository.cs b/bottomly.net/Bottomly/Repositories/MemberRepository.cs deleted file mode 100644 index 4d157c9..0000000 --- a/bottomly.net/Bottomly/Repositories/MemberRepository.cs +++ /dev/null @@ -1,23 +0,0 @@ -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) - { - var result = await _collection.Find(m => m.Username == username).FirstOrDefaultAsync(); - return result; - } - - public async Task GetBySlackIdAsync(string slackId) - { - var result = await _collection.Find(m => m.SlackId == slackId).FirstOrDefaultAsync(); - return result; - } - - public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs deleted file mode 100644 index 212a0ce..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/AbstractEventHandler.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public abstract class AbstractEventHandler( - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : IEventHandler -{ - protected readonly ISlackMessageBroker Broker = broker; - protected readonly ILogger Logger = logger; - protected readonly string Prefix = options.Value.Prefix; - - public abstract string Name { get; } - public abstract ICommand? Command { get; } - protected abstract string CommandSymbol { get; } - - protected string CommandTrigger => Prefix + CommandSymbol + " "; - public abstract bool CanHandle(MessageEvent message); - - 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 = Command is not null ? Command.GetPurpose() + Environment.NewLine : string.Empty; - var usage = $"Usage: `{GetUsage()}`"; - return name + purpose + usage + GetUsageAddendum(); - } - - protected abstract Task InvokeHandlerLogicAsync(MessageEvent message); - public abstract string GetUsage(); - public 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) - { - string? replyTs = null; - if (asReply) - { - replyTs = message.Ts; - } - - if (message.ThreadTs is not null) - { - replyTs = message.ThreadTs; - } - - 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); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs deleted file mode 100644 index 27a6a6e..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentKarmaReasonsEventHandler.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text; -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.EventHandlers; - -public class GetCurrentKarmaReasonsEventHandler( - GetCurrentKarmaReasonsCommand command, - SlackParser parser, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Karma Reasons"; - public override ICommand Command => command; - protected override string CommandSymbol => "reasons"; - public override string GetUsage() => CommandTrigger + "[recipient ]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - - protected override 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 command.ExecuteAsync(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/EventHandlers/GetCurrentNetKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs deleted file mode 100644 index 6d178eb..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetCurrentNetKarmaEventHandler.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text; -using Bottomly.Commands; -using Bottomly.Configuration; -using Bottomly.Models; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GetCurrentNetKarmaEventHandler( - GetCurrentNetKarmaCommand command, - SlackParser parser, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Get Current Karma"; - public override ICommand Command => command; - protected override string CommandSymbol => "karma"; - public override string GetUsage() => CommandTrigger + "[recipient ]"; - - public override string GetUsageAddendum() - { - var reactions = AddKarmaCommand.GetKarmaReactions(); - var lines = new StringBuilder($"{Environment.NewLine}Giving Karma with reactions:{Environment.NewLine}"); - foreach (var kvp in reactions) - { - lines.AppendLine($":{kvp.Key}: will {(kvp.Value == KarmaType.PozzyPoz ? "PozzyPoz" : "NeggyNeg")}"); - } - - return lines.ToString(); - } - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - - 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 command.ExecuteAsync(recipient); - await SendMessageResponseAsync($"{recipient}: {result}", message); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs deleted file mode 100644 index 134d246..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLeaderBoardEventHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text; -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GetLeaderBoardEventHandler( - GetLeaderBoardCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Get Leaderboard"; - public override ICommand Command => command; - protected override string CommandSymbol => "leaderboard"; - public override string GetUsage() => CommandTrigger + "[size of leaderboard. Default is 3]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - - protected override async Task InvokeHandlerLogicAsync(MessageEvent message) - { - var sizeArg = message.Text![CommandTrigger.Length..]; - var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; - - var result = await command.ExecuteAsync(size); - var 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/EventHandlers/GetLoserBoardEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs deleted file mode 100644 index e2aa195..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GetLoserBoardEventHandler.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Text; -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GetLoserBoardEventHandler( - GetLoserBoardCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Get Loserboard"; - public override ICommand Command => command; - protected override string CommandSymbol => "loserboard"; - public override string GetUsage() => CommandTrigger + "[size of loserboard. Default is 3]"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - - protected override async Task InvokeHandlerLogicAsync(MessageEvent message) - { - var sizeArg = message.Text![CommandTrigger.Length..]; - var size = int.TryParse(sizeArg, out var parsed) && parsed > 0 ? parsed : 3; - - var result = await command.ExecuteAsync(size); - var 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/EventHandlers/GiphyEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs deleted file mode 100644 index 1b7ab10..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GiphyEventHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GiphyEventHandler( - GiphyCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Giphy"; - public override ICommand Command => command; - protected override string CommandSymbol => "gif"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/EventHandlers/GoogleEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs deleted file mode 100644 index 5211aa9..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleEventHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GoogleEventHandler( - GoogleSearchCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Google"; - public override ICommand Command => command; - protected override string CommandSymbol => "g"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/EventHandlers/GoogleImageEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs deleted file mode 100644 index ce4d18f..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/GoogleImageEventHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class GoogleImageEventHandler( - GoogleImageSearchCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Google Image"; - public override ICommand Command => command; - protected override string CommandSymbol => "gi"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/EventHandlers/HelpEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs deleted file mode 100644 index cedc5c2..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/HelpEventHandler.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text; -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class HelpEventHandler( - IEnumerable handlers, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - private static readonly string[] HelpSymbols = ["help", "?", "list"]; - - public override string Name => "Help"; - public override ICommand? Command => null; - protected override string CommandSymbol => HelpSymbols[0]; - - public 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/EventHandlers/IEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs deleted file mode 100644 index 6c257a5..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/IEventHandler.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public interface IEventHandler -{ - bool CanHandle(MessageEvent message); - Task HandleAsync(MessageEvent message); - string BuildHelpMessage(); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs deleted file mode 100644 index 5517121..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/AbstractKarmaEventHandler.cs +++ /dev/null @@ -1,83 +0,0 @@ -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.EventHandlers.KarmaEventHandlers; - -public abstract class AbstractKarmaEventHandler( - AddKarmaCommand command, - SlackParser parser, - IMemberRepository memberRepository, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - private const string ForString = " for "; - protected readonly AddKarmaCommand KarmaCommand = command; - - public override ICommand Command => KarmaCommand; - public abstract override string Name { get; } - protected abstract KarmaType KarmaTypeValue { get; } - - public 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/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs deleted file mode 100644 index 41b544a..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/DecrementKarmaEventHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Bottomly.Models; -using Bottomly.Repositories; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Bottomly.Slack.EventHandlers.KarmaEventHandlers; - -public class DecrementKarmaEventHandler( - AddKarmaCommand command, - SlackParser parser, - IMemberRepository memberRepository, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractKarmaEventHandler(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/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs deleted file mode 100644 index c2ff576..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/KarmaEventHandlers/IncrementKarmaEventHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Bottomly.Models; -using Bottomly.Repositories; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Bottomly.Slack.EventHandlers.KarmaEventHandlers; - -public class IncrementKarmaEventHandler( - AddKarmaCommand command, - SlackParser parser, - IMemberRepository memberRepository, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractKarmaEventHandler(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/EventHandlers/RegEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs deleted file mode 100644 index b2256ee..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/RegEventHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class RegEventHandler( - RegSearchCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Reg Lookup"; - public override ICommand Command => command; - protected override string CommandSymbol => "reg"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/EventHandlers/ReleaseEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs deleted file mode 100644 index 391b5e1..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/ReleaseEventHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class ReleaseEventHandler( - ReleaseCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Release"; - public override ICommand Command => command; - protected override string CommandSymbol => "release"; - public override string GetUsage() => CommandTrigger.TrimEnd(); - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) == true; - - protected override 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/EventHandlers/TestEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs deleted file mode 100644 index 5bfba1c..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/TestEventHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class TestEventHandler( - ISlackMessageBroker broker, - IOptions options, - ILogger logger) : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Test"; - public override ICommand? Command => NoneCommand.None; - protected override string CommandSymbol => "test"; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger.TrimEnd()) ?? false; - - protected override Task InvokeHandlerLogicAsync(MessageEvent message) => - Broker.SendMessageAsync("OK", message.Channel); - - public override string GetUsage() => CommandTrigger; -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs deleted file mode 100644 index a112428..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/UrbanEventHandler.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class UrbanEventHandler( - UrbanSearchCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Urban Dictionary"; - public override ICommand Command => command; - protected override string CommandSymbol => "ud"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/EventHandlers/WikipediaEventHandler.cs b/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs deleted file mode 100644 index 2d564f9..0000000 --- a/bottomly.net/Bottomly/Slack/EventHandlers/WikipediaEventHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet.Events; - -namespace Bottomly.Slack.EventHandlers; - -public class WikipediaEventHandler( - WikipediaSearchCommand command, - ISlackMessageBroker broker, - IOptions options, - ILogger logger) - : AbstractEventHandler(broker, options, logger) -{ - public override string Name => "Wikipedia"; - public override ICommand Command => command; - protected override string CommandSymbol => "wik"; - public override string GetUsage() => CommandTrigger + ""; - - public override bool CanHandle(MessageEvent message) => - message.Text?.StartsWith(CommandTrigger) == true; - - protected override 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/ISlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs deleted file mode 100644 index 8632459..0000000 --- a/bottomly.net/Bottomly/Slack/ISlackMessageBroker.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 userSlackId); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs b/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs deleted file mode 100644 index 5901372..0000000 --- a/bottomly.net/Bottomly/Slack/PingSlashCommandHandler.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Extensions.Logging; -using SlackNet.Interaction; -using SlackNet.WebApi; - -namespace Bottomly.Slack; - -public class PingSlashCommandHandler(ILogger logger) : ISlashCommandHandler -{ - public Task Handle(SlashCommand command) - { - logger.LogInformation("Received slash command: {Command}", command); - return Task.FromResult(new SlashCommandResponse - { - Message = new Message { Text = "Pong!" }, - ResponseType = ResponseType.InChannel - }); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs deleted file mode 100644 index ab0c2f0..0000000 --- a/bottomly.net/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Repositories; -using Microsoft.Extensions.Logging; -using SlackNet; -using SlackNet.Events; - -namespace Bottomly.Slack.ReactionHandlers; - -public class AddKarmaReactionHandler( - AddKarmaCommand command, - IMemberRepository memberRepository, - ISlackMessageBroker broker, - ILogger logger) - : IReactionHandler -{ - public bool CanHandle(ReactionAdded reactionEvent) - { - var reaction = ParseReaction(reactionEvent.Reaction); - return AddKarmaCommand.GetKarmaReactions().ContainsKey(reaction); - } - - public async Task HandleAsync(ReactionAdded reactionEvent) - { - try - { - var reaction = ParseReaction(reactionEvent.Reaction); - var reactions = AddKarmaCommand.GetKarmaReactions(); - - var reactor = await memberRepository.GetBySlackIdAsync(reactionEvent.User); - var reactee = await memberRepository.GetBySlackIdAsync(reactionEvent.ItemUser); - - 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}", - reactions[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/IReactionHandler.cs b/bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs deleted file mode 100644 index 9b2e301..0000000 --- a/bottomly.net/Bottomly/Slack/ReactionHandlers/IReactionHandler.cs +++ /dev/null @@ -1,9 +0,0 @@ -using SlackNet.Events; - -namespace Bottomly.Slack.ReactionHandlers; - -public interface IReactionHandler -{ - bool CanHandle(ReactionAdded reactionEvent); - Task HandleAsync(ReactionAdded reactionEvent); -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs b/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs deleted file mode 100644 index 634cc7f..0000000 --- a/bottomly.net/Bottomly/Slack/SlackEventDispatchers.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Extensions.Logging; -using SlackNet; -using SlackNet.Events; - -namespace Bottomly.Slack; - -/// -/// Bridges SlackNet's IEventHandler<MessageEvent> to our SlackWorker message processing pipeline. -/// -public class SlackMessageEventDispatcher(SlackWorker worker, ILogger logger) - : IEventHandler -{ - public async Task Handle(MessageEvent slackEvent) - { - logger.LogDebug("Received message event: {Event}", slackEvent); - await worker.ProcessMessageAsync(slackEvent); - } -} - -/// -/// Bridges SlackNet's IEventHandler<ReactionAdded> to our SlackWorker reaction processing pipeline. -/// -public class SlackReactionEventDispatcher(SlackWorker worker, ILogger logger) - : IEventHandler -{ - public async Task Handle(ReactionAdded slackEvent) - { - logger.LogDebug("Received reaction event: {Event}", slackEvent); - await worker.ProcessReactionAsync(slackEvent); - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs b/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs deleted file mode 100644 index 44a3257..0000000 --- a/bottomly.net/Bottomly/Slack/SlackMessageBroker.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Bottomly.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SlackNet; -using SlackNet.WebApi; - -namespace Bottomly.Slack; - -public class SlackMessageBroker( - 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 userSlackId) - { - if (string.IsNullOrEmpty(text)) - { - return; - } - - try - { - var channelId = await slack.Conversations.Open([userSlackId]); - await SendMessageAsync(text, channelId); - } - catch (Exception ex) - { - logger.LogError(ex, "Error sending DM to user {UserId}", userSlackId); - } - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackParser.cs b/bottomly.net/Bottomly/Slack/SlackParser.cs deleted file mode 100644 index 9a8a0de..0000000 --- a/bottomly.net/Bottomly/Slack/SlackParser.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.RegularExpressions; -using Bottomly.Repositories; - -namespace Bottomly.Slack; - -public class SlackParser(IMemberRepository memberRepository) -{ - private static readonly Regex SlackIdPattern = new(@"<@([A-Z0-9]+)>", RegexOptions.Compiled); - - 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; - } -} \ No newline at end of file diff --git a/bottomly.net/Bottomly/Slack/SlackWorker.cs b/bottomly.net/Bottomly/Slack/SlackWorker.cs deleted file mode 100644 index 57a59da..0000000 --- a/bottomly.net/Bottomly/Slack/SlackWorker.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Bottomly.Repositories; -using Bottomly.Slack.EventHandlers; -using Bottomly.Slack.ReactionHandlers; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using SlackNet; -using SlackNet.Events; -using SlackNet.SocketMode; -using IEventHandler = Bottomly.Slack.EventHandlers.IEventHandler; - -namespace Bottomly.Slack; - -public class SlackWorker( - ISlackSocketModeClient socketClient, - IEnumerable eventHandlers, - HelpEventHandler helpHandler, - IEnumerable reactionHandlers, - IMemberRepository memberRepository, - ILogger logger) - : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - logger.LogInformation("Starting Slack Socket Mode connection..."); - - await socketClient.Connect(new SocketModeConnectionOptions(), stoppingToken); - - logger.LogInformation("Connected to Slack."); - } - catch (Exception ex) - { - logger.LogError(ex, "Error connecting 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 (helpHandler.CanHandle(message)) - { - await helpHandler.HandleAsync(message); - return; - } - - foreach (var handler in eventHandlers) - { - if (handler.CanHandle(message)) - { - await handler.HandleAsync(message); - } - } - } - 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 deleted file mode 100644 index 400dff5..0000000 --- a/bottomly.net/Bottomly/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - }, - "bottomly_env": "debug" -} \ No newline at end of file diff --git a/bottomly.net/bottomly.net.slnx b/bottomly.net/bottomly.net.slnx deleted file mode 100644 index 9eb5410..0000000 --- a/bottomly.net/bottomly.net.slnx +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - From 941464da5e6a7a42c5cb45306dd589b878e35410 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 08:45:18 +0000 Subject: [PATCH 07/13] Adds slack app token secret into github actions --- .github/workflows/build_and_test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 096df21..b023291 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -59,6 +59,7 @@ jobs: bottomly_google_api_key: ${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id: ${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token: ${{ secrets.SLACK_TOKEN }} + bottomly_slack_app_token: ${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix: _ bottomly_env: test bottomly_mongo_conn_str: ${{ secrets.MONGO_CONN_STR }} From 7ef614e7306b9ca36180f785b3435b60c50fb0bf Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 09:23:09 +0000 Subject: [PATCH 08/13] Updates slack sdk to 3.13 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c65abef..e67deea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ slackclient==1.1.3 requests>=2.20.0 websocket-client==0.48.0 slacksocket==1.0.0 -slack-sdk==3.4.0 +slack-sdk==3.13.0 beautifulsoup4==4.7.1 soupsieve==1.9 dnspython==1.16.0 From fcdcd8948582ef18201ee83b160fbe7b4a928ca3 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 09:34:58 +0000 Subject: [PATCH 09/13] Updates requirements.txt further --- requirements.txt | 64 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/requirements.txt b/requirements.txt index e67deea..72766a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,50 @@ -aiohttp==3.7.4 -pymongo==3.12.3 -pymodm==0.4.3 -google-api-python-client==1.6.5 -slackclient==1.1.3 -requests>=2.20.0 -websocket-client==0.48.0 -slacksocket==1.0.0 -slack-sdk==3.13.0 -beautifulsoup4==4.7.1 -soupsieve==1.9 -dnspython==1.16.0 -dnslib==0.9.14 -PyGithub==1.57 \ No newline at end of file +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +async-timeout==5.0.1 +attrs==25.4.0 +beautifulsoup4==4.14.3 +certifi==2026.2.25 +cffi==2.0.0 +chardet==6.0.0.post1 +charset-normalizer==3.4.4 +cryptography==46.0.5 +dnslib==0.9.26 +dnspython==2.8.0 +frozenlist==1.8.0 +google-api-core==2.30.0 +google-api-python-client==2.190.0 +google-auth==2.48.0 +google-auth-httplib2==0.3.0 +googleapis-common-protos==1.72.0 +httplib2==0.31.2 +idna==3.11 +multidict==6.7.1 +oauth2client==4.1.3 +packaging==26.0 +pip-review==1.3.0 +propcache==0.4.1 +proto-plus==1.27.1 +protobuf==6.33.5 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycparser==3.0 +PyGithub==2.8.1 +PyJWT==2.11.0 +pymodm==0.4.3 +pymongo==3.13.0 +PyNaCl==1.6.2 +pyparsing==3.3.2 +requests==2.32.5 +rsa==4.9.1 +six==1.17.0 +slack_sdk==3.40.1 +slackclient==2.9.4 +slacksocket==1.0.1 +soupsieve==2.8.3 +typing_extensions==4.15.0 +uritemplate==4.2.0 +urllib3==2.6.3 +websocket-client==0.56.0 +wrapt==2.1.1 +yarl==1.23.0 \ No newline at end of file From 75934c5ecbe904f7930a7830a0f6d7a47af88173 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 09:38:21 +0000 Subject: [PATCH 10/13] Updates python runtime version --- .github/workflows/build_and_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index b023291..02da1b8 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -38,10 +38,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.11.4 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: 3.11.4 - name: Install dependencies run: | python -m pip install --upgrade pip From 58d3fa48ffc4c8359677878fabb83a1241038394 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 09:51:05 +0000 Subject: [PATCH 11/13] Adds slack app token secret into github actions --- .github/workflows/push_to_live.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push_to_live.yml b/.github/workflows/push_to_live.yml index 92a6956..14a1d3d 100644 --- a/.github/workflows/push_to_live.yml +++ b/.github/workflows/push_to_live.yml @@ -42,4 +42,4 @@ jobs: registry-password: ${{ secrets.REGISTRY_PASSWORD }} name: bottomly-live location: 'uk south' - environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} + environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} From 084498bbd98238e581d32caebb1dcf4e62dc30bf Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 10:00:19 +0000 Subject: [PATCH 12/13] Adds missing envar to test workflow --- .github/workflows/push_to_test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push_to_test.yml b/.github/workflows/push_to_test.yml index d64fa93..fd4ecff 100644 --- a/.github/workflows/push_to_test.yml +++ b/.github/workflows/push_to_test.yml @@ -43,4 +43,4 @@ jobs: registry-password: ${{ secrets.REGISTRY_PASSWORD }} name: bottomly-test location: 'uk south' - environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} + environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} From 08983d9cbcf252155bd404b45229cbed3140c8ad Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Mon, 2 Mar 2026 10:25:54 +0000 Subject: [PATCH 13/13] removes debug config key --- .devcontainer/devcontainer.json | 3 +- .github/workflows/push_to_live.yml | 82 +++++++++++++++--------------- .github/workflows/push_to_test.yml | 80 +++++++++++++++-------------- .yamllint | 6 +++ config.py | 4 +- main.py | 7 --- requirements.txt | 2 + 7 files changed, 94 insertions(+), 90 deletions(-) create mode 100644 .yamllint diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f276fbc..9167918 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,7 +40,8 @@ "ms-python.python", "ms-python.vscode-pylance", "ms-azuretools.vscode-docker", - "eamodio.gitlens" + "eamodio.gitlens", + "kuscamara.yamllint-fix" ] } }, diff --git a/.github/workflows/push_to_live.yml b/.github/workflows/push_to_live.yml index 14a1d3d..7a122ab 100644 --- a/.github/workflows/push_to_live.yml +++ b/.github/workflows/push_to_live.yml @@ -1,45 +1,47 @@ +--- on: release: - types: - - created + types: [created] name: Push to Live - jobs: - build-and-deploy: - runs-on: ubuntu-latest - - environment: - name: live - - steps: + build-and-deploy: + runs-on: ubuntu-latest + environment: + name: live + steps: # checkout the repo - - name: 'Checkout GitHub Action' - uses: actions/checkout@main - - - name: 'Login via Azure CLI' - uses: azure/login@v1.4.5 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: 'Build and push image' - uses: azure/docker-login@v1 - with: - login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - run: | - docker build . -f run.dockerfile -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - - - name: 'Deploy to Azure Container Instances' - uses: 'azure/aci-deploy@v1' - with: - resource-group: ${{ secrets.RESOURCE_GROUP }} - dns-name-label: ${{ secrets.RESOURCE_GROUP }}live${{ github.run_number }} - image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} - registry-username: ${{ secrets.REGISTRY_USERNAME }} - registry-password: ${{ secrets.REGISTRY_PASSWORD }} - name: bottomly-live - location: 'uk south' - environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} + - name: Checkout GitHub Action + uses: actions/checkout@main + - name: Login via Azure CLI + uses: azure/login@v1.4.5 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Build and push image + uses: azure/docker-login@v1 + with: + login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - run: | + docker build . -f run.dockerfile -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + - name: Deploy to Azure Container Instances + uses: azure/aci-deploy@v1 + with: + resource-group: ${{ secrets.RESOURCE_GROUP }} + dns-name-label: ${{ secrets.RESOURCE_GROUP }}live${{ github.run_number }} + image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + registry-username: ${{ secrets.REGISTRY_USERNAME }} + registry-password: ${{ secrets.REGISTRY_PASSWORD }} + name: bottomly-live + location: uk south + environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} + bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} + bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} + bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} + bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} + bottomly_prefix=${{ secrets.PREFIX }} + bottomly_env=${{ secrets.ENV }} + bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} + bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} diff --git a/.github/workflows/push_to_test.yml b/.github/workflows/push_to_test.yml index fd4ecff..fc00429 100644 --- a/.github/workflows/push_to_test.yml +++ b/.github/workflows/push_to_test.yml @@ -1,46 +1,48 @@ +--- on: push - # release: # types: # - created name: Push to Test - jobs: - build-and-deploy: - runs-on: ubuntu-latest - - environment: - name: test - - steps: + build-and-deploy: + runs-on: ubuntu-latest + environment: + name: test + steps: # checkout the repo - - name: 'Checkout GitHub Action' - uses: actions/checkout@main - - - name: 'Login via Azure CLI' - uses: azure/login@v1.4.5 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: 'Build and push image' - uses: azure/docker-login@v1 - with: - login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - run: | - docker build . -f run.dockerfile -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - - - name: 'Deploy to Azure Container Instances' - uses: 'azure/aci-deploy@v1' - with: - resource-group: ${{ secrets.RESOURCE_GROUP }} - dns-name-label: ${{ secrets.RESOURCE_GROUP }}${{ github.run_number }} - image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} - registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} - registry-username: ${{ secrets.REGISTRY_USERNAME }} - registry-password: ${{ secrets.REGISTRY_PASSWORD }} - name: bottomly-test - location: 'uk south' - environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} bottomly_env=${{ secrets.ENV }} bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} + - name: Checkout GitHub Action + uses: actions/checkout@main + - name: Login via Azure CLI + uses: azure/login@v1.4.5 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Build and push image + uses: azure/docker-login@v1 + with: + login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - run: | + docker build . -f run.dockerfile -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + - name: Deploy to Azure Container Instances + uses: azure/aci-deploy@v1 + with: + resource-group: ${{ secrets.RESOURCE_GROUP }} + dns-name-label: ${{ secrets.RESOURCE_GROUP }}${{ github.run_number }} + image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/bottomly:${{ github.sha }} + registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + registry-username: ${{ secrets.REGISTRY_USERNAME }} + registry-password: ${{ secrets.REGISTRY_PASSWORD }} + name: bottomly-test + location: uk south + environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} + bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} + bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} + bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} + bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} + bottomly_prefix=${{ secrets.PREFIX }} + bottomly_env=${{ secrets.ENV }} + bottomly_mongo_conn_str=${{ secrets.MONGO_CONN_STR }} + bottomly_github_token=${{ secrets.RELEASE_HISTORY_TOKEN }} diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..7e7ffe5 --- /dev/null +++ b/.yamllint @@ -0,0 +1,6 @@ +extends: default + +rules: + line-length: disable + truthy: + allowed-values: ['true', 'false', 'yes', 'no'] \ No newline at end of file diff --git a/config.py b/config.py index f90b757..90e6643 100644 --- a/config.py +++ b/config.py @@ -13,7 +13,6 @@ class ConfigKeys(Enum): giphy_api_key = "bottomly_giphy_api_key" env_key = "bottomly_env" github_token = "bottomly_github_token" - slack_connection_mode = "bottomly_slack_connection_mode" class Config(object): @@ -29,8 +28,7 @@ class Config(object): ConfigKeys.prefix: "Standard command prefix is not configured", ConfigKeys.giphy_api_key: "Giphy API key is not configured", ConfigKeys.env_key: "Environment mode is not configured", - ConfigKeys.github_token: "Github token is not configured", - ConfigKeys.slack_connection_mode: "Slack connection mode is not configured" + ConfigKeys.github_token: "Github token is not configured" } # Functions diff --git a/main.py b/main.py index ea92359..d02bf95 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,5 @@ from config import Config, ConfigKeys import slack_channel.slack_event_handler -live_mode = "live" - if __name__ == '__main__': - is_debug = Config().get_config_value(ConfigKeys.env_key) != live_mode - connection_mode = Config().get_config_value(ConfigKeys.slack_connection_mode) - # if connection_mode == "RTM": - # slack_channel.slack_event_handler.run_slack_rtm_client() - # else: slack_channel.slack_event_handler.run_slack_socket_mode_client() diff --git a/requirements.txt b/requirements.txt index 72766a2..a0f2faf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,4 +47,6 @@ uritemplate==4.2.0 urllib3==2.6.3 websocket-client==0.56.0 wrapt==2.1.1 +yamllint==1.38.0 +yamlfix==1.19.1 yarl==1.23.0 \ No newline at end of file