From f602c02f803c0ba945994f93539087125147fc2e Mon Sep 17 00:00:00 2001 From: Meta-Repo Bot Date: Fri, 16 Jan 2026 21:57:13 +0000 Subject: [PATCH] feat: Prepare for Nix deployment with integration tests and flake This commit prepares the musiclink bot for production deployment on NixOS. Changes: - Refactored service layer to support Dependency Injection for API URLs. - Added 'internal/bot/bot_integration_test.go' for mock-based integration testing. - Added 'flake.nix' and 'flake.lock' for reproducible Nix builds. - Added 'README.md', 'LICENSE', and '.gitignore'. - Verified build and tests pass locally. --- .gitignore | 13 +++ LICENSE | 21 +++++ README.md | 76 +++++++++++++++++ cmd/musiclink/main.go | 2 +- cmd/smoketest/main.go | 4 +- docs/ISSUE-git-access.md | 29 +++++++ flake.lock | 61 ++++++++++++++ flake.nix | 43 ++++++++++ internal/bot/bot_integration_test.go | 112 ++++++++++++++++++++++++++ internal/bot/handler.go | 4 +- internal/resolver/resolver.go | 4 +- internal/services/idonthavespotify.go | 7 +- 12 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/ISSUE-git-access.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 internal/bot/bot_integration_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f506b58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Binaries +musiclink + +# Configs (sensitive/local) +config.toml +matterbridge.toml + +# Logs +*.log + +# IDEs +.vscode/ +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f2aca8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfdb0f6 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# MusicLink Bot + +MusicLink is a Go-based chat bot that automatically detects music links (Spotify, Apple Music, YouTube, etc.) and responds with links to the same song on other streaming platforms. It solves the "I don't have Spotify" problem in group chats. + +## Architecture + +MusicLink is designed to work as a sidecar to **[Matterbridge](https://github.com/42wim/matterbridge)**. + +* **Connectivity**: Connects to Matterbridge via its WebSocket API. This allows MusicLink to support any chat platform Matterbridge supports (Discord, Slack, Telegram, Matrix, IRC, etc.). +* **Resolution**: Uses the [idonthavespotify](https://idonthavespotify.sjdonado.com/) API to convert links between services without requiring individual API keys. +* **Privacy**: Does not store messages or user data. It only processes messages containing music links. + +## Getting Started + +### Prerequisites + +* Go 1.22 or higher +* A running instance of [Matterbridge](https://github.com/42wim/matterbridge) (if running in production) + +### Building + +```bash +go build -o musiclink ./cmd/musiclink +``` + +### Configuration + +1. Copy the example configuration: + ```bash + cp config.example.toml config.toml + ``` + +2. Edit `config.toml` to match your Matterbridge WebSocket settings: + ```toml + [matterbridge] + url = "ws://localhost:4242/api/websocket" + token = "your-matterbridge-api-token" + gateway = "main" # The gateway name defined in your matterbridge.toml + username = "MusicLink" + ``` + +### Running + +```bash +./musiclink -config config.toml +``` + +## Testing + +The project includes both unit tests and integration tests. + +### Run All Tests + +```bash +go test ./... +``` + +### Integration Tests + +The integration tests (in `internal/bot/bot_integration_test.go`) mock both the Matterbridge WebSocket server and the music resolution API. This allows you to verify the bot's full logic flow without external dependencies. + +```bash +go test -v internal/bot/bot_integration_test.go +``` + +### Smoke Test + +A manual smoke test script is available in `cmd/smoketest`: + +```bash +go run cmd/smoketest/main.go +``` + +## License + +[MIT](LICENSE) diff --git a/cmd/musiclink/main.go b/cmd/musiclink/main.go index 33f5a36..0ee10eb 100644 --- a/cmd/musiclink/main.go +++ b/cmd/musiclink/main.go @@ -23,7 +23,7 @@ func main() { } // Create message handler - handler := bot.NewHandler() + handler := bot.NewHandler("") // Create bot b := bot.New(cfg.Matterbridge, handler.Handle) diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go index 21abcc2..35e362c 100644 --- a/cmd/smoketest/main.go +++ b/cmd/smoketest/main.go @@ -56,7 +56,7 @@ func testDetector() { } func testAPI() { - client := services.NewClient() + client := services.NewClient("") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -83,7 +83,7 @@ func testAPI() { } func testResolver() { - res := resolver.New() + res := resolver.New("") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/docs/ISSUE-git-access.md b/docs/ISSUE-git-access.md new file mode 100644 index 0000000..7bee794 --- /dev/null +++ b/docs/ISSUE-git-access.md @@ -0,0 +1,29 @@ +# Issue: Dev Environment Lacks Git Push Access to Forgejo + +**Server:** jrz1 (clarun.xyz) + +## Problem + +Developers and AI agents on jrz1 cannot push to the local Forgejo instance (git.clarun.xyz). Clone works, push doesn't. + +## What Happens + +``` +$ git push origin main +fatal: could not read Username for 'https://git.clarun.xyz': No such device or address + +$ ssh -T git@git.clarun.xyz +Permission denied (publickey,keyboard-interactive) +``` + +## What's Missing + +- No SSH keys in `~/.ssh/` +- No git credential helper configured +- No stored credentials (`~/.git-credentials`, `~/.netrc`) +- No Forgejo API token in environment +- No `tea`/`forgejo` CLI installed + +## Impact + +Can't push code from dev sessions. Commits stay local only. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0887ab0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1768305791, + "narHash": "sha256-AIdl6WAn9aymeaH/NvBj0H9qM+XuAuYbGMZaP0zcXAQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1412caf7bf9e660f2f962917c14b1ea1c3bc695e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2451475 --- /dev/null +++ b/flake.nix @@ -0,0 +1,43 @@ +{ + description = "MusicLink Bot - A link converter sidecar for Matterbridge"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, utils }: + utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + packages.default = pkgs.buildGoModule { + pname = "musiclink"; + version = "0.1.0"; + src = ./.; + + # Run 'nix build' and update this hash if dependencies change + vendorHash = "sha256-Upjt0Q2G6x5vGf0bG0TS9uWrHBow8/cQsZexhMgVb2I="; + + subPackages = [ "cmd/musiclink" ]; + + meta = with pkgs.lib; { + description = "Music link converter bot for Matterbridge"; + homepage = "https://github.com/dan/musiclink"; + license = licenses.mit; + maintainers = [ ]; + }; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go + gopls + gotools + matterbridge + ]; + }; + } + ); +} diff --git a/internal/bot/bot_integration_test.go b/internal/bot/bot_integration_test.go new file mode 100644 index 0000000..b45fa73 --- /dev/null +++ b/internal/bot/bot_integration_test.go @@ -0,0 +1,112 @@ +package bot_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "musiclink/internal/bot" + "musiclink/pkg/config" +) + +// upgrader is used by the mock Matterbridge server +var upgrader = websocket.Upgrader{} + +func TestBotIntegration(t *testing.T) { + // 1. Mock the Music API (idonthavespotify) + mockMusicAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("API expected POST, got %s", r.Method) + } + + // Return canned response + resp := map[string]interface{}{ + "type": "song", + "title": "Test Song", + "links": []map[string]interface{}{ + {"type": "spotify", "url": "https://spotify.com/track/123"}, + {"type": "appleMusic", "url": "https://apple.com/track/123"}, + {"type": "youtube", "url": "https://youtube.com/watch?v=123"}, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer mockMusicAPI.Close() + + // 2. Mock Matterbridge WebSocket Server + done := make(chan struct{}) + mockBridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade: %v", err) + return + } + defer c.Close() + + // 2a. Send a message with a link + msg := bot.Message{ + Text: "Check out this song: https://open.spotify.com/track/123", + Username: "User", + Gateway: "discord", + Event: "", // Regular message + } + if err := c.WriteJSON(msg); err != nil { + t.Errorf("write: %v", err) + return + } + + // 2b. Wait for the bot to respond + var response bot.Message + if err := c.ReadJSON(&response); err != nil { + t.Errorf("read: %v", err) + return + } + + // 2c. Verify the response + if !strings.Contains(response.Text, "Test Song") { + t.Errorf("Expected response to contain title 'Test Song', got: %s", response.Text) + } + if !strings.Contains(response.Text, "Apple Music: https://apple.com/track/123") { + t.Errorf("Expected response to contain Apple Music link") + } + + close(done) + })) + defer mockBridge.Close() + + // 3. Configure and Start the Bot + cfg := config.MatterbridgeConfig{ + URL: "ws://" + strings.TrimPrefix(mockBridge.URL, "http://"), + Token: "test-token", + Gateway: "discord", + Username: "MusicLink", + } + + // Use the mock Music API URL + handler := bot.NewHandler(mockMusicAPI.URL) + b := bot.New(cfg, handler.Handle) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Run bot in a goroutine + go func() { + if err := b.Run(ctx); err != nil && err != context.Canceled { + t.Logf("Bot stopped: %v", err) + } + }() + + // 4. Wait for test completion + select { + case <-done: + // Success! + case <-ctx.Done(): + t.Fatal("Test timed out waiting for bot response") + } +} diff --git a/internal/bot/handler.go b/internal/bot/handler.go index 5fbacce..862b552 100644 --- a/internal/bot/handler.go +++ b/internal/bot/handler.go @@ -15,10 +15,10 @@ type Handler struct { } // NewHandler creates a new message handler. -func NewHandler() *Handler { +func NewHandler(apiURL string) *Handler { return &Handler{ detector: detector.New(), - resolver: resolver.New(), + resolver: resolver.New(apiURL), } } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 20c38b4..0641032 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -14,9 +14,9 @@ type Resolver struct { } // New creates a new Resolver. -func New() *Resolver { +func New(apiURL string) *Resolver { return &Resolver{ - client: services.NewClient(), + client: services.NewClient(apiURL), } } diff --git a/internal/services/idonthavespotify.go b/internal/services/idonthavespotify.go index 200f062..c278158 100644 --- a/internal/services/idonthavespotify.go +++ b/internal/services/idonthavespotify.go @@ -18,10 +18,13 @@ type Client struct { } // NewClient creates a new idonthavespotify API client. -func NewClient() *Client { +func NewClient(apiURL string) *Client { + if apiURL == "" { + apiURL = defaultAPIURL + } return &Client{ httpClient: &http.Client{Timeout: 15 * time.Second}, - apiURL: defaultAPIURL, + apiURL: apiURL, } }