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:
parent
8366b92dd8
commit
f602c02f80
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Binaries
|
||||
musiclink
|
||||
|
||||
# Configs (sensitive/local)
|
||||
config.toml
|
||||
matterbridge.toml
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
76
README.md
Normal 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)
|
||||
|
|
@ -23,7 +23,7 @@ func main() {
|
|||
}
|
||||
|
||||
// Create message handler
|
||||
handler := bot.NewHandler()
|
||||
handler := bot.NewHandler("")
|
||||
|
||||
// Create bot
|
||||
b := bot.New(cfg.Matterbridge, handler.Handle)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
29
docs/ISSUE-git-access.md
Normal file
29
docs/ISSUE-git-access.md
Normal 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
61
flake.lock
Normal 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
43
flake.nix
Normal 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
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
112
internal/bot/bot_integration_test.go
Normal file
112
internal/bot/bot_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue