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.
This commit is contained in:
Meta-Repo Bot 2026-01-16 21:57:13 +00:00
parent 8366b92dd8
commit f602c02f80
12 changed files with 367 additions and 9 deletions

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# Binaries
musiclink
# Configs (sensitive/local)
config.toml
matterbridge.toml
# Logs
*.log
# IDEs
.vscode/
.idea/

21
LICENSE Normal file
View file

@ -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.

76
README.md Normal file
View file

@ -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)

View file

@ -23,7 +23,7 @@ func main() {
} }
// Create message handler // Create message handler
handler := bot.NewHandler() handler := bot.NewHandler("")
// Create bot // Create bot
b := bot.New(cfg.Matterbridge, handler.Handle) b := bot.New(cfg.Matterbridge, handler.Handle)

View file

@ -56,7 +56,7 @@ func testDetector() {
} }
func testAPI() { func testAPI() {
client := services.NewClient() client := services.NewClient("")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@ -83,7 +83,7 @@ func testAPI() {
} }
func testResolver() { func testResolver() {
res := resolver.New() res := resolver.New("")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()

29
docs/ISSUE-git-access.md Normal file
View file

@ -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.

61
flake.lock Normal file
View file

@ -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
}

43
flake.nix Normal file
View file

@ -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
];
};
}
);
}

View file

@ -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")
}
}

View file

@ -15,10 +15,10 @@ type Handler struct {
} }
// NewHandler creates a new message handler. // NewHandler creates a new message handler.
func NewHandler() *Handler { func NewHandler(apiURL string) *Handler {
return &Handler{ return &Handler{
detector: detector.New(), detector: detector.New(),
resolver: resolver.New(), resolver: resolver.New(apiURL),
} }
} }

View file

@ -14,9 +14,9 @@ type Resolver struct {
} }
// New creates a new Resolver. // New creates a new Resolver.
func New() *Resolver { func New(apiURL string) *Resolver {
return &Resolver{ return &Resolver{
client: services.NewClient(), client: services.NewClient(apiURL),
} }
} }

View file

@ -18,10 +18,13 @@ type Client struct {
} }
// NewClient creates a new idonthavespotify API client. // NewClient creates a new idonthavespotify API client.
func NewClient() *Client { func NewClient(apiURL string) *Client {
if apiURL == "" {
apiURL = defaultAPIURL
}
return &Client{ return &Client{
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
apiURL: defaultAPIURL, apiURL: apiURL,
} }
} }