Deprecate matterbridge and fix matrix issues
This commit is contained in:
parent
4a01f7ad77
commit
95a70d8b86
35
README.md
35
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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...")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
39
docs/approach/2026-01-21-matrix-native-routing.md
Normal file
39
docs/approach/2026-01-21-matrix-native-routing.md
Normal 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
29
docs/code-review-plan.md
Normal 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.
|
||||||
81
docs/design-matrix-native-routing.md
Normal file
81
docs/design-matrix-native-routing.md
Normal 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.
|
||||||
22
docs/intent/2026-01-21-matrix-native-routing.md
Normal file
22
docs/intent/2026-01-21-matrix-native-routing.md
Normal 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 MusicLink’s core feature set.
|
||||||
|
* Relying on multi-gateway Matterbridge setups for routing correctness.
|
||||||
|
|
@ -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`
|
|
||||||
37
docs/reviews/bot-transport.md
Normal file
37
docs/reviews/bot-transport.md
Normal 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 doesn’t propagate to run loop; reconnect relies on read loop exiting.
|
||||||
24
docs/reviews/cli-entrypoints.md
Normal file
24
docs/reviews/cli-entrypoints.md
Normal 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
31
docs/reviews/config.md
Normal 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 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.
|
||||||
28
docs/reviews/docs.md
Normal file
28
docs/reviews/docs.md
Normal 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
32
docs/reviews/infra.md
Normal 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.
|
||||||
40
docs/reviews/issues-status.md
Normal file
40
docs/reviews/issues-status.md
Normal 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
22
docs/reviews/issues.md
Normal 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.
|
||||||
37
docs/reviews/message-handling.md
Normal file
37
docs/reviews/message-handling.md
Normal 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 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).
|
||||||
12
docs/work/2026-01-21-code-review-issues.md
Normal file
12
docs/work/2026-01-21-code-review-issues.md
Normal 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.
|
||||||
18
docs/work/2026-01-21-code-review.md
Normal file
18
docs/work/2026-01-21-code-review.md
Normal 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.
|
||||||
14
docs/work/2026-01-21-issues-fixes.md
Normal file
14
docs/work/2026-01-21-issues-fixes.md
Normal 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.
|
||||||
21
docs/work/2026-01-21-matrix-native-routing.md
Normal file
21
docs/work/2026-01-21-matrix-native-routing.md
Normal 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 ./...`.
|
||||||
|
|
@ -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
29
go.mod
|
|
@ -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
98
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 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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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+|` +
|
||||||
|
|
|
||||||
|
|
@ -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
361
internal/matrixbot/bot.go
Normal 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
|
||||||
|
}
|
||||||
83
internal/matrixbot/health.go
Normal file
83
internal/matrixbot/health.go
Normal 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)
|
||||||
|
}
|
||||||
184
internal/matrixbot/state_store.go
Normal file
184
internal/matrixbot/state_store.go
Normal 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
|
||||||
|
}
|
||||||
99
internal/matrixbot/stats.go
Normal file
99
internal/matrixbot/stats.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue