Deprecate matterbridge and fix matrix issues

This commit is contained in:
dan 2026-01-21 22:26:58 -08:00
parent 4a01f7ad77
commit 95a70d8b86
34 changed files with 1448 additions and 808 deletions

View file

@ -4,9 +4,9 @@ MusicLink is a Go-based chat bot that automatically detects music links (Spotify
## Architecture ## 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. * **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. * **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 ### Prerequisites
* Go 1.22 or higher * 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 ### Building
@ -30,13 +30,16 @@ go build -o musiclink ./cmd/musiclink
cp config.example.toml config.toml 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 ```toml
[matterbridge] [matrix]
url = "ws://localhost:4242/api/websocket" shadow = false # set true to log-only during validation
token = "your-matterbridge-api-token" healthAddr = ":8080" # optional health/metrics endpoint
gateway = "main" # The gateway name defined in your matterbridge.toml server = "https://matrix.example.com"
username = "MusicLink" accessToken = "your-matrix-access-token"
userId = "@musiclink:example.com"
rooms = ["!roomid:example.com"]
stateStorePath = "data/matrix-state.db"
``` ```
### Running ### Running
@ -45,6 +48,14 @@ go build -o musiclink ./cmd/musiclink
./musiclink -config config.toml ./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 ## Testing
The project includes both unit tests and integration tests. The project includes both unit tests and integration tests.
@ -57,11 +68,7 @@ go test ./...
### Integration Tests ### 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. No Matrix integration tests are included yet; add them as needed.
```bash
go test -v internal/bot/bot_integration_test.go
```
### Smoke Test ### Smoke Test

View file

@ -8,7 +8,8 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"musiclink/internal/bot" "musiclink/internal/handler"
"musiclink/internal/matrixbot"
"musiclink/pkg/config" "musiclink/pkg/config"
) )
@ -23,10 +24,13 @@ func main() {
} }
// Create message handler // Create message handler
handler := bot.NewHandler("") msgHandler := handler.New("")
// Create bot // 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 // Setup context with cancellation
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -43,10 +47,12 @@ func main() {
// Run the bot (connects and reconnects automatically) // Run the bot (connects and reconnects automatically)
log.Println("MusicLink bot starting...") log.Println("MusicLink bot starting...")
if err := b.Run(ctx); err != nil && err != context.Canceled { if err := mxBot.Run(ctx); err != nil && err != context.Canceled {
log.Fatalf("Bot error: %v", err) 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!") log.Println("Goodbye!")
} }

View file

@ -12,7 +12,7 @@ import (
) )
func main() { func main() {
fmt.Println("=== MusicLink Smoke Test ===\n") fmt.Println("=== MusicLink Smoke Test ===")
// Test 1: Detector // Test 1: Detector
fmt.Println("1. Testing link detection...") fmt.Println("1. Testing link detection...")

View file

@ -3,18 +3,26 @@
# This bot uses the idonthavespotify API to convert music links # This bot uses the idonthavespotify API to convert music links
# between streaming services. No API credentials needed! # between streaming services. No API credentials needed!
[matterbridge] [matrix]
# WebSocket URL for matterbridge API bridge # Shadow mode (log responses without sending)
url = "ws://localhost:4242/api/websocket" shadow = false
# API token (must match matterbridge config) # Optional health server address (ex: ":8080")
token = "your-matterbridge-api-token" healthAddr = ""
# Gateway name to send messages to # Matrix homeserver base URL
gateway = "main" server = "https://matrix.example.com"
# Bot username shown in messages # Access token for the bot user
username = "MusicLink" accessToken = "your-matrix-access-token"
# Avatar URL for the bot (optional) # Full Matrix user ID for the bot
avatar = "" 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"

View file

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

29
docs/code-review-plan.md Normal file
View file

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

View file

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

View file

@ -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 MusicLinks core feature set.
* Relying on multi-gateway Matterbridge setups for routing correctness.

View file

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

View file

@ -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 doesnt propagate to run loop; reconnect relies on read loop exiting.

View file

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

31
docs/reviews/config.md Normal file
View file

@ -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 arent 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 isnt set until URL present. Low impact but note the coupling.
## Notes
- Current `config.toml` is a local test file; no secrets should be committed.

28
docs/reviews/docs.md Normal file
View file

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

32
docs/reviews/infra.md Normal file
View file

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

View file

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

22
docs/reviews/issues.md Normal file
View file

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

View file

@ -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 `<url|text>` formats wont 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).

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
{ {
description = "MusicLink Bot - A link converter sidecar for Matterbridge"; description = "MusicLink Bot - Matrix-native music link converter";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
@ -23,7 +23,7 @@
subPackages = [ "cmd/musiclink" ]; subPackages = [ "cmd/musiclink" ];
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "Music link converter bot for Matterbridge"; description = "Matrix-native music link converter bot";
homepage = "https://github.com/dan/musiclink"; homepage = "https://github.com/dan/musiclink";
license = licenses.mit; license = licenses.mit;
maintainers = [ ]; maintainers = [ ];
@ -35,7 +35,6 @@
go go
gopls gopls
gotools gotools
matterbridge
]; ];
}; };
} }
@ -59,7 +58,7 @@
systemd.services.musiclink = { systemd.services.musiclink = {
description = "MusicLink Bot"; description = "MusicLink Bot";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" "matterbridge.service" ]; after = [ "network.target" ];
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";

29
go.mod
View file

@ -1,8 +1,33 @@
module musiclink module musiclink
go 1.22.8 go 1.24.0
require ( require (
github.com/BurntSushi/toml v1.6.0 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
) )

98
go.sum
View file

@ -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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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=

View file

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

View file

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

View file

@ -12,8 +12,9 @@ import (
var pattern = regexp.MustCompile( var pattern = regexp.MustCompile(
`https?://(?:` + `https?://(?:` +
`(?:open\.)?spotify\.com/(?:track|album|artist|playlist)/[a-zA-Z0-9]+|` + `(?: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}|` + `(?: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\.)?deezer\.com/(?:[a-z]{2}/)?(?:track|album|artist|playlist)/\d+|` +
`(?:www\.)?soundcloud\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+|` + `(?:www\.)?soundcloud\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+|` +
`(?:www\.)?tidal\.com/(?:browse/)?(?:track|album|artist|playlist)/\d+|` + `(?:www\.)?tidal\.com/(?:browse/)?(?:track|album|artist|playlist)/\d+|` +

View file

@ -1,4 +1,4 @@
package bot package handler
import ( import (
"context" "context"
@ -14,23 +14,23 @@ type Handler struct {
resolver *resolver.Resolver resolver *resolver.Resolver
} }
// NewHandler creates a new message handler. // New creates a new message handler.
func NewHandler(apiURL string) *Handler { func New(apiURL string) *Handler {
return &Handler{ return &Handler{
detector: detector.New(), detector: detector.New(),
resolver: resolver.New(apiURL), resolver: resolver.New(apiURL),
} }
} }
// Handle processes a message and returns a response if music links were found. // HandleText processes raw text and returns a response string when links are found.
func (h *Handler) Handle(ctx context.Context, msg Message) *Message { func (h *Handler) HandleText(ctx context.Context, text, username string) (string, bool) {
// Detect music links in the message // Detect music links in the message
links := h.detector.Detect(msg.Text) links := h.detector.Detect(text)
if len(links) == 0 { 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 // Process the first link found
link := links[0] 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) resolved, err := h.resolver.Resolve(ctx, link.URL)
if err != nil { if err != nil {
log.Printf("Error resolving link %s: %v", link.URL, err) 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)) log.Printf("Resolver returned for %s (found %d links)", link.URL, len(resolved.Links))
// Only respond if we found links on other services // Only respond if we found links on other services
if len(resolved.Links) <= 1 { if len(resolved.Links) <= 1 {
log.Printf("No additional links found for %s", link.URL) log.Printf("No additional links found for %s", link.URL)
return nil return "", false
} }
// Format the response // Format the response
@ -55,11 +55,8 @@ func (h *Handler) Handle(ctx context.Context, msg Message) *Message {
if resolved.Track != nil { if resolved.Track != nil {
title = resolved.Track.Title title = resolved.Track.Title
} }
text := resolver.Format(resolved, title) response := resolver.Format(resolved, title)
log.Printf("Sending response for %s", link.URL) log.Printf("Sending response for %s", link.URL)
return &Message{ return response, true
Text: text,
Gateway: msg.Gateway,
}
} }

361
internal/matrixbot/bot.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings"
"time" "time"
) )
@ -76,6 +78,10 @@ func (c *Client) Resolve(ctx context.Context, link string) (*APIResponse, error)
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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) return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
} }
@ -153,3 +159,16 @@ func apiTypeToServiceType(t string) (ServiceType, bool) {
return "", false 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
}

View file

@ -10,16 +10,18 @@ import (
// Config holds the complete application configuration. // Config holds the complete application configuration.
type Config struct { type Config struct {
Matterbridge MatterbridgeConfig `toml:"matterbridge"` Matrix MatrixConfig `toml:"matrix"`
} }
// MatterbridgeConfig holds matterbridge connection settings. // MatrixConfig holds Matrix-native bot settings.
type MatterbridgeConfig struct { type MatrixConfig struct {
URL string `toml:"url"` // WebSocket URL, e.g., "ws://localhost:4242/api/websocket" Shadow bool `toml:"shadow"` // Shadow mode (log only)
Token string `toml:"token"` // API token for authentication HealthAddr string `toml:"healthAddr"` // Health server listen address
Gateway string `toml:"gateway"` // Gateway name to send messages to Server string `toml:"server"` // Homeserver base URL
Username string `toml:"username"` // Bot username shown in messages AccessToken string `toml:"accessToken"` // Access token for bot user
Avatar string `toml:"avatar"` // Avatar URL for the bot 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. // 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. // Validate checks that required configuration fields are set.
func (c *Config) Validate() error { func (c *Config) Validate() error {
if c.Matterbridge.URL == "" { if c.Matrix.Server == "" {
return fmt.Errorf("matterbridge.url is required") return fmt.Errorf("matrix.server is required")
} }
if c.Matterbridge.Gateway == "" { if c.Matrix.AccessToken == "" {
return fmt.Errorf("matterbridge.gateway is required") return fmt.Errorf("matrix.accessToken is required")
} }
if c.Matterbridge.Username == "" { if c.Matrix.UserID == "" {
c.Matterbridge.Username = "MusicLink" 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 return nil
} }