diff --git a/apps/database/meowbotdb/Dockerfile b/apps/database/meowbotdb/Dockerfile new file mode 100644 index 0000000..05e16e0 --- /dev/null +++ b/apps/database/meowbotdb/Dockerfile @@ -0,0 +1,7 @@ +FROM postgres:17.4 + +ENV POSTGRES_USER=default_user +ENV POSTGRES_PASSWORD=default_password +ENV POSTGRES_DB=meowbot + +COPY apps/database/meowbotdb/src/ /docker-entrypoint-initdb.d/ diff --git a/apps/database/meowbotdb/README.md b/apps/database/meowbotdb/README.md new file mode 100644 index 0000000..2b6d5e5 --- /dev/null +++ b/apps/database/meowbotdb/README.md @@ -0,0 +1,127 @@ +# MeowbotDB + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Docker Image Version](https://img.shields.io/docker/v/dotablaze/meowbotdb) +![Docker Image Size](https://img.shields.io/docker/image-size/dotablaze/meowbotdb) +![Docker Downloads](https://img.shields.io/docker/pulls/dotablaze/meowbotdb?label=downloads) +![Nx](https://img.shields.io/badge/Nx-managed-blue) + +**MeowbotDB** is the PostgreSQL database service used by Meowbot โ€” a multi-server Discord bot for community interaction, reactions, and playful engagement. It provides the schema and seed data required for bot state, logs, preferences, and other persistent features. + +This service is containerized and managed via [Nx](https://nx.dev), supporting multi-arch Docker builds and local development with optional volume caching. + +--- + +## ๐Ÿ“ Project Structure + +```text +database/meowbotdb/ +โ”œโ”€โ”€ Dockerfile # Defines the Postgres image and entrypoint +โ”œโ”€โ”€ project.json # Nx configuration and targets +โ””โ”€โ”€ src/ + โ”œโ”€โ”€ 00_schema.sql # SQL schema definitions for bot state and logs + โ””โ”€โ”€ 01_data.sql # Initial data (e.g., test configs, emoji presets) +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Docker +- [Nx CLI](https://nx.dev) + +### Run Locally (Ephemeral) + +```bash +nx run meowbotdb:serve +``` + +Runs PostgreSQL in a disposable Docker container on port `5432`. + +### Run with Persistent Volume + +```bash +nx run meowbotdb:serve-cache +``` + +Creates and mounts a Docker volume (`meowbotdb-data`) for persistent local storage. + +### Clear Persistent Volume + +```bash +nx run meowbotdb:clear-cache +``` + +Removes the `meowbotdb-data` volume to reset local database state. + +--- + +## ๐Ÿ”จ Build + +Build Docker images with semantic versioning and multi-platform support. + +### CI/CD Multi-Arch Build + +```bash +nx run meowbotdb:build-image +``` + +Builds and pushes images for `linux/amd64` and `linux/arm64`, tagged as `latest` and with semantic version. + +### Local Build Only + +```bash +nx run meowbotdb:local-build-image +``` + +Builds a local image using host architecture for development/testing. + +--- + +## ๐Ÿ—ƒ๏ธ Database Credentials + +Default credentials after container start: + +```text +Host: localhost +Port: 5432 +Database: meowbot +User: default_user +Password: default_password +``` + +--- + +## ๐Ÿ“„ SQL Files + +- `00_schema.sql` โ€“ Table definitions, indexes, relationships +- `01_data.sql` โ€“ Seed data for dev/testing (emojis, preferences, etc.) + +These scripts are executed automatically at container start. + +--- + +## ๐Ÿงช Versioning + +Handled via [Conventional Commits](https://www.conventionalcommits.org/) + [`@jscutlery/semver`](https://github.com/jscutlery/semver): + +```bash +nx run meowbotdb:version +``` + +Automates version bumping, changelog, tagging, and build/push. + +--- + +## ๐Ÿ“ฆ Deployment + +Used internally by Meowbot services across environments, including CI pipelines and local dev. + +--- + +## ๐Ÿ“Œ Notes + +- This is a purpose-built, stateful service for Discord bot functionality. +- Migration tooling is not currently integrated โ€” for production schema evolution, consider [Flyway](https://flywaydb.org/) or [Sqitch](https://sqitch.org/). diff --git a/apps/database/meowbotdb/project.json b/apps/database/meowbotdb/project.json new file mode 100644 index 0000000..e2861ab --- /dev/null +++ b/apps/database/meowbotdb/project.json @@ -0,0 +1,50 @@ +{ + "name": "meowbotdb", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/database/meowbotdb/src", + "tags": ["type:app", "language:postgresql", "scope:meowbotdb"], + "targets": { + "version": { + "executor": "@jscutlery/semver:version", + "options": { + "push": true, + "preset": "conventionalcommits", + "postTargets": ["build-image"] + } + }, + "build-image": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "docker buildx build --platform linux/amd64,linux/arm64 -f {projectRoot}/Dockerfile -t dotablaze/meowbotdb:latest -t dotablaze/meowbotdb:{version} --push .", + "forwardAllArgs": false + } + ], + "parallel": false + } + }, + "local-build-image": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "docker buildx build -f {projectRoot}/Dockerfile -t dotablaze/meowbotdb:latest .", + "forwardAllArgs": false + } + ], + "parallel": false + } + }, + "serve": { + "command": "docker run --rm -p 5432:5432 dotablaze/meowbotdb:latest" + }, + "serve-cache": { + "command": "docker run --rm -v meowbotdb-data:/var/lib/postgresql/data -p 5432:5432 dotablaze/meowbotdb:latest" + }, + "clear-cache": { + "command": "docker volume rm meowbotdb-data || true" + } + } +} diff --git a/apps/database/meowbotdb/src/00_schema.sql b/apps/database/meowbotdb/src/00_schema.sql new file mode 100644 index 0000000..722fc6d --- /dev/null +++ b/apps/database/meowbotdb/src/00_schema.sql @@ -0,0 +1,47 @@ +-- CREATE DATABASE meowbot; + +CREATE TABLE guilds +( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE users +( + id TEXT PRIMARY KEY, + username TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE user_guild_stats +( + guild_id TEXT REFERENCES guilds (id) ON DELETE CASCADE, + user_id TEXT REFERENCES users (id) ON DELETE CASCADE, + successful_meows INT DEFAULT 0, + failed_meows INT DEFAULT 0, + total_meows INT DEFAULT 0, + current_streak INT DEFAULT 0, + highest_streak INT DEFAULT 0, + last_meow_at TIMESTAMP, + last_failed_meow_at TIMESTAMP, + PRIMARY KEY (guild_id, user_id) +); + +CREATE INDEX idx_user_guild_stats_user_id ON user_guild_stats (user_id); +CREATE INDEX idx_user_guild_stats_guild_id ON user_guild_stats (guild_id); + +CREATE TABLE guild_streaks +( + guild_id TEXT PRIMARY KEY REFERENCES guilds (id) ON DELETE CASCADE, + meow_count INT DEFAULT 0, + last_user_id TEXT, + high_score INT DEFAULT 0, + high_score_user_id TEXT +); + +CREATE TABLE IF NOT EXISTS guild_channels +( + guild_id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL +); + diff --git a/apps/database/meowbotdb/src/01_data.sql b/apps/database/meowbotdb/src/01_data.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/database/meowbotdb/src/01_data.sql @@ -0,0 +1 @@ + diff --git a/apps/go/meowbot/Dockerfile.local b/apps/go/meowbot/Dockerfile.local index 7513ea0..2633159 100644 --- a/apps/go/meowbot/Dockerfile.local +++ b/apps/go/meowbot/Dockerfile.local @@ -1,12 +1,12 @@ # Remember to check for updates to base images to incorporate security patches # Use a specific version of golang based on SHA256 digest for reproducibility and security -FROM golang:1.23 AS builder-go +FROM golang:1.24 AS builder-go # Use a specific version of node base on SHA256 digest for reproducibility and security FROM node:lts-alpine AS builder WORKDIR /app -ARG GOLANG_VERSION=1.23 +ARG GOLANG_VERSION=1.24 COPY --from=builder-go /usr/local/go /usr/local/go ENV PATH=$PATH:/usr/local/go/bin diff --git a/apps/go/meowbot/README.md b/apps/go/meowbot/README.md index a96c782..0bdf567 100644 --- a/apps/go/meowbot/README.md +++ b/apps/go/meowbot/README.md @@ -1,11 +1,10 @@ # ๐Ÿพ Meow Bot -![Docker](https://img.shields.io/docker/pulls/jdwillmsen/meow-bot?label=downloads) -![Docker Image Version](https://img.shields.io/docker/v/jdwillmsen/jdw-servicediscovery) -![Docker Image Size](https://img.shields.io/docker/image-size/jdwillmsen/jdw-servicediscovery) -![Docker Downloads](https://img.shields.io/docker/pulls/jdwillmsen/jdw-servicediscovery?label=downloads) +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Docker Image Version](https://img.shields.io/docker/v/dotablaze/meowbot) +![Docker Image Size](https://img.shields.io/docker/image-size/dotablaze/meowbot) +![Docker Downloads](https://img.shields.io/docker/pulls/dotablaze/meowbot?label=downloads) ![Nx](https://img.shields.io/badge/Nx-managed-blue) -![License](https://img.shields.io/github/license/jdwillmsen/jdw) **Meow Bot** is a lightweight and fun Discord bot built with Go and powered by `discordgo`. It tracks consecutive โ€œmeowโ€ messages in a single channel, maintaining streaks, preventing duplicate users, and celebrating high scores. Ideal for @@ -34,7 +33,7 @@ apps/go/meow-bot/ ### Prerequisites -- Go 1.23+ +- Go 1.24+ - [Nx CLI](https://nx.dev) - Docker (optional for containerized runs) - Discord bot token diff --git a/apps/go/meowbot/go.mod b/apps/go/meowbot/go.mod index e132a91..0ce58e1 100644 --- a/apps/go/meowbot/go.mod +++ b/apps/go/meowbot/go.mod @@ -1,11 +1,11 @@ module apps/go/meowbot -go 1.23 +go 1.24 require github.com/bwmarrin/discordgo v0.28.1 require ( - github.com/gorilla/websocket v1.4.2 // indirect - golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sys v0.32.0 // indirect ) diff --git a/apps/go/meowbot/go.sum b/apps/go/meowbot/go.sum index e5a04a3..7e56f28 100644 --- a/apps/go/meowbot/go.sum +++ b/apps/go/meowbot/go.sum @@ -1,12 +1,15 @@ github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/apps/go/meowbot/main.go b/apps/go/meowbot/main.go index e30d08e..9b61bf4 100644 --- a/apps/go/meowbot/main.go +++ b/apps/go/meowbot/main.go @@ -1,78 +1,91 @@ package main import ( + "context" "github.com/bwmarrin/discordgo" + "libs/go/meowbot/feature/api" + "libs/go/meowbot/feature/db" "libs/go/meowbot/feature/handler" "libs/go/meowbot/util" - "log/slog" "os" "os/signal" "syscall" ) -var ( - logger = slog.Default() - botToken = os.Getenv("DISCORD_BOT_TOKEN") - allowedChannel = os.Getenv("ALLOWED_CHANNEL_ID") -) - -func main() { - logger.Info("Booting up Meow bot...") - - util.InitEmojis(logger) +func Run(ctx context.Context, cfg util.AppConfig) error { + util.Cfg.Logger.Info("๐Ÿš€ Booting up Meow bot...", + "mode", util.Cfg.Mode, + "debug", util.Cfg.Debug, + ) - if allowedChannel != "" { - logger.Info("Channel restriction enabled", "channelID", allowedChannel) - } - - if botToken == "" { - logger.Error("DISCORD_BOT_TOKEN not set") - os.Exit(1) + // Initialize emojis and DB connection + util.InitEmojis() + if err := db.InitDB(ctx); err != nil { + return err } + util.Cfg.Logger.Info("โœ… Connected to meowbot PostgreSQL!") - sess, err := discordgo.New("Bot " + botToken) + // Create Discord session + sess, err := discordgo.New("Bot " + cfg.BotToken) if err != nil { - logger.Error("Failed to create Discord session", "error", err) - os.Exit(1) + return err } + // Set up intents and handlers sess.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent - sess.AddHandler(handler.MessageHandler(logger, allowedChannel)) - sess.AddHandler(handler.CommandHandler(logger)) + apiServer := api.New(db.DB, sess) + apiCtx, apiCancel := context.WithCancel(ctx) + defer apiCancel() + go apiServer.Start(apiCtx) - err = sess.Open() - if err != nil { - logger.Error("Failed to open Discord session", "error", err) - os.Exit(1) - } - defer func(sess *discordgo.Session) { - err := sess.Close() - if err != nil { - logger.Error("Failed to close Discord session", "error", err) - } - }(sess) + // Add message handlers + sess.AddHandler(handler.MessageHandler(ctx)) + sess.AddHandler(handler.CommandHandler(ctx)) + sess.AddHandler(handler.ComponentHandler(ctx)) - _, err = sess.ApplicationCommandCreate(sess.State.User.ID, "", &discordgo.ApplicationCommand{ - Name: "meowcount", - Description: "Check the current meow count", - }) - if err != nil { - logger.Error("Failed to register /meowcount", "error", err) + // Open Discord session + if err := sess.Open(); err != nil { + return err } + defer func() { + if err := sess.Close(); err != nil { + util.Cfg.Logger.Error("โŒ Failed to close Discord session", "error", err) + } else { + util.Cfg.Logger.Info("โœ… Successfully closed Discord session.") + } + }() - _, err = sess.ApplicationCommandCreate(sess.State.User.ID, "", &discordgo.ApplicationCommand{ - Name: "highscore", - Description: "Check the highest meow streak", - }) - if err != nil { - logger.Error("Failed to register /highscore", "error", err) + // Register commands + if err := handler.RegisterCommands(sess); err != nil { + return err } - logger.Info("๐Ÿฑ Meow bot is online!") + util.Cfg.Logger.Info("๐Ÿฑ Meow bot is online!") + // Wait for termination signal stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) <-stop - logger.Info("๐Ÿ‘‹ Shutting down Meow bot.") + // Graceful shutdown + if err := db.CloseDB(); err != nil { + return err + } + + util.Cfg.Logger.Info("๐Ÿ‘‹ Meow bot has shut down gracefully.") + return nil +} + +func main() { + // Ensure bot token is available before proceeding + if util.Cfg.BotToken == "" { + util.Cfg.Logger.Error("โŒ DISCORD_BOT_TOKEN not set. Exiting.") + os.Exit(1) + } + + // Run the bot and handle errors + if err := Run(context.Background(), util.Cfg); err != nil { + util.Cfg.Logger.Error("โŒ Error while running bot", "error", err) + os.Exit(1) + } } diff --git a/apps/go/meowbot/main_test.go b/apps/go/meowbot/main_test.go index b7d2430..9a5537a 100644 --- a/apps/go/meowbot/main_test.go +++ b/apps/go/meowbot/main_test.go @@ -1,8 +1,24 @@ package main import ( + "context" + "libs/go/meowbot/util" "testing" ) -func TestMain(m *testing.M) { +func TestRunWithInvalidBotToken(t *testing.T) { + cfg := util.AppConfig{ + BotToken: "", + ApiPort: "0", + } + + err := Run(context.Background(), cfg) + if err == nil { + t.Error("Expected error due to empty bot token, got nil") + } +} + +// Example for successful run test (would need deeper mocks/stubs) +func TestRunBootsUpBot(t *testing.T) { + t.Skip("Skipping integration test: no local DB running") } diff --git a/go.work b/go.work index af3b292..e039413 100644 --- a/go.work +++ b/go.work @@ -1,9 +1,11 @@ -go 1.23.0 +go 1.24.0 -toolchain go1.23.1 +toolchain go1.24.2 use ( ./apps/go/meowbot + ./libs/go/meowbot/feature/api + ./libs/go/meowbot/feature/db ./libs/go/meowbot/feature/handler ./libs/go/meowbot/feature/state ./libs/go/meowbot/util diff --git a/go.work.sum b/go.work.sum index bf27a8e..ba87903 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,17 +1,49 @@ +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw2e+eeNT/SbGySq8ajECXJ9e4fPoLhY= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/otel v1.11.2 h1:YBZcQlsVekzFsFbjygXMOXSs6pialIZxcjfO/mBDmR0= +go.opentelemetry.io/otel v1.11.2/go.mod h1:7p4EUV+AqgdlNV9gL97IgUZiVR3yrFXYo53f9BM3tRI= +go.opentelemetry.io/otel/trace v1.11.2 h1:Xf7hWSF2Glv0DE3MH7fBHvtpSBsjcBUe5MYAmZM/+y0= +go.opentelemetry.io/otel/trace v1.11.2/go.mod h1:4N+yC7QEz7TTsG9BSRLNAa63eg5E06ObSbKPmxQ/pKA= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/libs/go/meowbot/feature/api/README.md b/libs/go/meowbot/feature/api/README.md new file mode 100644 index 0000000..26b3283 --- /dev/null +++ b/libs/go/meowbot/feature/api/README.md @@ -0,0 +1,85 @@ +# ๐ŸŒ Meowbot API + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Nx](https://img.shields.io/badge/Nx-managed-blue) +![Go Module](https://img.shields.io/badge/Go-Module-brightgreen) + +**Meowbot API** is a lightweight HTTP layer for exposing Meowbot statistics via a RESTful interface. Built using the [`chi`](https://github.com/go-chi/chi) router and structured response models, it enables external systems or dashboards to consume streak and score data from Meowbot's backend. + +Part of the modular Meowbot architecture, managed via [Nx](https://nx.dev) in the `libs/go` workspace. + +--- + +## ๐Ÿ“ Project Structure + +``` +libs/go/meowbot/feature/api/ +โ”œโ”€โ”€ api.go # HTTP router, middleware, and handlers +โ”œโ”€โ”€ models.go # Response models for stats and errors +โ”œโ”€โ”€ go.mod / go.sum # Go module definition +โ””โ”€โ”€ project.json # Nx project definition +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Go 1.24+ +- [Nx CLI](https://nx.dev) +- PostgreSQL (if using with Meowbot's DB package) + +### Installation + +This package is intended to be used internally by the Meowbot platform: + +```go +import "github.com/dotablaze-tech/platform/libs/go/meowbot/feature/api" +``` + +--- + +## ๐Ÿ”Œ Usage + +Initialize the router with dependency injection: + +```go +r := api.NewRouter(api.Options{ + DB: myPostgresConn, // *sql.DB + Logger: myLogger, // slog.Logger +}) +http.ListenAndServe(":8080", r) +``` + +--- + +## โœจ Features + +- ๐Ÿš REST-style endpoints for streak and score retrieval +- ๐Ÿ“ฆ Typed JSON responses with helpful error structure +- ๐Ÿ” Graceful error handling and logging +- ๐Ÿ”„ Ready for container-based deployment +- ๐Ÿงฉ Easily extendable with new routes or middlewares + +--- + +## ๐Ÿงช Testing + +*Test coverage coming soon.* + +--- + +## ๐Ÿ“Œ Notes + +- Routes are not versioned yet (`/leaderboard`, `/highscore` etc.). +- Intended to be served behind a proxy or gateway (e.g. Ingress). +- All endpoints return JSON content. + +--- + +## ๐Ÿ“˜ Example Endpoints (Planned) + +- `GET /leaderboard?guild_id=123&sort=streak` +- `GET /highscore?guild_id=123` +- `GET /stats?guild_id=123&user_id=456` diff --git a/libs/go/meowbot/feature/api/api.go b/libs/go/meowbot/feature/api/api.go new file mode 100644 index 0000000..47bb6a8 --- /dev/null +++ b/libs/go/meowbot/feature/api/api.go @@ -0,0 +1,231 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "github.com/bwmarrin/discordgo" + "libs/go/meowbot/util" + "log/slog" + "net/http" + "strings" + "time" + + "libs/go/meowbot/feature/db" +) + +func New(db *sql.DB, sess *discordgo.Session) *Server { + return &Server{ + Logger: util.Cfg.Logger, + DB: db, + Session: sess, + } +} + +func (s *Server) Start(ctx context.Context) { + mux := http.NewServeMux() + + // Health + mux.HandleFunc("/liveness", s.livenessHandler) + mux.HandleFunc("/readiness", s.readinessHandler) + + // Stats + mux.HandleFunc("/stats", s.statsHandler) + mux.HandleFunc("/guilds/", s.guildStatsRouter) + mux.HandleFunc("/users/", s.userStatsRouter) + mux.HandleFunc("/leaderboard", s.leaderboardHandler) + mux.HandleFunc("/users", s.usersHandler) + mux.HandleFunc("/guilds", s.guildsHandler) + + addr := ":" + util.Cfg.ApiPort + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + s.Logger.Info("๐ŸŒ Starting API server", "addr", addr) + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Error("โŒ API server failed", "error", err) + } + }() + + <-ctx.Done() + s.Logger.Info("๐Ÿ›‘ Shutting down API server...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + s.Logger.Error("โŒ Failed to shutdown API server", "error", err) + } +} + +func (s *Server) livenessHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := s.DB.PingContext(ctx) == nil + discordHealthy := s.Session != nil && + s.Session.State != nil && + s.Session.State.User != nil && + s.Session.State.User.ID != "" + + status := "healthy" + if !dbHealthy || !discordHealthy { + status = "unhealthy" + } + + dbStatus := "unhealthy" + if dbHealthy { + dbStatus = "healthy" + } + + discordStatus := "unhealthy" + if discordHealthy { + discordStatus = "healthy" + } + + s.writeJSON(w, HealthResponse{ + Status: status, + Database: dbStatus, + Discord: discordStatus, + }) +} + +func (s *Server) readinessHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + dbHealthy := s.DB.PingContext(ctx) == nil + discordHealthy := s.Session != nil && + s.Session.State != nil && + s.Session.State.User != nil && + s.Session.State.User.ID != "" + + status := "ready" + if !dbHealthy || !discordHealthy { + status = "not ready" + } + + dbStatus := "not ready" + if dbHealthy { + dbStatus = "ready" + } + + discordStatus := "not ready" + if discordHealthy { + discordStatus = "ready" + } + + s.writeJSON(w, HealthResponse{ + Status: status, + Database: dbStatus, + Discord: discordStatus, + }) +} + +func (s *Server) statsHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + stats, err := db.GetGlobalStats(ctx, s.DB) + if err != nil { + s.Logger.Error("โŒ Failed to fetch stats", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(stats) +} + +func (s *Server) guildStatsRouter(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 3 || parts[0] != "guilds" || parts[2] != "stats" { + http.NotFound(w, r) + return + } + s.guildStatsHandler(w, r, parts[1]) +} + +func (s *Server) guildStatsHandler(w http.ResponseWriter, r *http.Request, guildID string) { + ctx := r.Context() + streak, err := db.GetGuildStats(ctx, s.DB, guildID) + if err != nil { + s.writeError(w, http.StatusNotFound, "guild not found", err) + return + } + + s.writeJSON(w, streak) +} + +func (s *Server) userStatsRouter(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 3 || parts[0] != "users" || parts[2] != "stats" { + http.NotFound(w, r) + return + } + s.userStatsHandler(w, r, parts[1]) +} + +func (s *Server) userStatsHandler(w http.ResponseWriter, r *http.Request, userID string) { + stats, err := db.GetUserGlobalStats(r.Context(), s.DB, userID) + if err != nil { + s.writeError(w, http.StatusNotFound, "user not found", err) + return + } + + perGuildStats, err := db.GetUserPerGuildStats(r.Context(), s.DB, userID) + if err != nil { + s.Logger.Warn("โš  Failed to fetch per-guild stats", slog.String("user_id", userID), slog.Any("error", err)) + } + stats.GuildStats = perGuildStats + + s.writeJSON(w, stats) +} + +func (s *Server) leaderboardHandler(w http.ResponseWriter, r *http.Request) { + entries, err := db.GetLeaderboard3(r.Context(), s.DB, 10) + if err != nil { + s.writeError(w, http.StatusInternalServerError, "internal error", err) + return + } + + s.writeJSON(w, entries) +} + +func (s *Server) usersHandler(w http.ResponseWriter, r *http.Request) { + users, err := db.GetAllUsers(r.Context(), s.DB) + if err != nil { + s.writeError(w, http.StatusInternalServerError, "failed to fetch users", err) + return + } + s.writeJSON(w, users) +} + +func (s *Server) guildsHandler(w http.ResponseWriter, r *http.Request) { + guilds, err := db.GetAllGuilds(r.Context(), s.DB) + if err != nil { + s.writeError(w, http.StatusInternalServerError, "failed to fetch guilds", err) + return + } + s.writeJSON(w, guilds) +} + +func (s *Server) writeJSON(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + s.Logger.Error("โŒ Failed to encode response", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + } +} + +func (s *Server) writeError(w http.ResponseWriter, status int, msg string, err error) { + s.Logger.Error("โŒ "+msg, "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if encodeErr := json.NewEncoder(w).Encode(map[string]string{"error": msg}); encodeErr != nil { + s.Logger.Error("โŒ Failed to write error JSON", "error", encodeErr) + } +} diff --git a/libs/go/meowbot/feature/api/go.mod b/libs/go/meowbot/feature/api/go.mod new file mode 100644 index 0000000..592e4c8 --- /dev/null +++ b/libs/go/meowbot/feature/api/go.mod @@ -0,0 +1,11 @@ +module libs/go/meowbot/feature/api + +go 1.24 + +require github.com/bwmarrin/discordgo v0.28.1 + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/libs/go/meowbot/feature/api/go.sum b/libs/go/meowbot/feature/api/go.sum new file mode 100644 index 0000000..6fadbc1 --- /dev/null +++ b/libs/go/meowbot/feature/api/go.sum @@ -0,0 +1,14 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/libs/go/meowbot/feature/api/models.go b/libs/go/meowbot/feature/api/models.go new file mode 100644 index 0000000..d4105be --- /dev/null +++ b/libs/go/meowbot/feature/api/models.go @@ -0,0 +1,19 @@ +package api + +import ( + "database/sql" + "github.com/bwmarrin/discordgo" + "log/slog" +) + +type Server struct { + Logger *slog.Logger + DB *sql.DB + Session *discordgo.Session +} + +type HealthResponse struct { + Status string `json:"status"` + Database string `json:"database"` + Discord string `json:"discord"` +} diff --git a/libs/go/meowbot/feature/api/project.json b/libs/go/meowbot/feature/api/project.json new file mode 100644 index 0000000..ccd047f --- /dev/null +++ b/libs/go/meowbot/feature/api/project.json @@ -0,0 +1,18 @@ +{ + "name": "go-meowbot-feature-api", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/go/meowbot/feature/api", + "tags": ["type:library", "language:go", "scope:meowbot", "library:feature"], + "targets": { + "test": { + "executor": "@nx-go/nx-go:test" + }, + "lint": { + "executor": "@nx-go/nx-go:lint" + }, + "tidy": { + "executor": "@nx-go/nx-go:tidy" + } + } +} diff --git a/libs/go/meowbot/feature/db/README.md b/libs/go/meowbot/feature/db/README.md new file mode 100644 index 0000000..415a1ef --- /dev/null +++ b/libs/go/meowbot/feature/db/README.md @@ -0,0 +1,93 @@ +# ๐Ÿ˜ Meowbot DB + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Nx](https://img.shields.io/badge/Nx-managed-blue) +![Go Module](https://img.shields.io/badge/Go-Module-brightgreen) + +**Meowbot DB** is the database persistence layer for [๐Ÿพ Meow Bot](https://github.com/dotablaze-tech/platform/tree/main/apps/go/meowbot). It provides typed access to PostgreSQL-backed guild and user statistics, including streaks, high scores, and usage tracking. This package wraps raw SQL interactions with clean Go functions and models. + +Part of the `meowbot` suite, it is managed via [Nx](https://nx.dev) in the `libs/go` workspace. + +--- + +## ๐Ÿ“ Project Structure + +``` +libs/go/meowbot/feature/db/ +โ”œโ”€โ”€ connection.go # Establishes DB connection with pooling and logging +โ”œโ”€โ”€ models.go # Structs for DB rows and query results +โ”œโ”€โ”€ stats.go # Core DB access functions for stats read/write +โ”œโ”€โ”€ stats_test.go # Unit tests for DB logic using mock/stub data +โ”œโ”€โ”€ go.mod / go.sum # Go module files +โ””โ”€โ”€ project.json # Nx project definition +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Go 1.24+ +- A running PostgreSQL instance +- [Nx CLI](https://nx.dev) + +### Installation + +This package is used internally by Meowbot. Import like so: + +```go +import "github.com/dotablaze-tech/platform/libs/go/meowbot/feature/db" +``` + +--- + +## ๐Ÿ”Œ Usage + +Initialize with your own `*sql.DB` connection: + +```go +conn := db.NewConnection("postgres://user:pass@host:5432/dbname") +err := conn.Ping() +if err != nil { + log.Fatalf("DB unreachable: %v", err) +} + +stats, err := db.GetUserGuildStats(conn, guildID, userID) +``` + +--- + +## โœจ Features + +- โš™๏ธ Connection management with custom config +- ๐Ÿ“Š Fetch and update guild/user streak statistics +- ๐Ÿงพ Models for `users`, `guilds`, and `user_guild_stats` tables +- ๐Ÿ” Explicit, type-safe SQL operations +- ๐Ÿงช Tests for core stat logic and edge cases + +--- + +## ๐Ÿงช Testing + +```bash +go test ./libs/go/meowbot/feature/db +``` + +--- + +## ๐Ÿง  Schema Overview + +This package assumes the following schema (defined in `apps/database/meowbotdb/src/00_schema.sql`): + +- `guilds (id TEXT PRIMARY KEY)` +- `users (id TEXT PRIMARY KEY)` +- `user_guild_stats (guild_id TEXT, user_id TEXT, meow_count INT, high_score INT, PRIMARY KEY (guild_id, user_id))` + +--- + +## ๐Ÿ“Œ Notes + +- This package avoids global state; all DB functions are explicitly passed a `*sql.DB` instance. +- Intended for use in stateless containers with Postgres connectivity. +- Error handling is left to the callerโ€”check all return values! diff --git a/libs/go/meowbot/feature/db/connection.go b/libs/go/meowbot/feature/db/connection.go new file mode 100644 index 0000000..c18f8d7 --- /dev/null +++ b/libs/go/meowbot/feature/db/connection.go @@ -0,0 +1,52 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "libs/go/meowbot/util" + "time" + + _ "github.com/lib/pq" +) + +var DB *sql.DB + +// InitDB initializes the database connection and handles connection pooling and context management. +func InitDB(ctx context.Context) error { + connStr := util.Cfg.DatabaseURL + if connStr == "" { + // Log a warning or use a fallback connection string for local dev environments + connStr = "postgres://default_user:default_password@127.0.0.1:5432/meowbot?sslmode=disable" + } + + // Open the database connection + var err error + DB, err = sql.Open("postgres", connStr) + if err != nil { + return fmt.Errorf("failed to open DB connection: %w", err) + } + + // Set connection pool options + DB.SetMaxOpenConns(10) // Maximum number of open connections to the database + DB.SetMaxIdleConns(5) // Maximum number of idle connections in the pool + DB.SetConnMaxLifetime(30 * time.Minute) // Maximum amount of time a connection may be reused + + // Attempt to ping the database to ensure the connection is live + if err := DB.PingContext(ctx); err != nil { + return fmt.Errorf("failed to ping DB: %w", err) + } + + // Connection is successful + return nil +} + +// CloseDB ensures that the DB connection is properly closed when the application shuts down. +func CloseDB() error { + if DB != nil { + if err := DB.Close(); err != nil { + return fmt.Errorf("failed to close DB connection: %w", err) + } + } + return nil +} diff --git a/libs/go/meowbot/feature/db/go.mod b/libs/go/meowbot/feature/db/go.mod new file mode 100644 index 0000000..eca8333 --- /dev/null +++ b/libs/go/meowbot/feature/db/go.mod @@ -0,0 +1,15 @@ +module libs/go/meowbot/feature/db + +go 1.24 + +require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/libs/go/meowbot/feature/db/go.sum b/libs/go/meowbot/feature/db/go.sum new file mode 100644 index 0000000..5966605 --- /dev/null +++ b/libs/go/meowbot/feature/db/go.sum @@ -0,0 +1,15 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/libs/go/meowbot/feature/db/models.go b/libs/go/meowbot/feature/db/models.go new file mode 100644 index 0000000..2e8748b --- /dev/null +++ b/libs/go/meowbot/feature/db/models.go @@ -0,0 +1,71 @@ +package db + +import ( + "time" +) + +type Guild struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type UserGuildStats struct { + GuildID string `json:"guild_id"` + UserID string `json:"user_id"` + SuccessfulMeows int `json:"successful_meows"` + FailedMeows int `json:"failed_meows"` + TotalMeows int `json:"total_meows"` + CurrentStreak int `json:"current_streak"` + HighestStreak int `json:"highest_streak"` + LastMeowAt *time.Time `json:"last_meow_at,omitempty"` + LastFailedMeowAt *time.Time `json:"last_failed_meow_at,omitempty"` +} + +type GuildStreak struct { + GuildID string `json:"guild_id"` + MeowCount int `json:"meow_count"` + LastUserID *string `json:"last_user_id,omitempty"` + HighScore int `json:"high_score"` + HighScoreUserID *string `json:"high_score_user_id,omitempty"` +} + +type GlobalStats struct { + TotalGuilds int `json:"total_guilds"` + TotalUsers int `json:"total_users"` + TotalMeows int `json:"total_meows"` +} + +type LeaderboardEntry struct { + User *User `json:"user"` + TotalMeows int `json:"total_meows"` + SuccessfulMeows int `json:"successful_meows"` + FailedMeows int `json:"failed_meows"` +} + +type GuildStats struct { + Guild *Guild `json:"guild"` + CurrentStreak int `json:"current_streak"` + LastUser *User `json:"last_user,omitempty"` + HighScore int `json:"high_score"` + HighScoreUser *User `json:"high_score_user,omitempty"` + TotalMeows int `json:"total_meows"` + SuccessfulMeows int `json:"successful_meows"` + FailedMeows int `json:"failed_meows"` +} + +type UserGlobalStats struct { + UserID string `json:"user_id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + SuccessfulMeows int `json:"successful_meows"` + FailedMeows int `json:"failed_meows"` + TotalMeows int `json:"total_meows"` + HighestStreak int `json:"highest_streak"` + GuildStats []UserGuildStats `json:"guild_stats,omitempty"` +} diff --git a/libs/go/meowbot/feature/db/project.json b/libs/go/meowbot/feature/db/project.json new file mode 100644 index 0000000..a752582 --- /dev/null +++ b/libs/go/meowbot/feature/db/project.json @@ -0,0 +1,18 @@ +{ + "name": "go-meowbot-feature-db", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/go/meowbot/feature/db", + "tags": ["type:library", "language:go", "scope:meowbot", "library:feature"], + "targets": { + "test": { + "executor": "@nx-go/nx-go:test" + }, + "lint": { + "executor": "@nx-go/nx-go:lint" + }, + "tidy": { + "executor": "@nx-go/nx-go:tidy" + } + } +} diff --git a/libs/go/meowbot/feature/db/stats.go b/libs/go/meowbot/feature/db/stats.go new file mode 100644 index 0000000..6956125 --- /dev/null +++ b/libs/go/meowbot/feature/db/stats.go @@ -0,0 +1,607 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +func UpsertUser(ctx context.Context, db *sql.DB, user User) error { + query := ` + INSERT INTO users (id, username) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE + SET username = EXCLUDED.username; + ` + _, err := db.ExecContext(ctx, query, user.ID, user.Username) + return err +} + +func UpsertGuild(ctx context.Context, db *sql.DB, guild Guild) error { + query := ` + INSERT INTO guilds (id) + VALUES ($1) + ON CONFLICT (id) DO NOTHING; + ` + + _, err := db.ExecContext(ctx, query, guild.ID) + return err +} + +func UpsertGuildChannel(ctx context.Context, db *sql.DB, guildID, channelID string) error { + query := ` + INSERT INTO guild_channels (guild_id, channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) DO UPDATE SET + channel_id = EXCLUDED.channel_id; + ` + + _, err := db.ExecContext(ctx, query, guildID, channelID) + if err != nil { + return fmt.Errorf("failed to upsert guild channel: %w", err) + } + return nil +} + +func GetChannelForGuild(ctx context.Context, db *sql.DB, guildID string) (string, error) { + query := `SELECT channel_id FROM guild_channels WHERE guild_id = $1;` + + var channelID string + err := db.QueryRowContext(ctx, query, guildID).Scan(&channelID) + + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to get guild channel: %w", err) + } + return channelID, nil +} + +func IncrementMeow(ctx context.Context, db *sql.DB, guildID, userID string, success bool, now time.Time) error { + successQuery := ` + INSERT INTO user_guild_stats (guild_id, user_id, successful_meows, total_meows, current_streak, highest_streak, last_meow_at) + VALUES ($1, $2, 1, 1, 1, 1, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET + successful_meows = user_guild_stats.successful_meows + 1, + total_meows = user_guild_stats.total_meows + 1, + current_streak = user_guild_stats.current_streak + 1, + highest_streak = GREATEST(user_guild_stats.highest_streak, user_guild_stats.current_streak + 1), + last_meow_at = $3; + ` + failureQuery := ` + INSERT INTO user_guild_stats (guild_id, user_id, failed_meows, total_meows, current_streak, last_failed_meow_at) + VALUES ($1, $2, 1, 1, 0, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET + failed_meows = user_guild_stats.failed_meows + 1, + total_meows = user_guild_stats.total_meows + 1, + current_streak = 0, + last_failed_meow_at = $3; + ` + + if success { + _, err := db.ExecContext(ctx, successQuery, guildID, userID, now) + return err + } + + _, err := db.ExecContext(ctx, failureQuery, guildID, userID, now) + return err +} + +func GetGuildStreak(ctx context.Context, db *sql.DB, guildID string) (*GuildStreak, error) { + query := ` + SELECT guild_id, meow_count, last_user_id, high_score, high_score_user_id + FROM guild_streaks + WHERE guild_id = $1; + ` + + row := db.QueryRowContext(ctx, query, guildID) + + var gs GuildStreak + err := row.Scan(&gs.GuildID, &gs.MeowCount, &gs.LastUserID, &gs.HighScore, &gs.HighScoreUserID) + if err != nil { + return nil, err + } + return &gs, nil +} + +func UpsertGuildStreak(ctx context.Context, db *sql.DB, streak GuildStreak) error { + query := ` + INSERT INTO guild_streaks (guild_id, meow_count, last_user_id, high_score, high_score_user_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (guild_id) DO UPDATE SET + meow_count = EXCLUDED.meow_count, + last_user_id = EXCLUDED.last_user_id, + high_score = EXCLUDED.high_score, + high_score_user_id = EXCLUDED.high_score_user_id; + ` + _, err := db.ExecContext( + ctx, + query, + streak.GuildID, + streak.MeowCount, + streak.LastUserID, + streak.HighScore, + streak.HighScoreUserID, + ) + return err +} + +func GetUserStats( + ctx context.Context, + db *sql.DB, + guildID *string, // nil means global + userID string, +) (UserGuildStats, error) { + var ( + query string + args []any + ) + + // If a specific guild, just pull that row. + if guildID != nil { + query = ` + SELECT + guild_id, + user_id, + successful_meows, + failed_meows, + total_meows, + current_streak, + highest_streak, + last_meow_at, + last_failed_meow_at + FROM user_guild_stats + WHERE guild_id = $1 AND user_id = $2 + ` + args = []any{*guildID, userID} + + // Otherwise aggregate globally: + } else { + query = ` + SELECT + '' AS guild_id, + user_id, + COALESCE(SUM(successful_meows),0) AS successful_meows, + COALESCE(SUM(failed_meows),0) AS failed_meows, + COALESCE(SUM(total_meows),0) AS total_meows, + COALESCE(MAX(current_streak),0) AS current_streak, + COALESCE(MAX(highest_streak),0) AS highest_streak, + MAX(last_meow_at) AS last_meow_at, + MAX(last_failed_meow_at) AS last_failed_meow_at + FROM user_guild_stats + WHERE user_id = $1 + GROUP BY user_id + ` + args = []any{userID} + } + + var stats UserGuildStats + err := db.QueryRowContext(ctx, query, args...).Scan( + &stats.GuildID, + &stats.UserID, + &stats.SuccessfulMeows, + &stats.FailedMeows, + &stats.TotalMeows, + &stats.CurrentStreak, + &stats.HighestStreak, + &stats.LastMeowAt, + &stats.LastFailedMeowAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) && guildID != nil { + // no entry for this guild/user yet + return UserGuildStats{ + GuildID: *guildID, + UserID: userID, + SuccessfulMeows: 0, + FailedMeows: 0, + TotalMeows: 0, + CurrentStreak: 0, + HighestStreak: 0, + LastMeowAt: nil, + LastFailedMeowAt: nil, + }, nil + } + return UserGuildStats{}, fmt.Errorf("GetUserStats: %w", err) + } + return stats, nil +} + +func GetGlobalStats(ctx context.Context, db *sql.DB) (*GlobalStats, error) { + guildsQuery := `SELECT COUNT(*) FROM guilds` + usersQuery := `SELECT COUNT(*) FROM users` + usersGuildStatsQuery := `SELECT COALESCE(SUM(total_meows), 0) FROM user_guild_stats` + var stats GlobalStats + + err := db.QueryRowContext(ctx, guildsQuery).Scan(&stats.TotalGuilds) + if err != nil { + return nil, err + } + + err = db.QueryRowContext(ctx, usersQuery).Scan(&stats.TotalUsers) + if err != nil { + return nil, err + } + + err = db.QueryRowContext(ctx, usersGuildStatsQuery).Scan(&stats.TotalMeows) + if err != nil { + return nil, err + } + + return &stats, nil +} + +func GetUserGlobalStats(ctx context.Context, db *sql.DB, userID string) (UserGlobalStats, error) { + query := ` + SELECT + u.id, + u.username, + u.created_at, + COALESCE(SUM(ugs.successful_meows), 0), + COALESCE(SUM(ugs.failed_meows), 0), + COALESCE(SUM(ugs.total_meows), 0), + COALESCE(MAX(ugs.highest_streak), 0) + FROM users u + LEFT JOIN user_guild_stats ugs ON u.id = ugs.user_id + WHERE u.id = $1 + GROUP BY u.id, u.username, u.created_at + ` + + var res UserGlobalStats + err := db.QueryRowContext(ctx, query, userID).Scan( + &res.UserID, + &res.Username, + &res.CreatedAt, + &res.SuccessfulMeows, + &res.FailedMeows, + &res.TotalMeows, + &res.HighestStreak, + ) + if err != nil { + return UserGlobalStats{}, err + } + + return res, nil +} + +func GetLeaderboard3(ctx context.Context, db *sql.DB, limit int) (entries []LeaderboardEntry, err error) { + query := ` + SELECT u.id, u.username, u.created_at, SUM(ugs.total_meows) as total + FROM user_guild_stats ugs + JOIN users u ON ugs.user_id = u.id + GROUP BY u.id, u.username, u.created_at + ORDER BY total DESC + LIMIT $1; + ` + + rows, err := db.QueryContext(ctx, query, limit) + if err != nil { + return nil, fmt.Errorf("query leaderboard: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close rows: %w", closeErr) + } + }() + + for rows.Next() { + var user User + var entry LeaderboardEntry + + err := rows.Scan(&user.ID, &user.Username, &user.CreatedAt, &entry.TotalMeows) + if err != nil { + return nil, fmt.Errorf("scan leaderboard row: %w", err) + } + + entry.User = &user + entries = append(entries, entry) + } + + if rowsErr := rows.Err(); rowsErr != nil { + return nil, fmt.Errorf("iterate leaderboard rows: %w", rowsErr) + } + + return entries, nil +} + +func GetGuildStats(ctx context.Context, db *sql.DB, guildID string) (*GuildStats, error) { + query := ` + SELECT + g.id, g.created_at, + gs.meow_count, + lu.id, lu.username, lu.created_at, + gs.high_score, + hu.id, hu.username, hu.created_at, + COALESCE(SUM(ugs.total_meows), 0) as total_meows, + COALESCE(SUM(ugs.successful_meows), 0) as successful_meows, + COALESCE(SUM(ugs.failed_meows), 0) as failed_meows + FROM guild_streaks gs + JOIN guilds g ON g.id = gs.guild_id + LEFT JOIN users lu ON lu.id = gs.last_user_id + LEFT JOIN users hu ON hu.id = gs.high_score_user_id + LEFT JOIN user_guild_stats ugs ON gs.guild_id = ugs.guild_id + WHERE gs.guild_id = $1 + GROUP BY g.id, g.created_at, gs.meow_count, + lu.id, lu.username, lu.created_at, + gs.high_score, + hu.id, hu.username, hu.created_at; + ` + + row := db.QueryRowContext(ctx, query, guildID) + + var stats GuildStats + var guild Guild + var lastUser, highScoreUser User + var lastUserID, highScoreUserID sql.NullString + var lastUsername, highScoreUsername sql.NullString + var lastCreatedAt, highScoreCreatedAt sql.NullTime + + err := row.Scan( + &guild.ID, &guild.CreatedAt, + &stats.CurrentStreak, + &lastUserID, &lastUsername, &lastCreatedAt, + &stats.HighScore, + &highScoreUserID, &highScoreUsername, &highScoreCreatedAt, + &stats.TotalMeows, + &stats.SuccessfulMeows, + &stats.FailedMeows, + ) + + if err != nil { + return nil, fmt.Errorf("failed to scan guild stats: %w", err) + } + + stats.Guild = &guild + + if lastUserID.Valid && lastUsername.Valid && lastCreatedAt.Valid { + lastUser = User{ + ID: lastUserID.String, + Username: lastUsername.String, + CreatedAt: lastCreatedAt.Time, + } + stats.LastUser = &lastUser + } + + if highScoreUserID.Valid && highScoreUsername.Valid && highScoreCreatedAt.Valid { + highScoreUser = User{ + ID: highScoreUserID.String, + Username: highScoreUsername.String, + CreatedAt: highScoreCreatedAt.Time, + } + stats.HighScoreUser = &highScoreUser + } + + return &stats, nil +} + +func GetAllUsers(ctx context.Context, db *sql.DB) ([]*User, error) { + query := `SELECT id, username, created_at FROM users` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query users: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close rows: %w", closeErr) + } + }() + + var users []*User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, &u) + } + return users, err +} + +func GetAllGuilds(ctx context.Context, db *sql.DB) ([]*Guild, error) { + query := `SELECT id, created_at FROM guilds` + + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("query guilds: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close rows: %w", closeErr) + } + }() + + var guilds []*Guild + for rows.Next() { + var g Guild + if err := rows.Scan(&g.ID, &g.CreatedAt); err != nil { + return nil, fmt.Errorf("scan guild: %w", err) + } + guilds = append(guilds, &g) + } + return guilds, rows.Err() +} + +func GetUserPerGuildStats(ctx context.Context, db *sql.DB, userID string) ([]UserGuildStats, error) { + query := ` + SELECT + guild_id, + successful_meows, + failed_meows, + total_meows, + current_streak, + highest_streak, + last_meow_at, + last_failed_meow_at + FROM user_guild_stats + WHERE user_id = $1 + ` + + rows, err := db.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close rows: %w", closeErr) + } + }() + + var stats []UserGuildStats + for rows.Next() { + var s UserGuildStats + err := rows.Scan( + &s.GuildID, + &s.SuccessfulMeows, + &s.FailedMeows, + &s.TotalMeows, + &s.CurrentStreak, + &s.HighestStreak, + &s.LastMeowAt, + &s.LastFailedMeowAt, + ) + if err != nil { + return nil, err + } + s.UserID = userID + stats = append(stats, s) + } + + return stats, nil +} + +func GetLeaderboard( + ctx context.Context, + db *sql.DB, + guildID *string, // nil means global + metric string, // "total_meows", "successful_meows", or "failed_meows" + limit, offset int, +) ([]LeaderboardEntry, int, error) { + var ( + query string + countQuery string + queryArgs []any + countArgs []any + ) + + // Use aggregation + baseSelect := fmt.Sprintf(` + SELECT u.id, u.username, u.created_at, SUM(ugs.%s) AS value + FROM user_guild_stats ugs + JOIN users u ON u.id = ugs.user_id + `, metric) + + baseGroupOrder := ` GROUP BY u.id ORDER BY value DESC LIMIT %d OFFSET %d` + baseCount := `SELECT COUNT(*) FROM ( + SELECT ugs.user_id + FROM user_guild_stats ugs + %s + GROUP BY ugs.user_id + ) AS subquery` + + if guildID != nil { + whereClause := `WHERE ugs.guild_id = $1` + query = baseSelect + " " + whereClause + fmt.Sprintf(baseGroupOrder, limit, offset) + countQuery = fmt.Sprintf(baseCount, whereClause) + queryArgs = []any{*guildID} + countArgs = []any{*guildID} + } else { + query = baseSelect + fmt.Sprintf(baseGroupOrder, limit, offset) + countQuery = fmt.Sprintf(baseCount, "") + queryArgs = []any{} + countArgs = []any{} + } + + rows, err := db.QueryContext(ctx, query, queryArgs...) + if err != nil { + return nil, 0, fmt.Errorf("query leaderboard: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("failed to close rows: %w", closeErr) + } + }() + + var entries []LeaderboardEntry + for rows.Next() { + var user User + var entry LeaderboardEntry + var value int + + if err := rows.Scan(&user.ID, &user.Username, &user.CreatedAt, &value); err != nil { + return nil, 0, fmt.Errorf("scan leaderboard row: %w", err) + } + + entry.User = &user + switch metric { + case "total_meows": + entry.TotalMeows = value + case "successful_meows": + entry.SuccessfulMeows = value + case "failed_meows": + entry.FailedMeows = value + } + + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("rows error: %w", err) + } + + var total int + err = db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("count leaderboard: %w", err) + } + + return entries, total, nil +} + +func GetUserRank(ctx context.Context, db *sql.DB, userID string, guildID *string, column string) (int, error) { + // Validate column + validColumns := map[string]bool{ + "total_meows": true, + "successful_meows": true, + "failed_meows": true, + "highest_streak": true, + "current_streak": true, + } + if !validColumns[column] { + return 0, fmt.Errorf("invalid column name: %s", column) + } + + var ( + query string + args []any + ) + + if guildID != nil { + query = fmt.Sprintf(` + SELECT rank FROM ( + SELECT user_id, RANK() OVER (ORDER BY %s DESC) AS rank + FROM user_guild_stats + WHERE guild_id = $1 + ) ranked WHERE user_id = $2 + `, column) + args = []any{*guildID, userID} + } else { + query = fmt.Sprintf(` + SELECT rank FROM ( + SELECT user_id, RANK() OVER (ORDER BY SUM(%s) DESC) AS rank + FROM user_guild_stats + GROUP BY user_id + ) ranked WHERE user_id = $1 + `, column) + args = []any{userID} + } + + var rank int + err := db.QueryRowContext(ctx, query, args...).Scan(&rank) + return rank, err +} diff --git a/libs/go/meowbot/feature/db/stats_test.go b/libs/go/meowbot/feature/db/stats_test.go new file mode 100644 index 0000000..d388fae --- /dev/null +++ b/libs/go/meowbot/feature/db/stats_test.go @@ -0,0 +1,76 @@ +package db + +import ( + "context" + "database/sql" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func TestUpsertGuildChannel(t *testing.T) { + mockDB, mock, err := sqlmock.New() + require.NoError(t, err) + defer func(mockDB *sql.DB) { + err := mockDB.Close() + if err != nil { + + } + }(mockDB) + + // Expect an exec with the right SQL and args + mock.ExpectExec(regexp.QuoteMeta(` + INSERT INTO guild_channels (guild_id, channel_id) + VALUES ($1, $2) + ON CONFLICT (guild_id) DO UPDATE SET + channel_id = EXCLUDED.channel_id; + `)). + WithArgs("guild-123", "chan-456"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err = UpsertGuildChannel(context.Background(), mockDB, "guild-123", "chan-456") + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetChannelForGuild_NoRow(t *testing.T) { + mockDB, mock, _ := sqlmock.New() + defer func(mockDB *sql.DB) { + err := mockDB.Close() + if err != nil { + + } + }(mockDB) + + // Simulate no row + mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel_id FROM guild_channels WHERE guild_id = $1;`)). + WithArgs("guild-foo"). + WillReturnError(sql.ErrNoRows) + + cid, err := GetChannelForGuild(context.Background(), mockDB, "guild-foo") + require.NoError(t, err) + require.Equal(t, "", cid) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetChannelForGuild_Found(t *testing.T) { + mockDB, mock, _ := sqlmock.New() + defer func(mockDB *sql.DB) { + err := mockDB.Close() + if err != nil { + + } + }(mockDB) + + rows := sqlmock.NewRows([]string{"channel_id"}).AddRow("chan-789") + mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel_id FROM guild_channels WHERE guild_id = $1;`)). + WithArgs("guild-foo"). + WillReturnRows(rows) + + cid, err := GetChannelForGuild(context.Background(), mockDB, "guild-foo") + require.NoError(t, err) + require.Equal(t, "chan-789", cid) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/libs/go/meowbot/feature/handler/README.md b/libs/go/meowbot/feature/handler/README.md new file mode 100644 index 0000000..05629e0 --- /dev/null +++ b/libs/go/meowbot/feature/handler/README.md @@ -0,0 +1,85 @@ +# ๐Ÿ—ฃ๏ธ Meowbot Handler + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Nx](https://img.shields.io/badge/Nx-managed-blue) +![Go Module](https://img.shields.io/badge/Go-Module-brightgreen) + +**Meowbot Handler** is a Go package that powers the Discord event and command handling layer of [๐Ÿพ Meow Bot](https://github.com/dotablaze-tech/platform/tree/main/apps/go/meowbot). It defines logic for responding to message events, slash commands, and interaction components using the `discordgo` library. + +This package is organized under the monorepoโ€™s feature set and managed using [Nx](https://nx.dev). + +--- + +## ๐Ÿ“ Project Structure + +``` +libs/go/meowbot/feature/handler/ +โ”œโ”€โ”€ commands.go # Slash command handling logic +โ”œโ”€โ”€ messages.go # Regex-based message response logic +โ”œโ”€โ”€ messages_test.go # Unit tests for message handling +โ”œโ”€โ”€ go.mod / go.sum # Go module definition +โ””โ”€โ”€ project.json # Nx project definition +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Go 1.24+ +- [Nx CLI](https://nx.dev) + +### Installation + +Used internally by Meowbotโ€™s core app. Import like this: + +```go +import "github.com/dotablaze-tech/platform/libs/go/meowbot/feature/handler" +``` + +--- + +## โœจ Features + +- ๐Ÿ” Regex-based detection for โ€œmeowโ€ messages (`meooow`, `meeeeow`, etc.) +- ๐Ÿงฉ Handles Discord slash commands such as `/highscore` and `/leaderboard` +- ๐Ÿ“ฌ Responds to Discord message events with embedded state logic +- ๐Ÿ“œ Modular message and command routing +- ๐Ÿงช Unit tested for behavior correctness + +--- + +## ๐Ÿง  Example Usage + +```go +handler := handler.NewHandler(deps) + +session.AddHandler(handler.HandleMessageCreate) +session.AddHandler(handler.HandleInteractionCreate) +``` + +--- + +## ๐Ÿงช Testing + +Run tests for message event logic: + +```bash +go test ./libs/go/meowbot/feature/handler +``` + +--- + +## ๐Ÿ”Œ Integration Notes + +- The handler relies on `state` and `db` libraries for tracking and persistence. +- Slash commands should be registered using `commands.go` definitions during startup. +- Structured logging via `slog` is embedded throughout. + +--- + +## ๐Ÿ“Œ Notes + +- Not a standalone Discord bot. Requires orchestration via the main Meowbot app. +- Commands and interactions are modularโ€”easily extended via additional handlers. diff --git a/libs/go/meowbot/feature/handler/commands.go b/libs/go/meowbot/feature/handler/commands.go index e1cacf8..a17e79f 100644 --- a/libs/go/meowbot/feature/handler/commands.go +++ b/libs/go/meowbot/feature/handler/commands.go @@ -1,54 +1,629 @@ package handler import ( + "context" "fmt" "github.com/bwmarrin/discordgo" + "libs/go/meowbot/feature/db" "libs/go/meowbot/feature/state" - "log/slog" + "libs/go/meowbot/util" + "strconv" + "strings" + "time" ) -func CommandHandler(logger *slog.Logger) func(*discordgo.Session, *discordgo.InteractionCreate) { +const leaderboardPageSize = 5 + +func sendResponseEmbed( + s *discordgo.Session, + i *discordgo.InteractionCreate, + embed *discordgo.MessageEmbed, + guildID string, + commandName string, +) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + util.Cfg.Logger.Error(fmt.Sprintf("โŒ Failed to respond to /%s", commandName), "error", err, "guildID", guildID) + } else { + util.Cfg.Logger.Info(fmt.Sprintf("๐Ÿ’ฌ Responded to /%s", commandName), "guildID", guildID, "response", embed.Description) + } +} + +// CommandHandler manages slash commands +func CommandHandler(ctx context.Context) func(*discordgo.Session, *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type != discordgo.InteractionApplicationCommand { return } guildID := i.GuildID - gs := state.GetOrCreate(guildID) + gs := state.GetOrCreate(ctx, guildID) switch i.ApplicationCommandData().Name { - case "meowcount": - resp := fmt.Sprintf("Current meow count: %d", gs.MeowCount) + case "count": + handleCount(s, i, gs) + case "highscore": + handleHighscore(s, i, gs) + case "stats": + handleStats(ctx, s, i) + case "setup": + handleSetup(ctx, s, i) + case "leaderboard": + handleLeaderboard(ctx, s, i) + + default: + util.Cfg.Logger.Warn("โš ๏ธ Unknown command", "guildID", guildID, "command", i.ApplicationCommandData().Name) + } + } +} + +func handleCount(s *discordgo.Session, i *discordgo.InteractionCreate, gs *state.GuildState) { + title := "๐Ÿ“ˆ Meow Count" + desc := fmt.Sprintf("Current meow count: **%d**", gs.MeowCount) + + embed := formatSimpleEmbed(title, desc) + sendResponseEmbed(s, i, embed, i.GuildID, "count") +} + +func handleHighscore(s *discordgo.Session, i *discordgo.InteractionCreate, gs *state.GuildState) { + title := "๐Ÿ† High Score" + desc := "๐Ÿ˜ฟ No high score yet!" + if gs.HighScore > 0 { + desc = fmt.Sprintf("High score: **%d** by <@%s>", gs.HighScore, gs.HighScoreUserID) + } + + embed := formatSimpleEmbed(title, desc) + sendResponseEmbed(s, i, embed, i.GuildID, "highscore") +} + +func handleStats(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + scope := "guild" + for _, opt := range i.ApplicationCommandData().Options { + switch opt.Name { + case "scope": + scope = opt.StringValue() + } + } + + guildID := &i.GuildID + scopeTitle := "Guild Stats" + if scope == "global" { + guildID = nil + scopeTitle = "Global Stats" + } + + stats, err := db.GetUserStats(ctx, db.DB, guildID, i.Member.User.ID) + if err != nil { + sendErrorEmbed(s, i, "โŒ Failed to Fetch Stats", "Couldn't fetch your stats. You might not have any meows yet!", i.GuildID, "stats", err) + return + } + + lastMeow := "N/A" + if stats.LastMeowAt != nil { + lastMeow = time.Since(*stats.LastMeowAt).Round(time.Second).String() + " ago" + } + + title := fmt.Sprintf("๐Ÿ“Š **Your Meows โ€” %s**", scopeTitle) + resp := fmt.Sprintf( + "๐Ÿ“ˆ Total Meows: %d\n"+ + "โœ… Successful Meows: %d\n"+ + "โŒ Failed Meows: %d\n"+ + "๐Ÿ” Highest Streak: %d\n"+ + "๐Ÿ”ฅ Current Streak: %d\n"+ + "โฑ๏ธ Last Meow: %s", + stats.TotalMeows, + stats.SuccessfulMeows, + stats.FailedMeows, + stats.HighestStreak, + stats.CurrentStreak, + lastMeow, + ) + + embed := formatSimpleEmbed(title, resp) + sendResponseEmbed(s, i, embed, i.GuildID, "stats") +} + +func handleSetup(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID := i.GuildID + + if i.Member.Permissions&discordgo.PermissionAdministrator == 0 { + embed := formatSimpleEmbed("๐Ÿšซ Permission Denied", "You need to be a server admin to use this command.") + sendResponseEmbed(s, i, embed, guildID, "setup") + return + } + + options := i.ApplicationCommandData().Options + if len(options) != 1 || options[0].Name != "channel" { + embed := formatSimpleEmbed("โš ๏ธ Invalid Usage", "You must provide a channel using `/setup channel:#channel-name`.", 0xffff00) + sendResponseEmbed(s, i, embed, guildID, "setup") + return + } + + channelOpt := options[0] + channelID := channelOpt.ChannelValue(s).ID + + err := db.UpsertGuildChannel(ctx, db.DB, guildID, channelID) + if err != nil { + sendErrorEmbed(s, i, "โŒ Failed to Set Channel", "Failed to set meow channel. Try again later.", guildID, "setup", err) + return + } + + title := "โš™ Setup Complete" + resp := fmt.Sprintf("โœ… Meow channel has been set to <#%s>", channelID) + sendSuccessEmbed(s, i, title, resp, guildID, "setup") +} - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: resp, +func handleLeaderboard(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + // Default options + scope := "guild" + metric := "total" + page := 1 + + // Parse options + for _, opt := range i.ApplicationCommandData().Options { + switch opt.Name { + case "scope": + scope = opt.StringValue() + case "metric": + metric = opt.StringValue() + case "page": + page = int(opt.IntValue()) + } + } + + if page < 1 { + page = 1 + } + + // Determine metric column + var column string + switch metric { + case "success": + column = "successful_meows" + case "fail": + column = "failed_meows" + default: + column = "total_meows" + } + + // Set scope + var guildID *string + if scope == "guild" { + id := i.GuildID + guildID = &id + } + + // Fetch leaderboard data from DB + _, totalCount, err := db.GetLeaderboard(ctx, db.DB, guildID, column, 0, 0) + if err != nil { + sendErrorEmbed(s, i, "โŒ Failed to Fetch Leaderboard", "Something went wrong while retrieving leaderboard data.", i.GuildID, "leaderboard", err) + return + } + + maxPages := (totalCount + leaderboardPageSize - 1) / leaderboardPageSize + if maxPages < 1 { + maxPages = 1 + } + + if page > maxPages { + page = maxPages + } + + offset := (page - 1) * leaderboardPageSize + entries, _, err := db.GetLeaderboard(ctx, db.DB, guildID, column, leaderboardPageSize, offset) + if err != nil { + sendErrorEmbed(s, i, "โŒ Failed to Fetch Leaderboard", "Something went wrong while retrieving leaderboard data.", i.GuildID, "leaderboard", err) + return + } + + if len(entries) == 0 { + embed := formatSimpleEmbed("๐Ÿ“‰ Empty Leaderboard", "No one has meowed yet! Be the first.", 0xFEE75C) + sendResponseEmbed(s, i, embed, i.GuildID, "leaderboard") + return + } + + // Fetch user's rank if interaction is from a user + userRank, rankErr := db.GetUserRank(ctx, db.DB, i.Member.User.ID, guildID, column) + + // Format embed and buttons + embed := formatLeaderboardEmbed(entries, scope, metric, page, totalCount, userRank, rankErr, i.Member.User.ID) + components := renderLeaderboardButtons(scope, metric, page, totalCount) + + // Respond with leaderboard + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + util.Cfg.Logger.Error("โŒ Failed to send leaderboard response:", "error", err) + } +} + +// UnregisterCommands unregisters slash commands with Discord +func UnregisterCommands(sess *discordgo.Session) error { + cmds, _ := sess.ApplicationCommands(sess.State.User.ID, "") + for _, cmd := range cmds { + err := sess.ApplicationCommandDelete(sess.State.User.ID, "", cmd.ID) + if err != nil { + return err + } + util.Cfg.Logger.Info("โœ… Unregistered command", "command", cmd.Name) + } + return nil +} + +// RegisterCommands registers slash commands with Discord +func RegisterCommands(sess *discordgo.Session) error { + commands := []discordgo.ApplicationCommand{ + { + Name: "count", + Description: "Check the current meow count for this server", + }, + { + Name: "highscore", + Description: "Check the highest meow streak for this server", + }, + { + Name: "stats", + Description: "Check your personal meow stats", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "scope", + Description: "Whether to show the guild or global stats", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Guild", Value: "guild"}, + {Name: "Global", Value: "global"}, + }, }, - }) + }, + }, + { + Name: "setup", + Description: "Configure Meow Bot for this server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Channel where Meow Bot should listen for meows", + Required: true, + }, + }, + }, + { + Name: "leaderboard", + Description: "Show the top meowers", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "scope", + Description: "Whether to show the guild or global leaderboard", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Guild", Value: "guild"}, + {Name: "Global", Value: "global"}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "metric", + Description: "Leaderboard metric", + Required: false, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Total Meows", Value: "total"}, + {Name: "Successful Meows", Value: "success"}, + {Name: "Failed Meows", Value: "fail"}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "page", + Description: "Page number of the leaderboard", + Required: false, + }, + }, + }, + } + + for _, cmd := range commands { + if util.Cfg.IsProd { + _, err := sess.ApplicationCommandCreate(sess.State.User.ID, "", &cmd) if err != nil { - logger.Error("Failed to respond to /meowcount", "error", err, "guildID", guildID) - } else { - logger.Info("Responded to /meowcount", "guildID", guildID, "count", gs.MeowCount) + util.Cfg.Logger.Error("โŒ Failed to register command", "command", cmd.Name, "error", err) + return err } - - case "highscore": - resp := "No high score yet!" - if gs.HighScore > 0 { - resp = fmt.Sprintf("๐Ÿ† High score: %d by %s", gs.HighScore, gs.HighScoreUser) + util.Cfg.Logger.Info("โœ… Registered command", "command", cmd.Name) + } else { + for _, guildId := range util.Cfg.Whitelist.Guilds { + _, err := sess.ApplicationCommandCreate(sess.State.User.ID, guildId, &cmd) + if err != nil { + util.Cfg.Logger.Error("โŒ Failed to register command", "command", cmd.Name, "guildID", guildId, "error", err) + return err + } + util.Cfg.Logger.Info("โœ… Registered command", "command", cmd.Name, "guildID", guildId) } + } + } + + return nil +} + +func renderLeaderboardButtons(scope string, metric string, page, total int) []discordgo.MessageComponent { + totalPages := (total + leaderboardPageSize - 1) / leaderboardPageSize + + if totalPages <= 1 { + return nil + } + + firstDisabled := page == 1 + prevDisabled := page <= 1 + nextDisabled := page >= totalPages + lastDisabled := page == totalPages + + firstStyle := discordgo.PrimaryButton + prevStyle := discordgo.PrimaryButton + nextStyle := discordgo.PrimaryButton + lastStyle := discordgo.PrimaryButton - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: resp, + if firstDisabled { + firstStyle = discordgo.SecondaryButton + } + if prevDisabled { + prevStyle = discordgo.SecondaryButton + } + if nextDisabled { + nextStyle = discordgo.SecondaryButton + } + if lastDisabled { + lastStyle = discordgo.SecondaryButton + } + + return []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "โฎ๏ธ First", + Style: firstStyle, + CustomID: fmt.Sprintf("lb_goto:1:%s:%s", scope, metric), + Disabled: firstDisabled, }, - }) - if err != nil { - logger.Error("Failed to respond to /highscore", "error", err, "guildID", guildID) - } else { - logger.Info("Responded to /highscore", "guildID", guildID, "score", gs.HighScore) - } + discordgo.Button{ + Label: "โ—€๏ธ Prev", + Style: prevStyle, + CustomID: fmt.Sprintf("lb_prev:%d:%s:%s", page, scope, metric), + Disabled: prevDisabled, + }, + discordgo.Button{ + Label: "Next โ–ถ๏ธ", + Style: nextStyle, + CustomID: fmt.Sprintf("lb_next:%d:%s:%s", page, scope, metric), + Disabled: nextDisabled, + }, + discordgo.Button{ + Label: "Last โญ๏ธ", + Style: lastStyle, + CustomID: fmt.Sprintf("lb_goto:%d:%s:%s", totalPages, scope, metric), + Disabled: lastDisabled, + }, + }, + }, + } +} + +func formatLeaderboardEmbed(entries []db.LeaderboardEntry, scope, metric string, page, total int, userRank int, rankErr error, currentUserID string) *discordgo.MessageEmbed { + var sb strings.Builder + startRank := (page-1)*leaderboardPageSize + 1 + + for i, entry := range entries { + rank := startRank + i + count := getCountByMetric(entry, metric) + + // Highlight current user + line := fmt.Sprintf("**%2d.** <@%s> โ€” %d\n", rank, entry.User.ID, count) + if entry.User.ID == currentUserID { + line = fmt.Sprintf("**%2d.** ๐Ÿ‘‘ <@%s> โ€” %d\n", rank, entry.User.ID, count) + } + + sb.WriteString(line) + } + + title := buildTitle(scope, metric) + start := (page-1)*leaderboardPageSize + 1 + end := start + len(entries) - 1 + + footerText := fmt.Sprintf("๐Ÿ“„ Page %d โ€” Showing ranks %dโ€“%d of %d", page, start, end, total) + if rankErr == nil { + footerText += fmt.Sprintf(" | Your Rank: #%d", userRank) + } + + // Set color based on metric + var color int + switch metric { + case "success": + color = 0x00cc66 // green + case "fail": + color = 0xcc3300 // red + case "total": + color = 0x3399ff // blue + default: + color = 0xaaaaaa // gray fallback + } + + return &discordgo.MessageEmbed{ + Title: title, + Description: sb.String(), + Color: color, + Footer: &discordgo.MessageEmbedFooter{ + Text: footerText, + }, + } +} + +func formatSimpleEmbed(title, description string, color ...int) *discordgo.MessageEmbed { + embedColor := 0x5865F2 // Default Discord blurple + if len(color) > 0 { + embedColor = color[0] + } + return &discordgo.MessageEmbed{ + Title: title, + Description: description, + Color: embedColor, + } +} + +func sendErrorEmbed( + s *discordgo.Session, + i *discordgo.InteractionCreate, + title, message string, + guildID string, + context string, + err error, +) { + util.Cfg.Logger.Error(title, "guildID", guildID, "error", err) + embed := formatSimpleEmbed(title, fmt.Sprintf("%s\n\n```%s```", message, err.Error()), 0xED4245) // red + sendResponseEmbed(s, i, embed, guildID, context) +} + +func sendSuccessEmbed( + s *discordgo.Session, + i *discordgo.InteractionCreate, + title, message string, + guildID string, + context string, +) { + embed := formatSimpleEmbed(title, message, 0x57F287) // green + sendResponseEmbed(s, i, embed, guildID, context) +} + +func ComponentHandler(ctx context.Context) func(*discordgo.Session, *discordgo.InteractionCreate) { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type == discordgo.InteractionMessageComponent { + handleLeaderboardPagination(ctx, s, i) + return } } } + +func getCountByMetric(e db.LeaderboardEntry, metric string) int { + switch metric { + case "success": + return e.SuccessfulMeows + case "fail": + return e.FailedMeows + default: + return e.TotalMeows + } +} + +func buildTitle(scope, metric string) string { + var scopeLabel, metricLabel string + + // Scope context + if scope == "global" { + scopeLabel = "Global Leaderboard ๐ŸŒ" + } else { + scopeLabel = "Guild Leaderboard ๐Ÿ " + } + + // Metric context + switch metric { + case "success": + metricLabel = "Most Successful Meows" + case "fail": + metricLabel = "Most Failed Meows" + default: + metricLabel = "Most Total Meows" + } + + return fmt.Sprintf("๐Ÿ† %s โ€” %s", metricLabel, scopeLabel) + +} + +func handleLeaderboardPagination(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + data := strings.Split(i.MessageComponentData().CustomID, ":") + if len(data) != 4 { + // Invalid format + return + } + + action := data[0] + pageStr := data[1] + scope := data[2] + metric := data[3] + + page, err := strconv.Atoi(pageStr) + if err != nil || page < 1 { + page = 1 + } + + // Update page number + switch action { + case "lb_prev": + page-- + case "lb_next": + page++ + case "lb_goto": + default: + return + } + if page < 1 { + page = 1 + } + + // Determine metric column + var column string + switch metric { + case "success": + column = "successful_meows" + case "fail": + column = "failed_meows" + default: + column = "total_meows" + } + + // Set scope + var guildID *string + if scope == "guild" { + id := i.GuildID + guildID = &id + } + + offset := (page - 1) * leaderboardPageSize + + entries, totalCount, err := db.GetLeaderboard(ctx, db.DB, guildID, column, leaderboardPageSize, offset) + + userRank, rankErr := db.GetUserRank(ctx, db.DB, i.Member.User.ID, guildID, column) + + if err != nil || len(entries) == 0 { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "โš ๏ธ Couldn't load that page of the leaderboard.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + embed := formatLeaderboardEmbed(entries, scope, metric, page, totalCount, userRank, rankErr, i.Member.User.ID) + components := renderLeaderboardButtons(scope, metric, page, totalCount) + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + }, + }) +} diff --git a/libs/go/meowbot/feature/handler/go.mod b/libs/go/meowbot/feature/handler/go.mod index d972f2d..7745081 100644 --- a/libs/go/meowbot/feature/handler/go.mod +++ b/libs/go/meowbot/feature/handler/go.mod @@ -1,8 +1,6 @@ module libs/go/meowbot/feature/handler -go 1.23.0 - -toolchain go1.23.1 +go 1.24.0 require github.com/bwmarrin/discordgo v0.28.1 diff --git a/libs/go/meowbot/feature/handler/messages.go b/libs/go/meowbot/feature/handler/messages.go index 5b0117f..bf72170 100644 --- a/libs/go/meowbot/feature/handler/messages.go +++ b/libs/go/meowbot/feature/handler/messages.go @@ -1,101 +1,169 @@ package handler import ( + "context" "fmt" "github.com/bwmarrin/discordgo" + "libs/go/meowbot/feature/db" "libs/go/meowbot/feature/state" "libs/go/meowbot/util" - "log/slog" "regexp" "strings" + "time" ) var meowRegex = regexp.MustCompile(`(?i)^m+e+o+w+$`) -func MessageHandler(logger *slog.Logger, allowedChannel string) func(*discordgo.Session, *discordgo.MessageCreate) { +func sendMessage(s *discordgo.Session, channelID, message, guildID string) error { + if !util.Cfg.IsAllowedGuild(guildID) { + util.Cfg.Logger.Debug("โœ‰๏ธ [DEV] Skipped sending message", "guildID", guildID, "channelID", channelID, "message", message) + return nil + } + _, err := s.ChannelMessageSend(channelID, message) + if err != nil { + util.Cfg.Logger.Error("โŒ Failed to send message", "guildID", guildID, "channelID", channelID, "error", err) + } + return err +} + +func safeReact(s *discordgo.Session, channelID, messageID, emoji, guildID string) { + if !util.Cfg.IsAllowedGuild(guildID) { + util.Cfg.Logger.Debug("๐Ÿ”• [DEV] Skipped reaction", "guildID", guildID, "emoji", emoji) + return + } + if err := s.MessageReactionAdd(channelID, messageID, emoji); err != nil { + util.Cfg.Logger.Warn("โš ๏ธ Failed to react", "emoji", emoji, "channelID", channelID, "messageID", messageID, "error", err) + } +} + +func upsertEntities(ctx context.Context, user *discordgo.User, guildID string) { + if err := db.UpsertGuild(ctx, db.DB, db.Guild{ID: guildID}); err != nil { + util.Cfg.Logger.Error("โŒ Failed to upsert guild", "guildID", guildID, "error", err) + } + if err := db.UpsertUser(ctx, db.DB, db.User{ + ID: user.ID, + Username: user.Username, + }); err != nil { + util.Cfg.Logger.Error("โŒ Failed to upsert user", "userID", user.ID, "username", user.Username, "error", err) + } +} + +func incrementMeow(ctx context.Context, guildID string, userID string, isMeow bool, timestamp time.Time) { + if err := db.IncrementMeow(ctx, db.DB, guildID, userID, isMeow, timestamp); err != nil { + util.Cfg.Logger.Error("โŒ Failed to increment meow", "guildID", guildID, "userID", userID, "error", err) + } +} + +func isInAllowedChannel(ctx context.Context, m *discordgo.MessageCreate) bool { + allowedChannelID, err := db.GetChannelForGuild(ctx, db.DB, m.GuildID) + if err != nil { + util.Cfg.Logger.Error("โŒ Could not fetch allowed channel", "guildID", m.GuildID, "channelID", m.ChannelID, "error", err) + return false + } + if allowedChannelID == "" || m.ChannelID != allowedChannelID { + util.Cfg.Logger.Debug("๐Ÿšซ Message in unauthorized channel", "guildID", m.GuildID, "channelID", m.ChannelID, "allowedChannelID", allowedChannelID) + return false + } + return true +} + +func processMeowMessage(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) { + content := strings.ToLower(strings.TrimSpace(m.Content)) + guildID := m.GuildID + user := m.Author + gs := state.GetOrCreate(ctx, guildID) + + util.Cfg.Logger.Info("๐Ÿ“ฌ Message received", "guildID", guildID, "channelID", m.ChannelID, "userID", user.ID, "username", user.Username, "content", m.Content) + + if meowRegex.MatchString(content) { + handleMeow(ctx, s, m, gs) + } else { + handleNonMeow(ctx, s, m) + } +} + +func handleMeow(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate, gs *state.GuildState) { + user := m.Author + guildID := m.GuildID + + if user.ID == gs.LastUserID { + incrementMeow(ctx, guildID, user.ID, false, m.Timestamp) + safeReact(s, m.ChannelID, m.ID, "โŒ", guildID) + err := sendMessage(s, m.ChannelID, "๐Ÿ˜พ You can't meow twice in a row!", guildID) + if err != nil { + return + } + util.Cfg.Logger.Warn("๐Ÿ”‚ Repeat meow", "guildID", guildID, "userID", user.ID) + state.Reset(guildID) + return + } + + gs.MeowCount++ + if gs.MeowCount > gs.HighScore { + gs.HighScore = gs.MeowCount + gs.HighScoreUserID = user.ID + err := sendMessage(s, m.ChannelID, fmt.Sprintf("๐Ÿ† New high score: %d meows by %s!", gs.HighScore, user.Username), guildID) + if err != nil { + return + } + util.Cfg.Logger.Info("๐Ÿ† New high score", "guildID", guildID, "userID", user.ID, "score", gs.HighScore) + } + + gs.LastUserID = user.ID + incrementMeow(ctx, guildID, user.ID, true, m.Timestamp) + err := sendMessage(s, m.ChannelID, fmt.Sprintf("%s **meow** x%d!", util.RandomEmoji(), gs.MeowCount), guildID) + if err != nil { + return + } + safeReact(s, m.ChannelID, m.ID, "๐Ÿฑ", guildID) + + err = db.UpsertGuildStreak(ctx, db.DB, db.GuildStreak{ + GuildID: guildID, + MeowCount: gs.MeowCount, + LastUserID: &gs.LastUserID, + HighScore: gs.HighScore, + HighScoreUserID: &gs.HighScoreUserID, + }) + if err != nil { + util.Cfg.Logger.Error("โŒ Failed to upsert guild streak", "guildID", guildID, "error", err) + return + } +} + +func handleNonMeow(ctx context.Context, s *discordgo.Session, m *discordgo.MessageCreate) { + user := m.Author + guildID := m.GuildID + + safeReact(s, m.ChannelID, m.ID, "โŒ", guildID) + err := sendMessage(s, m.ChannelID, "โŒ No meow? Resetting.", guildID) + if err != nil { + return + } + incrementMeow(ctx, guildID, user.ID, false, m.Timestamp) + state.Reset(guildID) + + util.Cfg.Logger.Info("๐Ÿ”„ Reset triggered", "guildID", guildID, "userID", user.ID) +} + +func logIgnoreBotMessage(m *discordgo.MessageCreate) { + util.Cfg.Logger.Debug("๐Ÿค– Ignored bot message", "guildID", m.GuildID, "channelID", m.ChannelID) +} + +func MessageHandler(ctx context.Context) func(*discordgo.Session, *discordgo.MessageCreate) { return func(s *discordgo.Session, m *discordgo.MessageCreate) { + // skip bot messages if m.Author.ID == s.State.User.ID { + logIgnoreBotMessage(m) return } - if allowedChannel != "" && m.ChannelID != allowedChannel { - logger.Debug("Ignored message from unauthorized channel", - "channelID", m.ChannelID, - "guildID", m.GuildID, - ) + if !isInAllowedChannel(ctx, m) { return } - guildID := m.GuildID - content := strings.ToLower(strings.TrimSpace(m.Content)) - gs := state.GetOrCreate(guildID) - - logger.Info("Message received", - "user", m.Author.Username, - "userID", m.Author.ID, - "content", m.Content, - "channelID", m.ChannelID, - "guildID", guildID, - ) - - if meowRegex.MatchString(content) { - if m.Author.ID == gs.LastUserID { - if _, err := s.ChannelMessageSend(m.ChannelID, "๐Ÿ˜พ You can't meow twice in a row!"); err != nil { - logger.Error("Failed to send repeat warning", - "error", err, - "guildID", guildID, - "channelID", m.ChannelID, - ) - } - logger.Warn("Repeat meow", - "user", m.Author.Username, - "guildID", guildID, - ) - state.Reset(guildID) - return - } - - gs.MeowCount++ - - if gs.MeowCount > gs.HighScore { - gs.HighScore = gs.MeowCount - gs.HighScoreUser = m.Author.Username - - if _, err := s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("๐Ÿ† New high score: %d meows by %s!", gs.HighScore, m.Author.Username)); err != nil { - logger.Error("Failed to send high score message", "error", err, "guildID", guildID) - } - logger.Info("New high score", "user", m.Author.Username, "score", gs.HighScore, "guildID", guildID) - } - - gs.LastUserID = m.Author.ID - msg := fmt.Sprintf("%s **meow** x%d!", util.RandomEmoji(), gs.MeowCount) - - if _, err := s.ChannelMessageSend(m.ChannelID, msg); err != nil { - logger.Error("Failed to send meow message", - "error", err, - "guildID", guildID, - "channelID", m.ChannelID, - ) - } else { - logger.Info("Meow counted", - "user", m.Author.Username, - "count", gs.MeowCount, - "guildID", guildID, - ) - } - } else { - if _, err := s.ChannelMessageSend(m.ChannelID, "โŒ No meow? Resetting."); err != nil { - logger.Error("Failed to send reset message", - "error", err, - "guildID", guildID, - "channelID", m.ChannelID, - ) - } - state.Reset(guildID) - logger.Info("Reset triggered", - "user", m.Author.Username, - "guildID", guildID, - ) - } + // Upsert user + guild + upsertEntities(ctx, m.Author, m.GuildID) + + processMeowMessage(ctx, s, m) } } diff --git a/libs/go/meowbot/feature/handler/messages_test.go b/libs/go/meowbot/feature/handler/messages_test.go new file mode 100644 index 0000000..1155aa2 --- /dev/null +++ b/libs/go/meowbot/feature/handler/messages_test.go @@ -0,0 +1,33 @@ +package handler + +import ( + "strings" + "testing" +) + +func TestMeowRegex(t *testing.T) { + cases := []struct { + input string + should bool + }{ + {"meow", true}, + {"MEOW", true}, + {"MEEEOOOWWWW", true}, + {"mEoW", true}, + {" meow ", true}, // whitespace is trimmed in your handler + {"meow1", false}, + {"m e o w", false}, + {"moo", false}, + {"woof", false}, + {"", false}, + } + + for _, c := range cases { + // simulate your handler's normalization + norm := strings.ToLower(strings.TrimSpace(c.input)) + matched := meowRegex.MatchString(norm) + if matched != c.should { + t.Errorf("meowRegex.MatchString(%q) = %v, want %v", c.input, matched, c.should) + } + } +} diff --git a/libs/go/meowbot/feature/state/README.md b/libs/go/meowbot/feature/state/README.md new file mode 100644 index 0000000..7078ec2 --- /dev/null +++ b/libs/go/meowbot/feature/state/README.md @@ -0,0 +1,83 @@ +# ๐Ÿง  Meowbot State + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Nx](https://img.shields.io/badge/Nx-managed-blue) +![Go Module](https://img.shields.io/badge/Go-Module-brightgreen) + +**Meowbot State** is a modular Go library for managing in-memory state tracking within the [๐Ÿพ Meow Bot](https://github.com/dotablaze-tech/platform/tree/main/apps/go/meowbot). It maintains per-guild data such as streak counts, user participation, and high score tracking to enable responsive, context-aware bot behavior. + +This package is part of the monorepoโ€™s feature set and is managed by [Nx](https://nx.dev). + +--- + +## ๐Ÿ“ Project Structure + +``` +libs/go/meowbot/feature/state/ +โ”œโ”€โ”€ state.go # Core state management logic +โ”œโ”€โ”€ state_test.go # Unit tests for state behavior +โ”œโ”€โ”€ go.mod / go.sum # Go module definition +โ””โ”€โ”€ project.json # Nx project definition +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Go 1.24+ +- [Nx CLI](https://nx.dev) + +### Installation + +Used internally within Meowbot features. To import: + +```go +import "github.com/dotablaze-tech/platform/libs/go/meowbot/feature/state" +``` + +--- + +## โœจ Features + +- โœ… Tracks per-guild streaks and user activity +- ๐Ÿšซ Prevents same-user repeat meows +- ๐Ÿ† Maintains high score history +- ๐Ÿ” Provides reset logic and accessors +- ๐Ÿ”’ Thread-safe for concurrent access + +--- + +## ๐Ÿงช Testing + +Run unit tests to verify state behavior: + +```bash +go test ./libs/go/meowbot/feature/state +``` + +--- + +## ๐Ÿง  Example Usage + +```go +state := NewGuildState() + +ok := state.TryMeow("user123") +if ok { + fmt.Println("Meow accepted! Current streak:", state.MeowCount) +} else { + fmt.Println("Invalid meow! You can't meow twice in a row.") +} + +highScoreUser := state.HighScoreUser +``` + +--- + +## ๐Ÿ“Œ Notes + +- The state is ephemeral by default. For persistence, pair it with the [db](../db) feature. +- Designed for single-channel operation per guild. +- This package does not include Discord-specific logicโ€”pure Go state. diff --git a/libs/go/meowbot/feature/state/go.mod b/libs/go/meowbot/feature/state/go.mod index f916ca9..d4eb9e4 100644 --- a/libs/go/meowbot/feature/state/go.mod +++ b/libs/go/meowbot/feature/state/go.mod @@ -1,3 +1,11 @@ module libs/go/meowbot/feature/state -go 1.23 +go 1.24 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/libs/go/meowbot/feature/state/go.sum b/libs/go/meowbot/feature/state/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/libs/go/meowbot/feature/state/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/libs/go/meowbot/feature/state/state.go b/libs/go/meowbot/feature/state/state.go index a04f2b6..59aa102 100644 --- a/libs/go/meowbot/feature/state/state.go +++ b/libs/go/meowbot/feature/state/state.go @@ -1,47 +1,62 @@ package state import ( + "context" + "libs/go/meowbot/feature/db" "sync" ) type GuildState struct { - MeowCount int - LastUserID string - HighScore int - HighScoreUser string + MeowCount int + LastUserID string + HighScore int + HighScoreUserID string } var ( + getGuildStreak = func(ctx context.Context, guildID string) (*db.GuildStreak, error) { + return db.GetGuildStreak(ctx, db.DB, guildID) + } + mu sync.Mutex store = make(map[string]*GuildState) ) -// GetOrCreate returns the guild state, creating it if needed. -func GetOrCreate(guildID string) *GuildState { +func GetOrCreate(ctx context.Context, guildID string) *GuildState { mu.Lock() defer mu.Unlock() - gs, ok := store[guildID] - if !ok { - gs = &GuildState{} - store[guildID] = gs + if gs, ok := store[guildID]; ok { + return gs + } + + dbStreak, err := getGuildStreak(ctx, guildID) + if err != nil { + dbStreak = &db.GuildStreak{} // fallback } + + gs := &GuildState{ + MeowCount: dbStreak.MeowCount, + LastUserID: deref(dbStreak.LastUserID), + HighScore: dbStreak.HighScore, + HighScoreUserID: deref(dbStreak.HighScoreUserID), + } + store[guildID] = gs return gs } -// Reset clears state for a guild. func Reset(guildID string) { mu.Lock() defer mu.Unlock() - if gs, ok := store[guildID]; ok { gs.MeowCount = 0 gs.LastUserID = "" } } -func ResetAll(guildID string) { - mu.Lock() - defer mu.Unlock() - store[guildID] = &GuildState{} +func deref(s *string) string { + if s == nil { + return "" + } + return *s } diff --git a/libs/go/meowbot/feature/state/state_test.go b/libs/go/meowbot/feature/state/state_test.go new file mode 100644 index 0000000..48a3784 --- /dev/null +++ b/libs/go/meowbot/feature/state/state_test.go @@ -0,0 +1,83 @@ +package state + +import ( + "context" + "errors" + "libs/go/meowbot/feature/db" + "testing" + + "github.com/stretchr/testify/assert" +) + +// helper to get a *string +func strPtr(s string) *string { return &s } + +func TestDeref(t *testing.T) { + assert.Equal(t, "", deref(nil)) + assert.Equal(t, "foo", deref(strPtr("foo"))) +} + +func TestGetOrCreate_CachesState(t *testing.T) { + // ensure fresh store + store = make(map[string]*GuildState) + + // stub out DB call that should never be called + getGuildStreak = func(ctx context.Context, guildID string) (*db.GuildStreak, error) { + t.Fatal("getGuildStreak should not be called when state already exists") + return nil, nil + } + + // pre-populate + expected := &GuildState{MeowCount: 42, LastUserID: "u", HighScore: 7, HighScoreUserID: "u2"} + store["g1"] = expected + + gs := GetOrCreate(context.Background(), "g1") + assert.Same(t, expected, gs) +} + +func TestGetOrCreate_LoadsFromDB(t *testing.T) { + // clear store + store = make(map[string]*GuildState) + + // stub DB call + getGuildStreak = func(_ context.Context, guildID string) (*db.GuildStreak, error) { + assert.Equal(t, "g2", guildID) + return &db.GuildStreak{ + GuildID: "g2", + MeowCount: 5, + LastUserID: strPtr("u3"), + HighScore: 10, + HighScoreUserID: strPtr("u4"), + }, nil + } + + gs := GetOrCreate(context.Background(), "g2") + assert.NotNil(t, gs) + assert.Equal(t, 5, gs.MeowCount) + assert.Equal(t, "u3", gs.LastUserID) + assert.Equal(t, 10, gs.HighScore) + assert.Equal(t, "u4", gs.HighScoreUserID) +} + +func TestGetOrCreate_DBErrorFallsBack(t *testing.T) { + store = make(map[string]*GuildState) + + getGuildStreak = func(_ context.Context, guildID string) (*db.GuildStreak, error) { + return nil, errors.New("boom") + } + + gs := GetOrCreate(context.Background(), "g3") + assert.NotNil(t, gs) + // fallback -> zero values + assert.Equal(t, 0, gs.MeowCount) + assert.Equal(t, "", gs.LastUserID) +} + +func TestReset(t *testing.T) { + store = make(map[string]*GuildState) + store["g4"] = &GuildState{MeowCount: 9, LastUserID: "u5"} + Reset("g4") + gs := store["g4"] + assert.Equal(t, 0, gs.MeowCount) + assert.Equal(t, "", gs.LastUserID) +} diff --git a/libs/go/meowbot/util/README.md b/libs/go/meowbot/util/README.md new file mode 100644 index 0000000..943bd46 --- /dev/null +++ b/libs/go/meowbot/util/README.md @@ -0,0 +1,94 @@ +# ๐Ÿงฐ Meowbot Util + +![Build](https://img.shields.io/github/actions/workflow/status/dotablaze-tech/platform/ci.yml?branch=main) +![Nx](https://img.shields.io/badge/Nx-managed-blue) +![Go Module](https://img.shields.io/badge/Go-Module-brightgreen) + +**Meowbot Util** is a utility library for +the [๐Ÿพ Meow Bot](https://github.com/dotablaze-tech/platform/tree/main/apps/go/meowbot), providing reusable helpers, +configuration loading, and emoji constants. It promotes clean separation of concerns and shared logic across Meowbot +features. + +This library is managed with [Nx](https://nx.dev) and structured as a standalone Go module for use in feature packages +like `handler`, `state`, and `api`. + +--- + +## ๐Ÿ“ Project Structure + +``` +libs/go/meowbot/util/ +โ”œโ”€โ”€ config.go # Bot config loading from env +โ”œโ”€โ”€ config_test.go # Tests for config loading +โ”œโ”€โ”€ emojis.go # Emoji constants used by Meowbot +โ”œโ”€โ”€ emojis_test.go # Emoji tests and verification +โ”œโ”€โ”€ go.mod / go.sum # Independent module for utility library +โ””โ”€โ”€ project.json # Nx project definition +``` + +--- + +## ๐Ÿš€ Getting Started + +### Prerequisites + +- Go 1.24+ +- [Nx CLI](https://nx.dev) + +### Installation + +This module is used internally in the monorepo. To use it in another Go module: + +```go +import "github.com/dotablaze-tech/platform/libs/go/meowbot/util" +``` + +Make sure your `go.work` and `go.mod` are properly wired if using outside of Nx context. + +--- + +## โœจ Features + +- **Emoji Constants**: Standardized emojis for Meowbot interactions (`๐Ÿ˜บ`, `๐Ÿ˜ผ`, `๐Ÿ˜พ`, etc.). +- **Config Loader**: Reads bot configuration (e.g., `DISCORD_BOT_TOKEN`) via `config.go`. +- **Test Coverage**: Unit tests included to validate config parsing and emoji availability. + +--- + +## ๐Ÿงช Testing + +To run tests for the util package: + +```bash +go test ./libs/go/meowbot/util +``` + +--- + +## ๐Ÿ”ง Usage Example + +```go +import ( + "fmt" + "os" + + "github.com/dotablaze-tech/platform/libs/go/meowbot/util" +) + +func main() { + config, err := util.LoadConfigFromEnv() + if err != nil { + panic(err) + } + fmt.Println("Bot token:", config.Token) + + fmt.Println("Happy Meow Emoji:", util.EmojiHappy) +} +``` + +--- + +## ๐Ÿ“Œ Notes + +- This library is a foundational utility used across all `meowbot` feature packages. +- Consider extending `config.go` if additional environment-based configuration is needed. diff --git a/libs/go/meowbot/util/config.go b/libs/go/meowbot/util/config.go new file mode 100644 index 0000000..f47af5c --- /dev/null +++ b/libs/go/meowbot/util/config.go @@ -0,0 +1,79 @@ +package util + +import ( + "github.com/jba/slog/handlers/loghandler" + "log/slog" + "os" + "strings" +) + +var Cfg = LoadConfig() + +type AppConfig struct { + Mode string + Debug bool + IsProd bool + BotToken string + ApiPort string + DatabaseURL string + EmojiList string + Logger *slog.Logger + Whitelist struct { + Guilds []string + } +} + +func LoadConfig() AppConfig { + mode := os.Getenv("MODE") + if mode == "" { + mode = "production" + } + + debug := os.Getenv("DEBUG") == "true" + + level := slog.LevelInfo + if debug { + level = slog.LevelDebug + } + logger := slog.New(loghandler.New(os.Stdout, &slog.HandlerOptions{ + Level: level, + })) + slog.SetDefault(logger) + + apiPort := os.Getenv("API_PORT") + if apiPort == "" { + apiPort = "8080" + } + + guildsCSV := os.Getenv("WHITELISTED_GUILDS") + var guilds []string + if guildsCSV != "" { + guilds = strings.Split(guildsCSV, ",") + } + + return AppConfig{ + Mode: mode, + Debug: debug, + IsProd: mode == "production", + ApiPort: apiPort, + BotToken: os.Getenv("DISCORD_BOT_TOKEN"), + DatabaseURL: os.Getenv("DATABASE_URL"), + EmojiList: os.Getenv("EMOJI_LIST"), + Logger: logger, + Whitelist: struct { + Guilds []string + }{Guilds: guilds}, + } +} + +func (cfg AppConfig) IsAllowedGuild(guildID string) bool { + if cfg.IsProd { + return true + } + for _, id := range cfg.Whitelist.Guilds { + if id == guildID { + return true + } + } + return false +} diff --git a/libs/go/meowbot/util/config_test.go b/libs/go/meowbot/util/config_test.go new file mode 100644 index 0000000..59a38dc --- /dev/null +++ b/libs/go/meowbot/util/config_test.go @@ -0,0 +1,76 @@ +package util + +import ( + "testing" +) + +func TestLoadConfig_Defaults(t *testing.T) { + t.Setenv("MODE", "") + t.Setenv("DEBUG", "") + t.Setenv("API_PORT", "") + t.Setenv("WHITELISTED_GUILDS", "") + t.Setenv("DISCORD_BOT_TOKEN", "test_token") + t.Setenv("DATABASE_URL", "postgres://test") + t.Setenv("EMOJI_LIST", "๐Ÿ˜บ,๐Ÿ˜ธ") + + cfg := LoadConfig() + + if cfg.Mode != "production" { + t.Errorf("Expected mode=production, got %s", cfg.Mode) + } + if cfg.ApiPort != "8080" { + t.Errorf("Expected default API_PORT=8080, got %s", cfg.ApiPort) + } + if !cfg.IsProd { + t.Error("Expected IsProd to be true in production mode") + } + if cfg.EmojiList != "๐Ÿ˜บ,๐Ÿ˜ธ" { + t.Error("Emoji list not loaded correctly") + } +} + +func TestLoadConfig_WhitelistParsing(t *testing.T) { + t.Setenv("WHITELISTED_GUILDS", "123,456,789") + cfg := LoadConfig() + + expected := []string{"123", "456", "789"} + if len(cfg.Whitelist.Guilds) != 3 { + t.Errorf("Expected 3 whitelisted guilds, got %d", len(cfg.Whitelist.Guilds)) + } + for i, id := range expected { + if cfg.Whitelist.Guilds[i] != id { + t.Errorf("Expected guild id %s, got %s", id, cfg.Whitelist.Guilds[i]) + } + } +} + +func TestIsAllowedGuild(t *testing.T) { + cfg := AppConfig{ + IsProd: false, + Whitelist: struct { + Guilds []string + }{ + Guilds: []string{"111", "222"}, + }, + } + + tests := []struct { + guildID string + allowed bool + }{ + {"111", true}, + {"333", false}, + } + + for _, test := range tests { + if cfg.IsAllowedGuild(test.guildID) != test.allowed { + t.Errorf("Expected IsAllowedGuild(%s) = %v", test.guildID, test.allowed) + } + } + + // Production override + cfg.IsProd = true + if !cfg.IsAllowedGuild("anything") { + t.Error("Expected IsAllowedGuild to return true in prod mode") + } +} diff --git a/libs/go/meowbot/util/emojis.go b/libs/go/meowbot/util/emojis.go index 241332f..265fb4d 100644 --- a/libs/go/meowbot/util/emojis.go +++ b/libs/go/meowbot/util/emojis.go @@ -1,21 +1,19 @@ package util import ( - "log/slog" "math/rand" - "os" "strings" ) var emojis []string -func InitEmojis(logger *slog.Logger) { - if emojiEnv := os.Getenv("EMOJI_LIST"); emojiEnv != "" { +func InitEmojis() { + if emojiEnv := Cfg.EmojiList; emojiEnv != "" { emojis = strings.Split(emojiEnv, ",") - logger.Info("Using custom emojis", "emojis", emojis) + Cfg.Logger.Info("โœจ Using custom emojis", "emojis", emojis) } else { - emojis = []string{"๐Ÿ˜บ", "๐Ÿˆ", "๐Ÿพ", "๐Ÿ˜น", "๐Ÿ˜ผ", "๐Ÿ˜ป"} - logger.Info("Using default emojis", "emojis", emojis) + emojis = []string{"๐Ÿ˜บ", "๐Ÿˆ", "๐Ÿพ", "๐Ÿ˜น", "๐Ÿ˜ผ", "๐Ÿ˜ป", "๐Ÿ˜ฝ", "๐Ÿ…", "๐Ÿฆ", "๐Ÿˆโ€โฌ›"} + Cfg.Logger.Info("๐Ÿพ Using default emojis", "emojis", emojis) } } diff --git a/libs/go/meowbot/util/emojis_test.go b/libs/go/meowbot/util/emojis_test.go new file mode 100644 index 0000000..05fdea9 --- /dev/null +++ b/libs/go/meowbot/util/emojis_test.go @@ -0,0 +1,65 @@ +// util/emojis_test.go +package util + +import ( + "testing" +) + +// helper to save and restore global state +func withEmojiConfig(emojiList string, fn func()) { + origList := Cfg.EmojiList + defer func() { Cfg.EmojiList = origList }() + Cfg.EmojiList = emojiList + fn() +} + +func TestInitEmojis_WithCustomEmojis(t *testing.T) { + withEmojiConfig("๐Ÿ™‚,๐Ÿ˜€,๐Ÿ˜,๐Ÿ˜‚", func() { + InitEmojis() + // Now emojis should be exactly the four we set + want := []string{"๐Ÿ™‚", "๐Ÿ˜€", "๐Ÿ˜", "๐Ÿ˜‚"} + if len(emojis) != len(want) { + t.Fatalf("expected %d emojis, got %d", len(want), len(emojis)) + } + for i, e := range want { + if emojis[i] != e { + t.Errorf("at index %d, want %q, got %q", i, e, emojis[i]) + } + } + }) +} + +func TestInitEmojis_WithDefaultEmojis(t *testing.T) { + withEmojiConfig("", func() { + InitEmojis() + // Default list is exactly 10 long + want := []string{"๐Ÿ˜บ", "๐Ÿˆ", "๐Ÿพ", "๐Ÿ˜น", "๐Ÿ˜ผ", "๐Ÿ˜ป", "๐Ÿ˜ฝ", "๐Ÿ…", "๐Ÿฆ", "๐Ÿˆโ€โฌ›"} + if len(emojis) != len(want) { + t.Fatalf("expected %d default emojis, got %d", len(want), len(emojis)) + } + for i, e := range want { + if emojis[i] != e { + t.Errorf("default[%d] = %q; want %q", i, emojis[i], e) + } + } + }) +} + +func TestRandomEmoji_WithinList(t *testing.T) { + // Force a small, known emoji list + emojis = []string{"A", "B", "C", "D"} + // Run RandomEmoji many times; it should never panic and always return one of A,B,C,D + for i := 0; i < 100; i++ { + got := RandomEmoji() + found := false + for _, e := range emojis { + if e == got { + found = true + break + } + } + if !found { + t.Errorf("RandomEmoji returned %q; want one of %v", got, emojis) + } + } +} diff --git a/libs/go/meowbot/util/go.mod b/libs/go/meowbot/util/go.mod index ca2a652..63c44ed 100644 --- a/libs/go/meowbot/util/go.mod +++ b/libs/go/meowbot/util/go.mod @@ -1,3 +1,5 @@ module libs/go/meowbot/util -go 1.23 +go 1.24 + +require github.com/jba/slog v0.2.0 diff --git a/libs/go/meowbot/util/go.sum b/libs/go/meowbot/util/go.sum new file mode 100644 index 0000000..6d97a29 --- /dev/null +++ b/libs/go/meowbot/util/go.sum @@ -0,0 +1,2 @@ +github.com/jba/slog v0.2.0 h1:jI0U5NRR3EJKGsbeEVpItJNogk0c4RMeCl7vJmogCJI= +github.com/jba/slog v0.2.0/go.mod h1:0Dh7Vyz3Td68Z1OwzadfincHwr7v+PpzadrS2Jua338=