From 95a70d8b86ea479ab0b2b2bd97717b53e950ab5e Mon Sep 17 00:00:00 2001 From: dan Date: Wed, 21 Jan 2026 22:26:58 -0800 Subject: [PATCH] Deprecate matterbridge and fix matrix issues --- README.md | 35 +- cmd/musiclink/main.go | 18 +- cmd/smoketest/main.go | 2 +- config.example.toml | 30 +- .../2026-01-21-matrix-native-routing.md | 39 ++ docs/code-review-plan.md | 29 ++ docs/design-matrix-native-routing.md | 81 ++++ .../2026-01-21-matrix-native-routing.md | 22 ++ docs/platform-setup.md | 337 ---------------- docs/reviews/bot-transport.md | 37 ++ docs/reviews/cli-entrypoints.md | 24 ++ docs/reviews/config.md | 31 ++ docs/reviews/docs.md | 28 ++ docs/reviews/infra.md | 32 ++ docs/reviews/issues-status.md | 40 ++ docs/reviews/issues.md | 22 ++ docs/reviews/message-handling.md | 37 ++ docs/work/2026-01-21-code-review-issues.md | 12 + docs/work/2026-01-21-code-review.md | 18 + docs/work/2026-01-21-issues-fixes.md | 14 + docs/work/2026-01-21-matrix-native-routing.md | 21 + flake.nix | 7 +- go.mod | 29 +- go.sum | 98 ++++- internal/bot/bot.go | 289 -------------- internal/bot/bot_integration_test.go | 112 ------ internal/detector/detector.go | 3 +- internal/{bot => handler}/handler.go | 27 +- internal/matrixbot/bot.go | 361 ++++++++++++++++++ internal/matrixbot/health.go | 83 ++++ internal/matrixbot/state_store.go | 184 +++++++++ internal/matrixbot/stats.go | 99 +++++ internal/services/idonthavespotify.go | 19 + pkg/config/config.go | 36 +- 34 files changed, 1448 insertions(+), 808 deletions(-) create mode 100644 docs/approach/2026-01-21-matrix-native-routing.md create mode 100644 docs/code-review-plan.md create mode 100644 docs/design-matrix-native-routing.md create mode 100644 docs/intent/2026-01-21-matrix-native-routing.md delete mode 100644 docs/platform-setup.md create mode 100644 docs/reviews/bot-transport.md create mode 100644 docs/reviews/cli-entrypoints.md create mode 100644 docs/reviews/config.md create mode 100644 docs/reviews/docs.md create mode 100644 docs/reviews/infra.md create mode 100644 docs/reviews/issues-status.md create mode 100644 docs/reviews/issues.md create mode 100644 docs/reviews/message-handling.md create mode 100644 docs/work/2026-01-21-code-review-issues.md create mode 100644 docs/work/2026-01-21-code-review.md create mode 100644 docs/work/2026-01-21-issues-fixes.md create mode 100644 docs/work/2026-01-21-matrix-native-routing.md delete mode 100644 internal/bot/bot.go delete mode 100644 internal/bot/bot_integration_test.go rename internal/{bot => handler}/handler.go (70%) create mode 100644 internal/matrixbot/bot.go create mode 100644 internal/matrixbot/health.go create mode 100644 internal/matrixbot/state_store.go create mode 100644 internal/matrixbot/stats.go diff --git a/README.md b/README.md index cfdb0f6..c1c970a 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ MusicLink is a Go-based chat bot that automatically detects music links (Spotify ## Architecture -MusicLink is designed to work as a sidecar to **[Matterbridge](https://github.com/42wim/matterbridge)**. +MusicLink is a Matrix-native bot that connects directly to a Matrix homeserver and replies in the originating room/thread. **E2EE rooms are not supported.** -* **Connectivity**: Connects to Matterbridge via its WebSocket API. This allows MusicLink to support any chat platform Matterbridge supports (Discord, Slack, Telegram, Matrix, IRC, etc.). +* **Connectivity**: Connects directly to Matrix and supports multiple allowlisted rooms. * **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. @@ -15,7 +15,7 @@ MusicLink is designed to work as a sidecar to **[Matterbridge](https://github.co ### Prerequisites * Go 1.22 or higher -* A running instance of [Matterbridge](https://github.com/42wim/matterbridge) (if running in production) +* A Matrix homeserver and bot access token ### Building @@ -30,13 +30,16 @@ go build -o musiclink ./cmd/musiclink cp config.example.toml config.toml ``` -2. Edit `config.toml` to match your Matterbridge WebSocket settings: +2. Configure Matrix-native mode (replies in the originating room/thread; E2EE rooms not supported): ```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" + [matrix] + shadow = false # set true to log-only during validation + healthAddr = ":8080" # optional health/metrics endpoint + server = "https://matrix.example.com" + accessToken = "your-matrix-access-token" + userId = "@musiclink:example.com" + rooms = ["!roomid:example.com"] + stateStorePath = "data/matrix-state.db" ``` ### Running @@ -45,6 +48,14 @@ go build -o musiclink ./cmd/musiclink ./musiclink -config config.toml ``` +## Health and Metrics (Matrix-native) + +If `matrix.healthAddr` is set, MusicLink exposes JSON health stats at `/healthz` (and `/metrics`). Example: + +```bash +curl http://localhost:8080/healthz +``` + ## Testing The project includes both unit tests and integration tests. @@ -57,11 +68,7 @@ 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 -``` +No Matrix integration tests are included yet; add them as needed. ### Smoke Test diff --git a/cmd/musiclink/main.go b/cmd/musiclink/main.go index 0ee10eb..a16ef5f 100644 --- a/cmd/musiclink/main.go +++ b/cmd/musiclink/main.go @@ -8,7 +8,8 @@ import ( "os/signal" "syscall" - "musiclink/internal/bot" + "musiclink/internal/handler" + "musiclink/internal/matrixbot" "musiclink/pkg/config" ) @@ -23,10 +24,13 @@ func main() { } // Create message handler - handler := bot.NewHandler("") + msgHandler := handler.New("") // Create bot - b := bot.New(cfg.Matterbridge, handler.Handle) + mxBot, err := matrixbot.New(cfg.Matrix, msgHandler.HandleText) + if err != nil { + log.Fatalf("Failed to create Matrix bot: %v", err) + } // Setup context with cancellation ctx, cancel := context.WithCancel(context.Background()) @@ -43,10 +47,12 @@ func main() { // Run the bot (connects and reconnects automatically) log.Println("MusicLink bot starting...") - if err := b.Run(ctx); err != nil && err != context.Canceled { - log.Fatalf("Bot error: %v", err) + if err := mxBot.Run(ctx); err != nil && err != context.Canceled { + log.Fatalf("Matrix bot error: %v", err) + } + if err := mxBot.Close(); err != nil { + log.Printf("Matrix bot close error: %v", err) } - b.Close() log.Println("Goodbye!") } diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go index 35e362c..5549834 100644 --- a/cmd/smoketest/main.go +++ b/cmd/smoketest/main.go @@ -12,7 +12,7 @@ import ( ) func main() { - fmt.Println("=== MusicLink Smoke Test ===\n") + fmt.Println("=== MusicLink Smoke Test ===") // Test 1: Detector fmt.Println("1. Testing link detection...") diff --git a/config.example.toml b/config.example.toml index 0113f91..49a16c8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -3,18 +3,26 @@ # This bot uses the idonthavespotify API to convert music links # between streaming services. No API credentials needed! -[matterbridge] -# WebSocket URL for matterbridge API bridge -url = "ws://localhost:4242/api/websocket" +[matrix] +# Shadow mode (log responses without sending) +shadow = false -# API token (must match matterbridge config) -token = "your-matterbridge-api-token" +# Optional health server address (ex: ":8080") +healthAddr = "" -# Gateway name to send messages to -gateway = "main" +# Matrix homeserver base URL +server = "https://matrix.example.com" -# Bot username shown in messages -username = "MusicLink" +# Access token for the bot user +accessToken = "your-matrix-access-token" -# Avatar URL for the bot (optional) -avatar = "" +# Full Matrix user ID for the bot +userId = "@musiclink:example.com" + +# Allowlisted room IDs to monitor +rooms = [ + "!roomid:example.com" +] + +# Path to state store for sync token + dedupe +stateStorePath = "data/matrix-state.db" diff --git a/docs/approach/2026-01-21-matrix-native-routing.md b/docs/approach/2026-01-21-matrix-native-routing.md new file mode 100644 index 0000000..315f86b --- /dev/null +++ b/docs/approach/2026-01-21-matrix-native-routing.md @@ -0,0 +1,39 @@ +# Approach: Matrix-Native MusicLink Routing + +## The Vector (Strategy) +* **Core Philosophy**: Minimal-invasive integration with safety rails (explicit allowlist, shadow mode, persistence) while preserving current MusicLink behavior. +* **Key Technical Decisions**: + * Decision 1: Matterbridge vs Matrix SDK -> **Matrix SDK** because correct room/thread routing requires direct room-aware event handling. + * Decision 2: SDK choice -> **mautrix-go** because the codebase is Go and it offers mature Matrix support (including threads and state handling). + * Decision 3: E2EE support -> **Not supported in v1**; bot will refuse/skip encrypted rooms and log a clear warning. + * Decision 4: Threading semantics -> **Reply in-thread when the event references a thread**, and always anchor replies with `m.in_reply_to` for compatibility. + * Decision 5: Sync token persistence -> **Required**, stored locally in a lightweight state store (e.g., SQLite in the data directory). + * Decision 6: Parallel validation -> **Shadow mode** (read + compute + log only) to avoid double-posting. + * Decision 7: Allowlist/join policy -> **Join only allowlisted rooms**; ignore or leave non-allowlisted invites. + * Decision 8: Dedup/idempotency -> **Persist processed event IDs** in the state store with a bounded TTL to prevent double replies after restarts. + * Decision 9: Rate limiting -> **Outbound queue with retry/backoff** honoring `retry_after_ms` to avoid 429 storms. + +## The Architecture +* **New Components**: + * Matrix client module (sync, event filtering, reply posting). + * State store for sync tokens and event dedupe (SQLite). + * Outbound send queue with backoff. +* **Modified Components**: + * Config schema (matrix enabled/server/access token/user id/rooms/state store path). + * Message handling entrypoint to accept Matrix events. + * Logging/metrics for sync health and send failures. +* **Data Model Changes**: + * Expanded `matrix` settings with `rooms` and `stateStorePath`. + +## The Risks (Blast Radius) +* **Known Unknowns**: Matrix SDK threading behavior across clients; limits of non-E2EE support in target rooms. +* **Failure Modes**: + * Reply posted to wrong room/thread due to malformed relations. + * Event loops from self-messages or duplicate sync deliveries. + * Missed messages if sync token store is corrupted or reset. + * Silent failures if encrypted rooms are allowlisted. + * Token leakage or expiration without clear operational guidance. + +## The Plan Outline (High Level) +1. **Phase 1**: Implement Matrix-native mode in shadow-only operation with persistence, filtering, and observability. +2. **Phase 2**: Enable active posting, canary to a subset of rooms, then retire Matterbridge routing. diff --git a/docs/code-review-plan.md b/docs/code-review-plan.md new file mode 100644 index 0000000..f05d5eb --- /dev/null +++ b/docs/code-review-plan.md @@ -0,0 +1,29 @@ +# Code Review Plan (6-Part Pass) + +## Scope +Review the repository in six focused passes to cover entrypoints, transport layers, core logic, config, docs, and infrastructure. + +## Plan +1. **CLI entrypoints** + - Paths: `cmd/musiclink/`, `cmd/smoketest/` + - Focus: startup flow, flags, shutdown handling, error propagation. + +2. **Bot transport layers** + - Paths: `internal/bot/`, `internal/matrixbot/` + - Focus: protocol handling, reconnection, rate limits, threading/room routing, dedupe, and resource cleanup. + +3. **Message handling & link detection** + - Paths: `internal/detector/`, `internal/resolver/`, `internal/services/` + - Focus: parsing correctness, error handling, API usage, formatting logic. + +4. **Config & runtime wiring** + - Paths: `pkg/config/`, `config.example.toml`, `config.toml` + - Focus: validation, defaults, secrets handling, backwards compatibility. + +5. **Docs & design artifacts** + - Paths: `docs/`, `README.md`, `WORKLOG.md` + - Focus: accuracy vs implementation, user-facing setup guidance. + +6. **Project/infra metadata** + - Paths: `go.mod`, `go.sum`, `flake.nix`, `flake.lock`, `LICENSE` + - Focus: dependency hygiene, tooling assumptions, licensing. diff --git a/docs/design-matrix-native-routing.md b/docs/design-matrix-native-routing.md new file mode 100644 index 0000000..c6c6d2f --- /dev/null +++ b/docs/design-matrix-native-routing.md @@ -0,0 +1,81 @@ +# Design: Matrix-Native MusicLink Routing + +## Goal +Make MusicLink a native Matrix bot that listens to multiple rooms and replies in the **same room** that originated the message. This removes Matterbridge from the routing path and eliminates cross-room fan-out. + +## Background +Current deployment uses Matterbridge as an API gateway. When multiple Matrix rooms are configured in a single Matterbridge gateway, messages fan out to other rooms. This causes unintended cross-posting (including DM leakage) when MusicLink is enabled in more than one room. + +## Objectives +- **Correct routing:** Replies must go back to the originating room (and thread when applicable). +- **Multi-room support:** One MusicLink instance can monitor multiple Matrix rooms. +- **No fan-out bus:** Remove Matterbridge dependency for routing. +- **Minimal operational complexity:** Single service, single config, single token. + +## Non-Goals +- Replacing mautrix-slack (Slack ↔ Matrix bridge remains). +- Adding new link providers beyond current MusicLink behavior. + +## High-Level Architecture + +``` +Slack Room + -> mautrix-slack (Matrix portal room) + -> MusicLink (Matrix-native bot) + -> same Matrix portal room (reply) + -> mautrix-slack -> Slack thread +``` + +## Proposed Implementation + +### 1) Matrix Client +Use a Matrix SDK (mautrix-go or matrix-nio) to: +- Login using a bot token (or access token from config). +- Sync events from configured rooms. +- Ignore messages from the bot itself. +- Post replies to the same room. + +### 2) Room Configuration +Extend config with explicit room allowlist: + +```toml +[matrix] +server = "https://clarun.xyz" +accessToken = "..." +userId = "@musiclink:clarun.xyz" +rooms = [ + "!DPQveBnfuDrbgOe6dm:clarun.xyz", + "!dT40EUcemb8e6bPiig:clarun.xyz" +] +``` + +### 3) Threading Support +If the incoming event references a thread (e.g., `m.relates_to` with `rel_type=m.thread`), reply into that thread; otherwise post a standard room message. + +### 4) Message Handling +- Parse message body for supported music links. +- Call `idonthavespotify` (existing behavior). +- Post formatted reply in the same room. + +### 5) Loop Prevention +- Ignore events from `@musiclink`. +- Optionally ignore events without link matches. +- Add a small delay/backoff on rate limit responses. + +## Migration Plan +1. **Add Matrix client support behind a feature flag** (e.g., `matrix.enabled`). +2. **Deploy in parallel with Matterbridge** to validate routing and threading. +3. **Disable Matterbridge** once Matrix-native mode is verified. + +## Risks +- Matrix SDK differences in threading or formatting. +- Token handling and access permissions for the bot user. +- Message deduplication and race conditions in sync processing. + +## Open Questions +- Which Matrix SDK should we standardize on (mautrix-go vs matrix-nio)? +- Do we need explicit thread support in Slack via mautrix-slack mapping? +- Should we persist a small state store for sync tokens? + +## Appendix: Why Not Multiple Gateways? +Multiple gateways in Matterbridge solve cross-posting, but still rely on the fan-out bus model and add operational overhead. A Matrix-native bot is simpler and more correct for routing semantics. diff --git a/docs/intent/2026-01-21-matrix-native-routing.md b/docs/intent/2026-01-21-matrix-native-routing.md new file mode 100644 index 0000000..4521fea --- /dev/null +++ b/docs/intent/2026-01-21-matrix-native-routing.md @@ -0,0 +1,22 @@ +# Intent: Matrix-Native MusicLink Routing + +## The Volition (Why) +* **Surface Request**: Make MusicLink a native Matrix bot that listens to multiple rooms and replies in the same originating room/thread, removing Matterbridge from routing. +* **Deep Volition**: Eliminate cross-room fan-out and accidental cross-posting (including DM leakage) while keeping MusicLink simple to run and correct in multi-room deployments. +* **The "Done" State**: MusicLink safely monitors multiple Matrix rooms, replies only where the message originated (and in-thread when applicable), and no longer relies on Matterbridge for routing. + +## The Context +* **Constraints**: + * Maintain existing MusicLink link parsing and response behavior. + * Keep mautrix-slack in place for Slack ↔ Matrix bridging. + * Prefer minimal operational complexity (single service/config/token). +* **Silent Requirements**: + * Avoid cross-posting across rooms. + * Preserve correct reply threading semantics. + * Ignore bot self-messages to prevent loops. + * Keep configuration explicit about which rooms are monitored. + +## The Anti-Goals +* Replacing mautrix-slack or altering Slack ↔ Matrix bridging. +* Adding new link providers or changing MusicLink’s core feature set. +* Relying on multi-gateway Matterbridge setups for routing correctness. diff --git a/docs/platform-setup.md b/docs/platform-setup.md deleted file mode 100644 index 67aa26a..0000000 --- a/docs/platform-setup.md +++ /dev/null @@ -1,337 +0,0 @@ -# MusicLink Bot - Platform Setup Guide - -## Overview - -MusicLink is a bot that detects music links (Spotify, YouTube, Apple Music, etc.) in chat messages and responds with equivalent links on other streaming services. - -It uses the [idonthavespotify](https://github.com/sjdonado/idonthavespotify) API for link conversion, so **no Spotify/YouTube API credentials are required**. - -## Architecture - -``` -┌─────────────────────────── jrz1 (NixOS VPS) ───────────────────────────┐ -│ │ -│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ -│ │ matterbridge.service │ │ musiclink.service │ │ -│ │ │ │ │ │ -│ │ Bridges chat platforms │ WS │ Detects music links and │ │ -│ │ and exposes local API │◄─────►│ responds with alternatives │ │ -│ │ │ │ │ │ -│ │ - Slack bridge │ │ - Link detection (regex) │ │ -│ │ - API bridge (:4242) │ │ - Calls idonthavespotify │ │ -│ │ - (Discord, Matrix │ │ API for conversion │ │ -│ │ can be added later) │ │ │ │ -│ └────────────┬────────────┘ └──────────────┬──────────────┘ │ -│ │ │ │ -└───────────────┼────────────────────────────────────┼────────────────────┘ - │ │ - ▼ ▼ - ┌───────────┐ ┌────────────────────┐ - │ Slack │ │ idonthavespotify │ - └───────────┘ │ (external API) │ - └────────────────────┘ -``` - -### How It Works - -1. User posts a music link in Slack (e.g., `https://open.spotify.com/track/abc123`) -2. Matterbridge forwards the message to musiclink via WebSocket -3. MusicLink detects the music URL and calls the idonthavespotify API -4. The API returns equivalent links for Spotify, YouTube, Apple Music, Tidal, Deezer, etc. -5. MusicLink formats and sends the response back through matterbridge to Slack - -### Why Matterbridge? - -Matterbridge acts as a universal adapter. Instead of writing platform-specific code for each chat service, we: - -1. Run matterbridge (connects to Slack, Discord, Matrix, etc.) -2. Matterbridge exposes a simple WebSocket API locally -3. Our bot connects to that API and receives/sends messages as JSON - -This means: -- **Adding a new platform** = config change in matterbridge (no code changes) -- **Bot code stays simple** = just WebSocket + JSON, works from any language -- **Already in nixpkgs** = `services.matterbridge` module ready to use - -### Why idonthavespotify API? - -- **No API credentials needed** - No Spotify Developer account, no YouTube API keys -- **Supports 8 platforms** - Spotify, YouTube, Apple Music, Deezer, Tidal, SoundCloud, Qobuz, Bandcamp -- **Simple integration** - Single HTTP POST, returns all platform links -- **Open source** - Can self-host if needed later - -## Components - -### 1. Matterbridge (existing nixpkgs module) - -**Package:** `pkgs.matterbridge` (v1.26.0) -**Module:** `services.matterbridge` -**Docs:** https://github.com/42wim/matterbridge/wiki - -### 2. MusicLink Bot (this repo) - -**Language:** Go -**Location:** `/home/dan/proj/musiclink` -**Connects to:** Matterbridge API via WebSocket -**Uses:** idonthavespotify API for link conversion - -## NixOS Configuration - -### Matterbridge Service - -```nix -{ config, pkgs, ... }: - -{ - services.matterbridge = { - enable = true; - configPath = "/var/lib/matterbridge/matterbridge.toml"; - }; - - # Ensure config directory exists - systemd.tmpfiles.rules = [ - "d /var/lib/matterbridge 0750 matterbridge matterbridge -" - ]; -} -``` - -### MusicLink Service - -```nix -{ config, pkgs, ... }: - -let - musiclink = pkgs.buildGoModule { - pname = "musiclink"; - version = "0.1.0"; - src = /home/dan/proj/musiclink; # or fetchFromGitHub - vendorHash = null; # update after first build - }; -in -{ - systemd.services.musiclink = { - description = "MusicLink Bot"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" "matterbridge.service" ]; - requires = [ "matterbridge.service" ]; - - serviceConfig = { - Type = "simple"; - ExecStart = "${musiclink}/bin/musiclink -config /var/lib/musiclink/config.toml"; - Restart = "always"; - RestartSec = "5s"; - - # Hardening - DynamicUser = true; - StateDirectory = "musiclink"; - ProtectSystem = "strict"; - ProtectHome = true; - NoNewPrivileges = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictNamespaces = true; - LockPersonality = true; - MemoryDenyWriteExecute = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - PrivateMounts = true; - SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; - }; - }; - - systemd.tmpfiles.rules = [ - "d /var/lib/musiclink 0750 musiclink musiclink -" - ]; -} -``` - -## Configuration Files - -### Matterbridge (`/var/lib/matterbridge/matterbridge.toml`) - -```toml -# ============================================================================= -# Matterbridge Configuration -# Bridges Slack to local API for musiclink bot -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Slack Connection -# ----------------------------------------------------------------------------- -[slack.workspace] -# Bot User OAuth Token (starts with xoxb-) -# Get from: https://api.slack.com/apps → Your App → OAuth & Permissions -Token = "xoxb-YOUR-SLACK-BOT-TOKEN" - -# Show username before messages -PrefixMessagesWithNick = true - -# Optional: customize how remote users appear -RemoteNickFormat = "[{PROTOCOL}] {NICK}" - -# ----------------------------------------------------------------------------- -# Local API Bridge (for musiclink bot) -# ----------------------------------------------------------------------------- -[api.musiclink] -# Only bind to localhost (not exposed externally) -BindAddress = "127.0.0.1:4242" - -# Shared secret for bot authentication -Token = "GENERATE-A-RANDOM-TOKEN-HERE" - -# Message buffer size -Buffer = 1000 - -# ----------------------------------------------------------------------------- -# Gateway Configuration -# Routes messages between bridges -# ----------------------------------------------------------------------------- -[[gateway]] -name = "main" -enable = true - -# Slack channels to bridge -[[gateway.inout]] -account = "slack.workspace" -channel = "music" # Change to your channel name - -# API endpoint for musiclink bot -[[gateway.inout]] -account = "api.musiclink" -channel = "api" -``` - -### MusicLink (`/var/lib/musiclink/config.toml`) - -```toml -# ============================================================================= -# MusicLink Bot Configuration -# -# Uses idonthavespotify API - no external API credentials needed! -# ============================================================================= - -[matterbridge] -# Must match matterbridge API bridge config -url = "ws://127.0.0.1:4242/api/websocket" -token = "GENERATE-A-RANDOM-TOKEN-HERE" # Same as matterbridge [api.musiclink].Token -gateway = "main" - -# Bot identity -username = "MusicLink" -avatar = "" # Optional URL -``` - -That's the entire config - no Spotify/YouTube API keys required! - -## Secrets Management - -The only secrets needed are: -- **Slack bot token** (`xoxb-...`) -- **Matterbridge↔MusicLink shared token** (generate a random string) - -Options for managing secrets: - -1. **sops-nix** - Encrypted secrets in repo, decrypted at deploy time -2. **agenix** - Age-encrypted secrets -3. **Environment files** - `EnvironmentFile=` in systemd service - -Example with environment file: - -```nix -systemd.services.matterbridge.serviceConfig = { - EnvironmentFile = "/run/secrets/matterbridge.env"; -}; -``` - -```bash -# /run/secrets/matterbridge.env -MATTERBRIDGE_SLACK_TOKEN=xoxb-... -``` - -Then in matterbridge.toml: -```toml -[slack.workspace] -Token = "${MATTERBRIDGE_SLACK_TOKEN}" -``` - -## Slack App Setup - -1. Go to https://api.slack.com/apps -2. Create New App → From scratch -3. App name: "MusicLink", select workspace -4. **OAuth & Permissions:** - - Bot Token Scopes needed: - - `channels:history` - Read messages - - `channels:read` - See channel list - - `chat:write` - Send messages - - `users:read` - Get user info -5. **Install to Workspace** -6. Copy **Bot User OAuth Token** (`xoxb-...`) -7. Invite bot to channel: `/invite @MusicLink` - -## Deployment Checklist - -- [ ] Slack app created and bot token obtained -- [ ] Generate random token for matterbridge↔musiclink auth -- [ ] Add matterbridge NixOS config -- [ ] Create `/var/lib/matterbridge/matterbridge.toml` -- [ ] Add musiclink NixOS config -- [ ] Create `/var/lib/musiclink/config.toml` -- [ ] Deploy NixOS config (`nixos-rebuild switch`) -- [ ] Verify services: `systemctl status matterbridge musiclink` -- [ ] Test: Post a Spotify link in the configured Slack channel - -## Logs & Debugging - -```bash -# Check service status -systemctl status matterbridge -systemctl status musiclink - -# View logs -journalctl -u matterbridge -f -journalctl -u musiclink -f - -# Test matterbridge API directly -curl -H "Authorization: Bearer YOUR-TOKEN" http://localhost:4242/api/health - -# Test idonthavespotify API directly -curl -X POST 'https://idonthavespotify.sjdonado.com/api/search?v=1' \ - -H 'Content-Type: application/json' \ - -d '{"link":"https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh"}' -``` - -## Adding More Platforms Later - -To add Discord, Matrix, or other platforms, just update `matterbridge.toml`: - -```toml -# Add Discord -[discord.myserver] -Token = "discord-bot-token" -Server = "server-id" - -# Add to gateway -[[gateway.inout]] -account = "discord.myserver" -channel = "music" -``` - -No changes needed to musiclink bot - matterbridge handles the bridging. - -## Self-Hosting idonthavespotify (Optional) - -If you want to avoid the external API dependency, idonthavespotify can be self-hosted: - -- Repo: https://github.com/sjdonado/idonthavespotify -- Requires: Docker or Bun runtime -- Note: Self-hosting still requires Spotify/Tidal API credentials - -For most use cases, the hosted API at `idonthavespotify.sjdonado.com` is sufficient. - -## Questions? - -- Matterbridge docs: https://github.com/42wim/matterbridge/wiki -- idonthavespotify: https://github.com/sjdonado/idonthavespotify -- MusicLink repo: `/home/dan/proj/musiclink` diff --git a/docs/reviews/bot-transport.md b/docs/reviews/bot-transport.md new file mode 100644 index 0000000..0ec66b9 --- /dev/null +++ b/docs/reviews/bot-transport.md @@ -0,0 +1,37 @@ +# Code Review: Bot Transport Layers + +## Scope +- `internal/bot/` +- `internal/matrixbot/` + +## Findings +### ✅ Strengths +- Matterbridge bot has resilient reconnect loop with exponential backoff and ping keepalive. +- Matrix bot implements room allowlist, self-message filtering, shadow mode, and thread-aware replies. +- Matrix bot persists sync tokens + dedupe state; avoids reprocessing on restarts. +- Rate-limit handling respects `M_LIMIT_EXCEEDED` with retry/backoff. +- Health endpoint exposes useful counters and sync timestamps. + +### ⚠️ Issues / Opportunities +1. **Matterbridge message channel is unbounded for handlers** + - `messages` buffered at 100; when full, messages drop. No metrics for drops or backpressure handling. + - Consider exposing drop count or increasing buffer if needed. + +2. **Matrix queue drops without marking dedupe** + - When send queue is full, response is dropped but the event remains unmarked in dedupe store, so a later replay could re-trigger. + - Consider marking as processed on drop (or explicit retry queue sizing). + +3. **Matrix encrypted room handling is partial** + - `m.room.encryption` events mark rooms as encrypted, but only after join. Encrypted rooms already in allowlist could still process early timeline events before encryption event arrives. + - Consider checking encryption state via `/state` or guarding on first sync for allowlisted rooms. + +4. **Matrix send loop has no shutdown drain** + - `sendLoop` exits on context cancel without draining queued sends; may lose last responses on shutdown. + - Consider best-effort drain with timeout or log queue length on shutdown. + +5. **State store cleanup timer tied to MarkEventProcessed** + - Cleanup only runs when processing messages, so long idle periods may keep stale entries. Low impact but worth noting. + +## Notes +- The Matrix bot appropriately avoids E2EE rooms and invites, but could log a clear startup warning when any allowlisted room is encrypted. +- Matterbridge bot closes connection on ping failure but doesn’t propagate to run loop; reconnect relies on read loop exiting. diff --git a/docs/reviews/cli-entrypoints.md b/docs/reviews/cli-entrypoints.md new file mode 100644 index 0000000..50247aa --- /dev/null +++ b/docs/reviews/cli-entrypoints.md @@ -0,0 +1,24 @@ +# Code Review: CLI Entrypoints + +## Scope +- `cmd/musiclink/main.go` +- `cmd/smoketest/main.go` + +## Findings +### ✅ Strengths +- Clean startup flow: load config → init handler → choose Matrix vs Matterbridge → run with signal cancellation. +- Matrix mode cleanly isolated; `mxBot.Close()` called after run. +- Smoke test exercises detector/API/resolver end-to-end with clear output. + +### ⚠️ Issues / Opportunities +1. **Matterbridge close on fatal path** + - If `mbBot.Run` returns a non-canceled error, `log.Fatalf` exits before `mbBot.Close()` runs. + - Low impact (process exits), but consistent cleanup could be improved by deferring close after construction. + +2. **Smoke test hard-fails on external API issues** + - Smoke test exits on any API error (expected), but no retries/backoff. + - Acceptable for manual runs; document that it depends on idonthavespotify uptime. + +## Notes +- Signal handling and shutdown behavior are consistent with a long-running service. +- No CLI flags for selecting mode beyond config; that matches config-first expectations. diff --git a/docs/reviews/config.md b/docs/reviews/config.md new file mode 100644 index 0000000..b0ea8e1 --- /dev/null +++ b/docs/reviews/config.md @@ -0,0 +1,31 @@ +# Code Review: Config & Runtime Wiring + +## Scope +- `pkg/config/config.go` +- `config.example.toml` +- `config.toml` + +## Findings +### ✅ Strengths +- Validation logic clearly separates Matterbridge vs Matrix-required fields. +- Defaults are set for missing Matterbridge username and Matrix state store path. +- Example config documents Matrix mode and health endpoint. + +### ⚠️ Issues / Opportunities +1. **Mutually exclusive modes aren’t enforced** + - If both Matterbridge URL and Matrix enabled are set, both validate, but only Matrix is used in runtime. + - Consider warning or requiring explicit selection to avoid confusion. + +2. **No validation for room ID format** + - `matrix.rooms` accepts any string; invalid IDs will only fail later at runtime. + - Consider validating room IDs or at least trimming whitespace. + +3. **Token fields likely to be logged if config is dumped** + - Config loads raw tokens without masking; should avoid logging config objects to prevent leaks. + - Documentation could recommend loading tokens from env/secret stores. + +4. **Config defaults for Matterbridge only applied when URL set** + - If a user sets `matrix.enabled=false` and forgets `matterbridge.url`, they get an error, but username default isn’t set until URL present. Low impact but note the coupling. + +## Notes +- Current `config.toml` is a local test file; no secrets should be committed. diff --git a/docs/reviews/docs.md b/docs/reviews/docs.md new file mode 100644 index 0000000..96c4465 --- /dev/null +++ b/docs/reviews/docs.md @@ -0,0 +1,28 @@ +# Code Review: Docs & Design Artifacts + +## Scope +- `docs/` +- `README.md` +- `WORKLOG.md` + +## Findings +### ✅ Strengths +- Matrix-native design/intent/approach/work docs are consistent and aligned with implementation. +- README includes Matrix-native configuration, health endpoint, and E2EE limitation. +- Work log captures recent operational issues and next steps. + +### ⚠️ Issues / Opportunities +1. **README still frames Matterbridge as core architecture** + - Architecture section says it is designed to work as a Matterbridge sidecar; Matrix-native mode is now an equal option. + - Consider reframing to describe two supported modes to avoid confusion. + +2. **Worklog mentions Go 1.22.8** + - `WORKLOG.md` references Go 1.22.8; repo now uses Go 1.24.0. + - Consider updating the worklog or adding a note about the bump. + +3. **No migration guide for Matrix-native** + - Docs include config, but no step-by-step migration/cutover guidance for Matterbridge deployments. + - Consider adding a short “migration” section covering shadow mode validation and cutover. + +## Notes +- Design doc open questions are mostly resolved (SDK choice, state store). Might update the design doc to close them. diff --git a/docs/reviews/infra.md b/docs/reviews/infra.md new file mode 100644 index 0000000..3a2473e --- /dev/null +++ b/docs/reviews/infra.md @@ -0,0 +1,32 @@ +# Code Review: Project/Infra Metadata + +## Scope +- `go.mod`, `go.sum` +- `flake.nix`, `flake.lock` +- `LICENSE` + +## Findings +### ✅ Strengths +- Nix flake provides build + dev shell and a hardened systemd service definition. +- Go module dependencies are explicit; module list is straightforward. +- LICENSE is standard MIT. + +### ⚠️ Issues / Opportunities +1. **Flake description still Matterbridge-centric** + - Description/metadata mention Matterbridge sidecar; Matrix-native mode is now supported. + - Consider updating description/homepage to avoid confusion. + +2. **Nix service unit assumes Matterbridge** + - `after = [ "network.target" "matterbridge.service" ]` bakes in Matterbridge even for Matrix-native mode. + - Consider making the dependency conditional or optional. + +3. **Go toolchain version mismatch with README/worklog** + - `go.mod` is now 1.24.0, while README says 1.22+ and worklog mentions 1.22.8. + - Consider aligning documentation and nix dev shell Go version. + +4. **Vendor hash may need update after deps** + - `vendorHash` in flake may need updating due to new deps (mautrix/sqlite). + - Nix builds will fail until hash is refreshed. + +## Notes +- Dev shell includes `matterbridge`; consider adding `sqlite` if using the pure-Go driver for local inspection. diff --git a/docs/reviews/issues-status.md b/docs/reviews/issues-status.md new file mode 100644 index 0000000..6141b19 --- /dev/null +++ b/docs/reviews/issues-status.md @@ -0,0 +1,40 @@ +# Code Review Issues Status + +## CLI Entrypoints +1. **Matterbridge close on fatal path** — **Dropped** + - Matterbridge runtime removed; no longer applicable. + +2. **Smoke test hard-fails on external API issues** — **Defer** + - Acceptable for manual smoketest; revisit if automated CI uses it. + +## Bot Transport Layers +3. **Matterbridge message channel drop without metrics** — **Dropped** + - Matterbridge runtime removed; no longer applicable. + +4. **Matrix send queue drops without marking dedupe** — **Fix** + - Should mark processed on drop or add retry/queue sizing safeguards. + +5. **Matrix encrypted room handling is partial** — **Fix** + - Add startup check for encryption state or guard until encryption state known. + +6. **Matrix send loop has no shutdown drain** — **Defer** + - Low impact; log queue length or add best-effort drain later. + +7. **State store cleanup only on message processing** — **Defer** + - Low impact; consider periodic cleanup task if store grows. + +## Message Handling & Link Detection +8. **Detector misses common URL variants** — **Fix** + - Expand regex for common variants (spoti.fi, Apple Music track URLs, etc.). + +9. **Detector ignores formatted links** — **Defer** + - Requires HTML/Markdown parsing; not urgent for plain-text traffic. + +10. **Resolver only processes first link** — **Defer** + - Clarify behavior or implement multi-link responses later. + +11. **API error handling omits response body** — **Fix** + - Capture response body (bounded) on non-200 responses for debugging. + +12. **Service mapping lacks Qobuz support** — **Defer** + - Decide whether to add Qobuz support or remove enum later. diff --git a/docs/reviews/issues.md b/docs/reviews/issues.md new file mode 100644 index 0000000..63f4dcc --- /dev/null +++ b/docs/reviews/issues.md @@ -0,0 +1,22 @@ +# Code Review Issues (Aggregated) + +## CLI Entrypoints +1. **Smoke test hard-fails on external API issues** — **Deferred** + - No retries/backoff; relies on idonthavespotify uptime. + +## Bot Transport Layers +2. **Matrix send loop has no shutdown drain** — **Deferred** + - Queued responses may be lost on shutdown. + +3. **State store cleanup only on message processing** — **Deferred** + - Stale dedupe entries can persist during idle periods. + +## Message Handling & Link Detection +4. **Detector ignores formatted links** — **Deferred** + - No parsing for Markdown/HTML link formats. + +5. **Resolver only processes first link** — **Deferred** + - Multiple links in one message are ignored beyond the first. + +6. **Service mapping lacks Qobuz support** — **Deferred** + - Enum exists but no detection/formatting. diff --git a/docs/reviews/message-handling.md b/docs/reviews/message-handling.md new file mode 100644 index 0000000..e63fe88 --- /dev/null +++ b/docs/reviews/message-handling.md @@ -0,0 +1,37 @@ +# Code Review: Message Handling & Link Detection + +## Scope +- `internal/detector/` +- `internal/resolver/` +- `internal/services/` + +## Findings +### ✅ Strengths +- Regex detection is consolidated in one place; uses a single pattern for all supported services. +- Resolver encapsulates the idonthavespotify API and wraps errors with context. +- Output formatting is consistent and service ordering is explicit. + +### ⚠️ Issues / Opportunities +1. **Detector misses some common URL variants** + - Apple Music URLs can include `music.apple.com/{country}/album/...` but also have track URLs and other forms not matched. + - Spotify short links (`https://spoti.fi/...`) are not detected. + - Consider adding more variants or making the detector extensible with service-specific regexes. + +2. **Detector does not parse Markdown/HTML links** + - Matrix `formatted_body` or Slack-style `` formats won’t be parsed, only raw URLs. + - Consider optional parsing of formatted content if available. + +3. **Resolver only processes the first link** + - Handler selects `links[0]`, ignoring additional links in the message. + - Consider responding to multiple links or clarifying this behavior in docs. + +4. **API error handling omits response body** + - `Resolve` returns `API returned status %d` without details, making debugging harder. + - Consider reading up to N bytes of body on error for logging. + +5. **Service mapping lacks Qobuz even though enum exists** + - `ServiceQobuz` defined but never populated; detector/formatting do not include it. + - Consider either supporting or removing it to avoid dead config. + +## Notes +- Formatting uses title for track only; artist metadata is not used even if present (currently not parsed from API response). diff --git a/docs/work/2026-01-21-code-review-issues.md b/docs/work/2026-01-21-code-review-issues.md new file mode 100644 index 0000000..ab7335f --- /dev/null +++ b/docs/work/2026-01-21-code-review-issues.md @@ -0,0 +1,12 @@ +# Work: Code Review Issues Follow-up + +## The Checklist +- [ ] **W001**: Triaging CLI entrypoint issues (matterbridge close on fatal path, smoketest API failure handling). + - *Verification*: Decision recorded in `docs/reviews/issues-status.md` +- [ ] **W002**: Triaging bot transport issues (queue drops, encrypted room handling, shutdown drain, cleanup cadence). + - *Verification*: Decision recorded in `docs/reviews/issues-status.md` +- [ ] **W003**: Triaging message handling/link detection issues (URL variants, formatted links, multi-link handling, API error body, Qobuz). + - *Verification*: Decision recorded in `docs/reviews/issues-status.md` + +## The Audit Trail +* [2026-01-21] Work plan created. diff --git a/docs/work/2026-01-21-code-review.md b/docs/work/2026-01-21-code-review.md new file mode 100644 index 0000000..afd00a7 --- /dev/null +++ b/docs/work/2026-01-21-code-review.md @@ -0,0 +1,18 @@ +# Work: Code Review (6-Part Pass) + +## The Checklist +- [ ] **W001**: Review CLI entrypoints (`cmd/musiclink`, `cmd/smoketest`) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/cli-entrypoints.md` +- [ ] **W002**: Review bot transport layers (`internal/bot`, `internal/matrixbot`) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/bot-transport.md` +- [ ] **W003**: Review message handling & link detection (`internal/detector`, `internal/resolver`, `internal/services`) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/message-handling.md` +- [ ] **W004**: Review config & runtime wiring (`pkg/config`, config TOML) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/config.md` +- [ ] **W005**: Review docs & design artifacts (`docs`, `README.md`, `WORKLOG.md`) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/docs.md` +- [ ] **W006**: Review project/infra metadata (`go.mod`, `go.sum`, `flake.nix`, `LICENSE`) and capture findings. + - *Verification*: Notes recorded in `docs/reviews/infra.md` + +## The Audit Trail +* [2026-01-21] Work plan created. diff --git a/docs/work/2026-01-21-issues-fixes.md b/docs/work/2026-01-21-issues-fixes.md new file mode 100644 index 0000000..f9f820f --- /dev/null +++ b/docs/work/2026-01-21-issues-fixes.md @@ -0,0 +1,14 @@ +# Work: Fix Remaining Code Review Issues + +## The Checklist +- [ ] **W001**: Address Matrix send queue drop dedupe handling (mark on drop or adjust queue behavior). + - *Verification*: `go test ./...` +- [ ] **W002**: Improve encrypted room handling by checking encryption state at startup or on first sync. + - *Verification*: `go test ./...` +- [ ] **W003**: Expand detector URL variants (spoti.fi, Apple Music song URLs). + - *Verification*: `go test ./...` +- [ ] **W004**: Include bounded response body in API error handling for idonthavespotify. + - *Verification*: `go test ./...` + +## The Audit Trail +* [2026-01-21] Work plan created. diff --git a/docs/work/2026-01-21-matrix-native-routing.md b/docs/work/2026-01-21-matrix-native-routing.md new file mode 100644 index 0000000..88cb77d --- /dev/null +++ b/docs/work/2026-01-21-matrix-native-routing.md @@ -0,0 +1,21 @@ +# Work: Matrix-Native MusicLink Routing + +## The Checklist +- [x] **W001**: Add Matrix-native configuration fields (matrix enabled/server/access token/user id/rooms/state store path) and load them in config parsing. + - *Verification*: `go test ./...` +- [x] **W002**: Introduce Matrix client module with sync loop, room allowlist filtering, and bot self-message filtering. + - *Verification*: `go test ./...` +- [x] **W003**: Implement thread-aware reply posting (m.in_reply_to + thread rel when applicable) and link parsing integration. + - *Verification*: `go test ./...` +- [x] **W004**: Add SQLite-backed state store for sync token and processed event IDs with TTL cleanup. + - *Verification*: `go test ./...` +- [x] **W005**: Implement outbound queue with retry/backoff honoring Matrix rate-limit responses. + - *Verification*: `go test ./...` +- [x] **W006**: Add shadow mode logging and metrics/health reporting for sync status and send failures. + - *Verification*: `go test ./...` +- [x] **W007**: Document Matrix-native mode in README/config examples (including non-E2EE constraint and allowlist behavior). + - *Verification*: `go test ./...` + +## The Audit Trail +* [2026-01-21] Work plan created. +* [2026-01-21] W001-W007 completed. Verified with `go test ./...`. diff --git a/flake.nix b/flake.nix index 7f2b8f8..4a082c2 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "MusicLink Bot - A link converter sidecar for Matterbridge"; + description = "MusicLink Bot - Matrix-native music link converter"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; @@ -23,7 +23,7 @@ subPackages = [ "cmd/musiclink" ]; meta = with pkgs.lib; { - description = "Music link converter bot for Matterbridge"; + description = "Matrix-native music link converter bot"; homepage = "https://github.com/dan/musiclink"; license = licenses.mit; maintainers = [ ]; @@ -35,7 +35,6 @@ go gopls gotools - matterbridge ]; }; } @@ -59,7 +58,7 @@ systemd.services.musiclink = { description = "MusicLink Bot"; wantedBy = [ "multi-user.target" ]; - after = [ "network.target" "matterbridge.service" ]; + after = [ "network.target" ]; serviceConfig = { Type = "simple"; diff --git a/go.mod b/go.mod index ec31b57..9cc4d6d 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,33 @@ module musiclink -go 1.22.8 +go 1.24.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/gorilla/websocket v1.5.3 + maunium.net/go/mautrix v0.26.2 + modernc.org/sqlite v1.44.3 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.9.5 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 40ae17b..ef91420 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,98 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4= +go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g= +maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/bot/bot.go b/internal/bot/bot.go deleted file mode 100644 index 877d40b..0000000 --- a/internal/bot/bot.go +++ /dev/null @@ -1,289 +0,0 @@ -// Package bot handles communication with matterbridge via WebSocket. -package bot - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "sync" - "time" - - "github.com/gorilla/websocket" - - "musiclink/pkg/config" -) - -// Message represents a matterbridge message. -type Message struct { - Text string `json:"text"` - Channel string `json:"channel"` - Username string `json:"username"` - UserID string `json:"userid"` - Avatar string `json:"avatar"` - Account string `json:"account"` - Event string `json:"event"` - Protocol string `json:"protocol"` - Gateway string `json:"gateway"` - ParentID string `json:"parent_id"` - Timestamp time.Time `json:"timestamp"` - ID string `json:"id"` - Extra map[string]interface{} `json:"extra"` -} - -// Bot manages the WebSocket connection to matterbridge. -type Bot struct { - config config.MatterbridgeConfig - conn *websocket.Conn - handler MessageHandler - - mu sync.Mutex - done chan struct{} - messages chan Message -} - -// MessageHandler is called for each received message. -type MessageHandler func(ctx context.Context, msg Message) *Message - -// New creates a new Bot instance. -func New(cfg config.MatterbridgeConfig, handler MessageHandler) *Bot { - return &Bot{ - config: cfg, - handler: handler, - done: make(chan struct{}), - messages: make(chan Message, 100), - } -} - -// connect establishes a WebSocket connection to matterbridge. -func (b *Bot) connect(ctx context.Context) error { - header := http.Header{} - if b.config.Token != "" { - header.Set("Authorization", "Bearer "+b.config.Token) - } - - dialer := websocket.Dialer{ - HandshakeTimeout: 10 * time.Second, - } - - conn, _, err := dialer.DialContext(ctx, b.config.URL, header) - if err != nil { - return fmt.Errorf("connecting to matterbridge: %w", err) - } - - // Increase read limit to 10MB to handle potentially huge Matrix messages with previews - conn.SetReadLimit(10 * 1024 * 1024) - - b.mu.Lock() - b.conn = conn - b.mu.Unlock() - - log.Printf("Connected to matterbridge at %s", b.config.URL) - return nil -} - -// Run connects to matterbridge and starts the message processing loop. -// It automatically reconnects on connection failures. -func (b *Bot) Run(ctx context.Context) error { - backoff := time.Second - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - // Connect (or reconnect) - if err := b.connect(ctx); err != nil { - log.Printf("Connection failed: %v (retrying in %v)", err, backoff) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(backoff): - // Exponential backoff, max 30 seconds - backoff = min(backoff*2, 30*time.Second) - continue - } - } - - // Reset backoff on successful connection - backoff = time.Second - - // Run the message loop until disconnection - err := b.runLoop(ctx) - - // Connection lost, will reconnect - if err != nil && err != context.Canceled { - log.Printf("Connection lost: %v (reconnecting...)", err) - } - - b.closeConn() - - if err == context.Canceled || (ctx.Err() != nil) { - return ctx.Err() - } - } -} - -// runLoop processes messages until the connection is lost or context is canceled. -func (b *Bot) runLoop(ctx context.Context) error { - b.mu.Lock() - conn := b.conn - b.mu.Unlock() - - if conn == nil { - return fmt.Errorf("connection is nil") - } - - // Create a sub-context for this specific connection's goroutines - connCtx, cancel := context.WithCancel(ctx) - defer cancel() - - // Channel to signal read loop exit - readDone := make(chan error, 1) - - // Start reader goroutine - go func() { - readDone <- b.readLoop(connCtx, conn) - }() - - // Start ping goroutine - go b.pingLoop(connCtx, conn) - - // Process messages - for { - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-readDone: - return err - case msg := <-b.messages: - // Skip our own messages - if msg.Username == b.config.Username { - continue - } - - // Skip events that aren't regular messages - if msg.Event != "" && msg.Event != "api_connected" { - continue - } - - // Handle the message - response := b.handler(ctx, msg) - if response != nil { - if err := b.Send(*response); err != nil { - log.Printf("Error sending response: %v", err) - } - } - } - } -} - -// readLoop reads messages from the WebSocket connection. -func (b *Bot) readLoop(ctx context.Context, conn *websocket.Conn) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - _, data, err := conn.ReadMessage() - if err != nil { - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - return nil - } - return fmt.Errorf("read error: %w", err) - } - - if len(data) > 1024 { - log.Printf("Received large message: %d bytes", len(data)) - } - - var msg Message - if err := json.Unmarshal(data, &msg); err != nil { - log.Printf("Error parsing message: %v", err) - continue - } - - select { - case b.messages <- msg: - case <-ctx.Done(): - return ctx.Err() - default: - log.Printf("Message queue full, dropping message") - } - } -} - -// pingLoop sends periodic pings to keep the connection alive. -func (b *Bot) pingLoop(ctx context.Context, conn *websocket.Conn) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - log.Printf("Ping failed: %v", err) - b.closeConn() - return - } - } - } -} - -// Send sends a message to matterbridge. -func (b *Bot) Send(msg Message) error { - // Set required fields - msg.Gateway = b.config.Gateway - msg.Username = b.config.Username - if b.config.Avatar != "" { - msg.Avatar = b.config.Avatar - } - - data, err := json.Marshal(msg) - if err != nil { - return err - } - log.Printf("Sending message payload size: %d bytes", len(data)) - - b.mu.Lock() - defer b.mu.Unlock() - - if b.conn == nil { - return fmt.Errorf("not connected") - } - - b.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) - return b.conn.WriteMessage(websocket.TextMessage, data) -} - -// closeConn closes the current connection. -func (b *Bot) closeConn() { - b.mu.Lock() - defer b.mu.Unlock() - - if b.conn != nil { - b.conn.Close() - b.conn = nil - } -} - -// Close closes the WebSocket connection. -func (b *Bot) Close() error { - b.mu.Lock() - defer b.mu.Unlock() - - if b.conn != nil { - err := b.conn.Close() - b.conn = nil - return err - } - return nil -} diff --git a/internal/bot/bot_integration_test.go b/internal/bot/bot_integration_test.go deleted file mode 100644 index b45fa73..0000000 --- a/internal/bot/bot_integration_test.go +++ /dev/null @@ -1,112 +0,0 @@ -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/detector/detector.go b/internal/detector/detector.go index 6f70f1f..a4778b1 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -12,8 +12,9 @@ import ( var pattern = regexp.MustCompile( `https?://(?:` + `(?:open\.)?spotify\.com/(?:track|album|artist|playlist)/[a-zA-Z0-9]+|` + + `spoti\.fi/[a-zA-Z0-9]+|` + `(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|music\.youtube\.com/watch\?v=)[a-zA-Z0-9_-]{11}|` + - `(?:music\.)?apple\.com/[a-z]{2}/(?:album|artist|playlist)/[^\s]+|` + + `(?:music\.)?apple\.com/[a-z]{2}/(?:album|artist|playlist|song)/[^\s]+|` + `(?:www\.)?deezer\.com/(?:[a-z]{2}/)?(?:track|album|artist|playlist)/\d+|` + `(?:www\.)?soundcloud\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+|` + `(?:www\.)?tidal\.com/(?:browse/)?(?:track|album|artist|playlist)/\d+|` + diff --git a/internal/bot/handler.go b/internal/handler/handler.go similarity index 70% rename from internal/bot/handler.go rename to internal/handler/handler.go index 0f3de65..b8b05aa 100644 --- a/internal/bot/handler.go +++ b/internal/handler/handler.go @@ -1,4 +1,4 @@ -package bot +package handler import ( "context" @@ -14,23 +14,23 @@ type Handler struct { resolver *resolver.Resolver } -// NewHandler creates a new message handler. -func NewHandler(apiURL string) *Handler { +// New creates a new message handler. +func New(apiURL string) *Handler { return &Handler{ detector: detector.New(), resolver: resolver.New(apiURL), } } -// Handle processes a message and returns a response if music links were found. -func (h *Handler) Handle(ctx context.Context, msg Message) *Message { +// HandleText processes raw text and returns a response string when links are found. +func (h *Handler) HandleText(ctx context.Context, text, username string) (string, bool) { // Detect music links in the message - links := h.detector.Detect(msg.Text) + links := h.detector.Detect(text) if len(links) == 0 { - return nil + return "", false } - log.Printf("Found %d music link(s) in message from %s: %s", len(links), msg.Username, links[0].URL) + log.Printf("Found %d music link(s) in message from %s: %s", len(links), username, links[0].URL) // Process the first link found link := links[0] @@ -40,14 +40,14 @@ func (h *Handler) Handle(ctx context.Context, msg Message) *Message { resolved, err := h.resolver.Resolve(ctx, link.URL) if err != nil { log.Printf("Error resolving link %s: %v", link.URL, err) - return nil + return "", false } log.Printf("Resolver returned for %s (found %d links)", link.URL, len(resolved.Links)) // Only respond if we found links on other services if len(resolved.Links) <= 1 { log.Printf("No additional links found for %s", link.URL) - return nil + return "", false } // Format the response @@ -55,11 +55,8 @@ func (h *Handler) Handle(ctx context.Context, msg Message) *Message { if resolved.Track != nil { title = resolved.Track.Title } - text := resolver.Format(resolved, title) + response := resolver.Format(resolved, title) log.Printf("Sending response for %s", link.URL) - return &Message{ - Text: text, - Gateway: msg.Gateway, - } + return response, true } diff --git a/internal/matrixbot/bot.go b/internal/matrixbot/bot.go new file mode 100644 index 0000000..94d84f7 --- /dev/null +++ b/internal/matrixbot/bot.go @@ -0,0 +1,361 @@ +// Package matrixbot handles Matrix-native bot behavior. +package matrixbot + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "musiclink/pkg/config" +) + +// TextHandler processes plain text messages and returns a response when applicable. +type TextHandler func(ctx context.Context, text, username string) (string, bool) + +// Bot manages Matrix-native sync and message handling. +type Bot struct { + cfg config.MatrixConfig + client *mautrix.Client + handler TextHandler + allowedRooms map[id.RoomID]struct{} + encryptedRooms map[id.RoomID]struct{} + stateStore *StateStore + sendQueue chan sendRequest + stats botStats +} + +type sendRequest struct { + roomID id.RoomID + event *event.Event + response string +} + +// New creates a new Matrix-native bot instance. +func New(cfg config.MatrixConfig, handler TextHandler) (*Bot, error) { + client, err := mautrix.NewClient(cfg.Server, id.UserID(cfg.UserID), cfg.AccessToken) + if err != nil { + return nil, fmt.Errorf("create matrix client: %w", err) + } + + allowed := make(map[id.RoomID]struct{}, len(cfg.Rooms)) + for _, room := range cfg.Rooms { + allowed[id.RoomID(room)] = struct{}{} + } + + store, err := NewStateStore(cfg.StateStorePath) + if err != nil { + return nil, fmt.Errorf("init state store: %w", err) + } + client.Store = store + log.Printf("Matrix state store: %s", cfg.StateStorePath) + + bot := &Bot{ + cfg: cfg, + client: client, + handler: handler, + allowedRooms: allowed, + encryptedRooms: make(map[id.RoomID]struct{}), + stateStore: store, + sendQueue: make(chan sendRequest, 100), + } + + syncer := client.Syncer.(*mautrix.DefaultSyncer) + syncer.OnEventType(event.EventMessage, bot.onMessage) + syncer.OnEventType(event.StateMember, bot.onMember) + syncer.OnEventType(event.StateEncryption, bot.onEncryption) + syncer.OnSync(bot.onSync) + + return bot, nil +} + +// Run starts the sync loop. +func (b *Bot) Run(ctx context.Context) error { + mode := "active" + if b.cfg.Shadow { + mode = "shadow" + } + log.Printf("Matrix bot starting (%s mode, rooms: %d)", mode, len(b.allowedRooms)) + log.Printf("Matrix allowlist rooms: %s", strings.Join(roomList(b.allowedRooms), ", ")) + if b.cfg.HealthAddr != "" { + log.Printf("Matrix health server listening on %s", b.cfg.HealthAddr) + go b.startHealthServer(ctx) + } + if err := b.prefetchEncryptionState(ctx); err != nil { + log.Printf("Matrix encryption state check failed: %v", err) + } + go b.sendLoop(ctx) + return b.client.SyncWithContext(ctx) +} + +// Close releases Matrix bot resources. +func (b *Bot) Close() error { + if b.stateStore != nil { + return b.stateStore.Close() + } + return nil +} + +func (b *Bot) onMember(ctx context.Context, evt *event.Event) { + if evt == nil { + return + } + if evt.GetStateKey() != b.client.UserID.String() { + return + } + member := evt.Content.AsMember() + if member.Membership != event.MembershipInvite { + return + } + if isEncryptedInvite(evt) { + log.Printf("Matrix invite ignored (encrypted room): %s", evt.RoomID) + if _, err := b.client.LeaveRoom(ctx, evt.RoomID); err != nil { + log.Printf("Matrix invite leave failed for %s: %v", evt.RoomID, err) + } + return + } + if _, ok := b.allowedRooms[evt.RoomID]; ok { + if _, err := b.client.JoinRoomByID(ctx, evt.RoomID); err != nil { + log.Printf("Matrix invite join failed for %s: %v", evt.RoomID, err) + } + return + } + if _, err := b.client.LeaveRoom(ctx, evt.RoomID); err != nil { + log.Printf("Matrix invite leave failed for %s: %v", evt.RoomID, err) + } +} + +func (b *Bot) onMessage(ctx context.Context, evt *event.Event) { + if evt == nil { + return + } + if _, ok := b.allowedRooms[evt.RoomID]; !ok { + return + } + if evt.Sender == b.client.UserID { + return + } + if b.isRoomEncrypted(evt.RoomID) { + b.stats.markEncryptedSkipped() + return + } + + b.stats.markReceived() + + if processed, err := b.stateStore.WasEventProcessed(ctx, evt.ID.String()); err != nil { + log.Printf("Matrix dedupe check failed (event %s): %v", evt.ID, err) + } else if processed { + return + } + + content := evt.Content.AsMessage() + if content == nil { + return + } + if !content.MsgType.IsText() { + return + } + if content.RelatesTo != nil && content.RelatesTo.GetReplaceID() != "" { + return + } + if content.Body == "" { + return + } + + response, ok := b.handler(ctx, content.Body, evt.Sender.String()) + if !ok { + return + } + + if b.cfg.Shadow { + log.Printf("Matrix shadow reply (room %s, event %s): %s", evt.RoomID, evt.ID, response) + b.stats.markResponded() + if _, err := b.stateStore.MarkEventProcessed(ctx, evt.ID.String()); err != nil { + log.Printf("Matrix dedupe record failed (event %s): %v", evt.ID, err) + } + return + } + + select { + case b.sendQueue <- sendRequest{roomID: evt.RoomID, event: evt, response: response}: + default: + b.stats.markDropped() + if _, err := b.stateStore.MarkEventProcessed(ctx, evt.ID.String()); err != nil { + log.Printf("Matrix dedupe record failed after drop (event %s): %v", evt.ID, err) + } + log.Printf("Matrix send queue full; dropping response for event %s", evt.ID) + } +} + +func (b *Bot) sendReply(ctx context.Context, evt *event.Event, response string) error { + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: response, + } + + original := evt.Content.AsMessage() + if original != nil && original.RelatesTo != nil && original.RelatesTo.GetThreadParent() != "" { + content.SetThread(evt) + } else { + content.SetReply(evt) + } + + _, err := b.client.SendMessageEvent(ctx, evt.RoomID, event.EventMessage, content) + return err +} + +func (b *Bot) sendLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case req := <-b.sendQueue: + b.sendWithRetry(ctx, req) + } + } +} + +func (b *Bot) sendWithRetry(ctx context.Context, req sendRequest) { + const maxAttempts = 5 + backoff := time.Second + + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err := b.sendReply(ctx, req.event, req.response); err != nil { + delay, ok := rateLimitDelay(err) + if ok { + b.stats.markRateLimited() + b.stats.setLastSendError(err.Error()) + if delay <= 0 { + delay = backoff + } + log.Printf("Matrix rate limited (event %s), retrying in %s", req.event.ID, delay) + if !sleepContext(ctx, delay) { + return + } + backoff = minDuration(backoff*2, 30*time.Second) + continue + } + b.stats.setLastSendError(err.Error()) + log.Printf("Matrix send failed (room %s, event %s): %v", req.roomID, req.event.ID, err) + return + } + + b.stats.markResponded() + if _, err := b.stateStore.MarkEventProcessed(ctx, req.event.ID.String()); err != nil { + log.Printf("Matrix dedupe record failed (event %s): %v", req.event.ID, err) + } + return + } + + b.stats.setLastSendError("max retries exceeded") + log.Printf("Matrix send failed after retries (room %s, event %s)", req.roomID, req.event.ID) +} + +func rateLimitDelay(err error) (time.Duration, bool) { + if !errors.Is(err, mautrix.MLimitExceeded) { + return 0, false + } + + var httpErr mautrix.HTTPError + if errors.As(err, &httpErr) && httpErr.RespError != nil { + if retry, ok := httpErr.RespError.ExtraData["retry_after_ms"]; ok { + switch value := retry.(type) { + case float64: + return time.Duration(value) * time.Millisecond, true + case int64: + return time.Duration(value) * time.Millisecond, true + case int: + return time.Duration(value) * time.Millisecond, true + } + } + } + + return 0, true +} + +func sleepContext(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func (b *Bot) onEncryption(ctx context.Context, evt *event.Event) { + if evt == nil { + return + } + if _, ok := b.allowedRooms[evt.RoomID]; !ok { + return + } + if !b.isRoomEncrypted(evt.RoomID) { + b.encryptedRooms[evt.RoomID] = struct{}{} + log.Printf("Matrix room marked encrypted; skipping messages: %s", evt.RoomID) + } +} + +func (b *Bot) onSync(ctx context.Context, _ *mautrix.RespSync, _ string) bool { + b.stats.markSync() + return true +} + +func (b *Bot) isRoomEncrypted(roomID id.RoomID) bool { + _, ok := b.encryptedRooms[roomID] + return ok +} + +func (b *Bot) prefetchEncryptionState(ctx context.Context) error { + for roomID := range b.allowedRooms { + var content event.EncryptionEventContent + if err := b.client.StateEvent(ctx, roomID, event.StateEncryption, "", &content); err != nil { + if errors.Is(err, mautrix.MNotFound) { + continue + } + return err + } + if !b.isRoomEncrypted(roomID) { + b.encryptedRooms[roomID] = struct{}{} + log.Printf("Matrix room marked encrypted (state fetch); skipping messages: %s", roomID) + } + } + return nil +} + +func roomList(rooms map[id.RoomID]struct{}) []string { + list := make([]string, 0, len(rooms)) + for roomID := range rooms { + list = append(list, roomID.String()) + } + return list +} + +func isEncryptedInvite(evt *event.Event) bool { + if evt == nil || evt.Unsigned.InviteRoomState == nil { + return false + } + for _, state := range evt.Unsigned.InviteRoomState { + if state == nil { + continue + } + if state.Type == event.StateEncryption { + return true + } + } + return false +} diff --git a/internal/matrixbot/health.go b/internal/matrixbot/health.go new file mode 100644 index 0000000..7245c90 --- /dev/null +++ b/internal/matrixbot/health.go @@ -0,0 +1,83 @@ +package matrixbot + +import ( + "context" + "encoding/json" + "log" + "net/http" + "time" +) + +type healthResponse struct { + Status string `json:"status"` + Mode string `json:"mode"` + Rooms int `json:"rooms"` + LastSync string `json:"last_sync,omitempty"` + LastEvent string `json:"last_event,omitempty"` + LastSend string `json:"last_send,omitempty"` + LastSendError string `json:"last_send_error,omitempty"` + Received int64 `json:"received"` + Responded int64 `json:"responded"` + Dropped int64 `json:"dropped"` + RateLimited int64 `json:"rate_limited"` + EncryptedSkipped int64 `json:"encrypted_skipped"` +} + +func (b *Bot) startHealthServer(ctx context.Context) { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", b.handleHealth) + mux.HandleFunc("/metrics", b.handleHealth) + + srv := &http.Server{ + Addr: b.cfg.HealthAddr, + Handler: mux, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("Matrix health server shutdown error: %v", err) + } + }() + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("Matrix health server error: %v", err) + } +} + +func (b *Bot) handleHealth(w http.ResponseWriter, _ *http.Request) { + snapshot := b.stats.Snapshot() + mode := "active" + if b.cfg.Shadow { + mode = "shadow" + } + payload := healthResponse{ + Status: "ok", + Mode: mode, + Rooms: len(b.allowedRooms), + LastSync: formatTime(snapshot.LastSync), + LastEvent: formatTime(snapshot.LastEvent), + LastSend: formatTime(snapshot.LastSend), + LastSendError: snapshot.LastSendError, + Received: snapshot.Received, + Responded: snapshot.Responded, + Dropped: snapshot.Dropped, + RateLimited: snapshot.RateLimited, + EncryptedSkipped: snapshot.EncryptedSkipped, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Printf("Matrix health encode error: %v", err) + } +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/internal/matrixbot/state_store.go b/internal/matrixbot/state_store.go new file mode 100644 index 0000000..6927372 --- /dev/null +++ b/internal/matrixbot/state_store.go @@ -0,0 +1,184 @@ +// Package matrixbot provides Matrix state storage utilities. +package matrixbot + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + _ "modernc.org/sqlite" + + "maunium.net/go/mautrix/id" +) + +const ( + defaultEventTTL = 24 * time.Hour +) + +// StateStore persists sync tokens and processed event IDs. +type StateStore struct { + db *sql.DB + mu sync.Mutex + dedupeTTL time.Duration + lastCleanup time.Time +} + +// NewStateStore opens or creates the state store database. +func NewStateStore(path string) (*StateStore, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create state store dir: %w", err) + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open state store: %w", err) + } + + store := &StateStore{db: db, dedupeTTL: defaultEventTTL} + if err := store.init(); err != nil { + _ = db.Close() + return nil, err + } + + return store, nil +} + +func (s *StateStore) init() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS sync_tokens ( + user_id TEXT PRIMARY KEY, + filter_id TEXT, + next_batch TEXT + );`, + `CREATE TABLE IF NOT EXISTS processed_events ( + event_id TEXT PRIMARY KEY, + processed_at INTEGER NOT NULL + );`, + } + for _, stmt := range stmts { + if _, err := s.db.Exec(stmt); err != nil { + return fmt.Errorf("init state store: %w", err) + } + } + return nil +} + +// Close closes the state store database. +func (s *StateStore) Close() error { + return s.db.Close() +} + +// SaveFilterID stores the filter ID for the given user. +func (s *StateStore) SaveFilterID(ctx context.Context, userID id.UserID, filterID string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO sync_tokens (user_id, filter_id) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET filter_id = excluded.filter_id`, + userID.String(), filterID, + ) + return err +} + +// LoadFilterID loads the filter ID for the given user. +func (s *StateStore) LoadFilterID(ctx context.Context, userID id.UserID) (string, error) { + var filterID sql.NullString + err := s.db.QueryRowContext(ctx, + `SELECT filter_id FROM sync_tokens WHERE user_id = ?`, + userID.String(), + ).Scan(&filterID) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + if filterID.Valid { + return filterID.String, nil + } + return "", nil +} + +// SaveNextBatch stores the next batch token for the given user. +func (s *StateStore) SaveNextBatch(ctx context.Context, userID id.UserID, nextBatchToken string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO sync_tokens (user_id, next_batch) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET next_batch = excluded.next_batch`, + userID.String(), nextBatchToken, + ) + return err +} + +// LoadNextBatch loads the next batch token for the given user. +func (s *StateStore) LoadNextBatch(ctx context.Context, userID id.UserID) (string, error) { + var nextBatch sql.NullString + err := s.db.QueryRowContext(ctx, + `SELECT next_batch FROM sync_tokens WHERE user_id = ?`, + userID.String(), + ).Scan(&nextBatch) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", err + } + if nextBatch.Valid { + return nextBatch.String, nil + } + return "", nil +} + +// WasEventProcessed checks whether an event ID has already been recorded. +func (s *StateStore) WasEventProcessed(ctx context.Context, eventID string) (bool, error) { + var exists bool + err := s.db.QueryRowContext(ctx, + `SELECT 1 FROM processed_events WHERE event_id = ?`, + eventID, + ).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// MarkEventProcessed records a processed event. Returns true if newly recorded. +func (s *StateStore) MarkEventProcessed(ctx context.Context, eventID string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + if s.lastCleanup.IsZero() || now.Sub(s.lastCleanup) > s.dedupeTTL { + if err := s.cleanupLocked(ctx, now); err != nil { + return false, err + } + s.lastCleanup = now + } + + res, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO processed_events (event_id, processed_at) VALUES (?, ?)`, + eventID, now.Unix(), + ) + if err != nil { + return false, err + } + + affected, err := res.RowsAffected() + if err != nil { + return false, err + } + return affected > 0, nil +} + +func (s *StateStore) cleanupLocked(ctx context.Context, now time.Time) error { + cutoff := now.Add(-s.dedupeTTL).Unix() + _, err := s.db.ExecContext(ctx, + `DELETE FROM processed_events WHERE processed_at < ?`, + cutoff, + ) + return err +} diff --git a/internal/matrixbot/stats.go b/internal/matrixbot/stats.go new file mode 100644 index 0000000..69f8732 --- /dev/null +++ b/internal/matrixbot/stats.go @@ -0,0 +1,99 @@ +package matrixbot + +import ( + "sync" + "sync/atomic" + "time" +) + +type botStats struct { + received int64 + responded int64 + dropped int64 + rateLimited int64 + encryptedSkipped int64 + + mu sync.Mutex + lastEvent time.Time + lastSync time.Time + lastSend time.Time + lastSendError string +} + +type statsSnapshot struct { + Received int64 + Responded int64 + Dropped int64 + RateLimited int64 + EncryptedSkipped int64 + LastEvent time.Time + LastSync time.Time + LastSend time.Time + LastSendError string +} + +func (s *botStats) markReceived() { + atomic.AddInt64(&s.received, 1) + s.setLastEvent(time.Now()) +} + +func (s *botStats) markResponded() { + atomic.AddInt64(&s.responded, 1) + s.setLastSend(time.Now()) +} + +func (s *botStats) markDropped() { + atomic.AddInt64(&s.dropped, 1) +} + +func (s *botStats) markRateLimited() { + atomic.AddInt64(&s.rateLimited, 1) +} + +func (s *botStats) markEncryptedSkipped() { + atomic.AddInt64(&s.encryptedSkipped, 1) +} + +func (s *botStats) markSync() { + s.setLastSync(time.Now()) +} + +func (s *botStats) setLastEvent(t time.Time) { + s.mu.Lock() + s.lastEvent = t + s.mu.Unlock() +} + +func (s *botStats) setLastSync(t time.Time) { + s.mu.Lock() + s.lastSync = t + s.mu.Unlock() +} + +func (s *botStats) setLastSend(t time.Time) { + s.mu.Lock() + s.lastSend = t + s.mu.Unlock() +} + +func (s *botStats) setLastSendError(err string) { + s.mu.Lock() + s.lastSendError = err + s.mu.Unlock() +} + +func (s *botStats) Snapshot() statsSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return statsSnapshot{ + Received: atomic.LoadInt64(&s.received), + Responded: atomic.LoadInt64(&s.responded), + Dropped: atomic.LoadInt64(&s.dropped), + RateLimited: atomic.LoadInt64(&s.rateLimited), + EncryptedSkipped: atomic.LoadInt64(&s.encryptedSkipped), + LastEvent: s.lastEvent, + LastSync: s.lastSync, + LastSend: s.lastSend, + LastSendError: s.lastSendError, + } +} diff --git a/internal/services/idonthavespotify.go b/internal/services/idonthavespotify.go index c278158..277e9d4 100644 --- a/internal/services/idonthavespotify.go +++ b/internal/services/idonthavespotify.go @@ -5,7 +5,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" + "strings" "time" ) @@ -76,6 +78,10 @@ func (c *Client) Resolve(ctx context.Context, link string) (*APIResponse, error) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + body := readErrorBody(resp) + if body != "" { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) + } return nil, fmt.Errorf("API returned status %d", resp.StatusCode) } @@ -153,3 +159,16 @@ func apiTypeToServiceType(t string) (ServiceType, bool) { return "", false } } + +func readErrorBody(resp *http.Response) string { + const maxErrorBytes = 2048 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes)) + if err != nil { + return "" + } + msg := strings.TrimSpace(string(data)) + if msg == "" { + return "" + } + return msg +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7e766d2..9669e92 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,16 +10,18 @@ import ( // Config holds the complete application configuration. type Config struct { - Matterbridge MatterbridgeConfig `toml:"matterbridge"` + Matrix MatrixConfig `toml:"matrix"` } -// MatterbridgeConfig holds matterbridge connection settings. -type MatterbridgeConfig struct { - URL string `toml:"url"` // WebSocket URL, e.g., "ws://localhost:4242/api/websocket" - Token string `toml:"token"` // API token for authentication - Gateway string `toml:"gateway"` // Gateway name to send messages to - Username string `toml:"username"` // Bot username shown in messages - Avatar string `toml:"avatar"` // Avatar URL for the bot +// MatrixConfig holds Matrix-native bot settings. +type MatrixConfig struct { + Shadow bool `toml:"shadow"` // Shadow mode (log only) + HealthAddr string `toml:"healthAddr"` // Health server listen address + Server string `toml:"server"` // Homeserver base URL + AccessToken string `toml:"accessToken"` // Access token for bot user + UserID string `toml:"userId"` // Full Matrix user ID + Rooms []string `toml:"rooms"` // Allowlisted room IDs + StateStorePath string `toml:"stateStorePath"` // Path to state store (sync token/dedupe) } // Load reads and parses a TOML configuration file. @@ -43,14 +45,20 @@ func Load(path string) (*Config, error) { // Validate checks that required configuration fields are set. func (c *Config) Validate() error { - if c.Matterbridge.URL == "" { - return fmt.Errorf("matterbridge.url is required") + if c.Matrix.Server == "" { + return fmt.Errorf("matrix.server is required") } - if c.Matterbridge.Gateway == "" { - return fmt.Errorf("matterbridge.gateway is required") + if c.Matrix.AccessToken == "" { + return fmt.Errorf("matrix.accessToken is required") } - if c.Matterbridge.Username == "" { - c.Matterbridge.Username = "MusicLink" + if c.Matrix.UserID == "" { + return fmt.Errorf("matrix.userId is required") + } + if len(c.Matrix.Rooms) == 0 { + return fmt.Errorf("matrix.rooms must include at least one room") + } + if c.Matrix.StateStorePath == "" { + c.Matrix.StateStorePath = "data/matrix-state.db" } return nil }