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
|
||||
|
||||
MusicLink is designed to work as a sidecar to **[Matterbridge](https://github.com/42wim/matterbridge)**.
|
||||
MusicLink is a Matrix-native bot that connects directly to a Matrix homeserver and replies in the originating room/thread. **E2EE rooms are not supported.**
|
||||
|
||||
* **Connectivity**: Connects to Matterbridge via its WebSocket API. This allows MusicLink to support any chat platform Matterbridge supports (Discord, Slack, Telegram, Matrix, IRC, etc.).
|
||||
* **Connectivity**: Connects directly to Matrix and supports multiple allowlisted rooms.
|
||||
* **Resolution**: Uses the [idonthavespotify](https://idonthavespotify.sjdonado.com/) API to convert links between services without requiring individual API keys.
|
||||
* **Privacy**: Does not store messages or user data. It only processes messages containing music links.
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ MusicLink is designed to work as a sidecar to **[Matterbridge](https://github.co
|
|||
### Prerequisites
|
||||
|
||||
* Go 1.22 or higher
|
||||
* A running instance of [Matterbridge](https://github.com/42wim/matterbridge) (if running in production)
|
||||
* A Matrix homeserver and bot access token
|
||||
|
||||
### Building
|
||||
|
||||
|
|
@ -30,13 +30,16 @@ go build -o musiclink ./cmd/musiclink
|
|||
cp config.example.toml config.toml
|
||||
```
|
||||
|
||||
2. Edit `config.toml` to match your Matterbridge WebSocket settings:
|
||||
2. Configure Matrix-native mode (replies in the originating room/thread; E2EE rooms not supported):
|
||||
```toml
|
||||
[matterbridge]
|
||||
url = "ws://localhost:4242/api/websocket"
|
||||
token = "your-matterbridge-api-token"
|
||||
gateway = "main" # The gateway name defined in your matterbridge.toml
|
||||
username = "MusicLink"
|
||||
[matrix]
|
||||
shadow = false # set true to log-only during validation
|
||||
healthAddr = ":8080" # optional health/metrics endpoint
|
||||
server = "https://matrix.example.com"
|
||||
accessToken = "your-matrix-access-token"
|
||||
userId = "@musiclink:example.com"
|
||||
rooms = ["!roomid:example.com"]
|
||||
stateStorePath = "data/matrix-state.db"
|
||||
```
|
||||
|
||||
### Running
|
||||
|
|
@ -45,6 +48,14 @@ go build -o musiclink ./cmd/musiclink
|
|||
./musiclink -config config.toml
|
||||
```
|
||||
|
||||
## Health and Metrics (Matrix-native)
|
||||
|
||||
If `matrix.healthAddr` is set, MusicLink exposes JSON health stats at `/healthz` (and `/metrics`). Example:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The project includes both unit tests and integration tests.
|
||||
|
|
@ -57,11 +68,7 @@ go test ./...
|
|||
|
||||
### Integration Tests
|
||||
|
||||
The integration tests (in `internal/bot/bot_integration_test.go`) mock both the Matterbridge WebSocket server and the music resolution API. This allows you to verify the bot's full logic flow without external dependencies.
|
||||
|
||||
```bash
|
||||
go test -v internal/bot/bot_integration_test.go
|
||||
```
|
||||
No Matrix integration tests are included yet; add them as needed.
|
||||
|
||||
### Smoke Test
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import (
|
|||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"musiclink/internal/bot"
|
||||
"musiclink/internal/handler"
|
||||
"musiclink/internal/matrixbot"
|
||||
"musiclink/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -23,10 +24,13 @@ func main() {
|
|||
}
|
||||
|
||||
// Create message handler
|
||||
handler := bot.NewHandler("")
|
||||
msgHandler := handler.New("")
|
||||
|
||||
// Create bot
|
||||
b := bot.New(cfg.Matterbridge, handler.Handle)
|
||||
mxBot, err := matrixbot.New(cfg.Matrix, msgHandler.HandleText)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create Matrix bot: %v", err)
|
||||
}
|
||||
|
||||
// Setup context with cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -43,10 +47,12 @@ func main() {
|
|||
|
||||
// Run the bot (connects and reconnects automatically)
|
||||
log.Println("MusicLink bot starting...")
|
||||
if err := b.Run(ctx); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Bot error: %v", err)
|
||||
if err := mxBot.Run(ctx); err != nil && err != context.Canceled {
|
||||
log.Fatalf("Matrix bot error: %v", err)
|
||||
}
|
||||
if err := mxBot.Close(); err != nil {
|
||||
log.Printf("Matrix bot close error: %v", err)
|
||||
}
|
||||
|
||||
b.Close()
|
||||
log.Println("Goodbye!")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== MusicLink Smoke Test ===\n")
|
||||
fmt.Println("=== MusicLink Smoke Test ===")
|
||||
|
||||
// Test 1: Detector
|
||||
fmt.Println("1. Testing link detection...")
|
||||
|
|
|
|||
|
|
@ -3,18 +3,26 @@
|
|||
# This bot uses the idonthavespotify API to convert music links
|
||||
# between streaming services. No API credentials needed!
|
||||
|
||||
[matterbridge]
|
||||
# WebSocket URL for matterbridge API bridge
|
||||
url = "ws://localhost:4242/api/websocket"
|
||||
[matrix]
|
||||
# Shadow mode (log responses without sending)
|
||||
shadow = false
|
||||
|
||||
# API token (must match matterbridge config)
|
||||
token = "your-matterbridge-api-token"
|
||||
# Optional health server address (ex: ":8080")
|
||||
healthAddr = ""
|
||||
|
||||
# Gateway name to send messages to
|
||||
gateway = "main"
|
||||
# Matrix homeserver base URL
|
||||
server = "https://matrix.example.com"
|
||||
|
||||
# Bot username shown in messages
|
||||
username = "MusicLink"
|
||||
# Access token for the bot user
|
||||
accessToken = "your-matrix-access-token"
|
||||
|
||||
# Avatar URL for the bot (optional)
|
||||
avatar = ""
|
||||
# Full Matrix user ID for the bot
|
||||
userId = "@musiclink:example.com"
|
||||
|
||||
# Allowlisted room IDs to monitor
|
||||
rooms = [
|
||||
"!roomid:example.com"
|
||||
]
|
||||
|
||||
# Path to state store for sync token + dedupe
|
||||
stateStorePath = "data/matrix-state.db"
|
||||
|
|
|
|||
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 = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
subPackages = [ "cmd/musiclink" ];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Music link converter bot for Matterbridge";
|
||||
description = "Matrix-native music link converter bot";
|
||||
homepage = "https://github.com/dan/musiclink";
|
||||
license = licenses.mit;
|
||||
maintainers = [ ];
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
go
|
||||
gopls
|
||||
gotools
|
||||
matterbridge
|
||||
];
|
||||
};
|
||||
}
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
systemd.services.musiclink = {
|
||||
description = "MusicLink Bot";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "matterbridge.service" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
|
|
|
|||
29
go.mod
29
go.mod
|
|
@ -1,8 +1,33 @@
|
|||
module musiclink
|
||||
|
||||
go 1.22.8
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
maunium.net/go/mautrix v0.26.2
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.mau.fi/util v0.9.5 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4=
|
||||
go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.26.2 h1:rLiZLQoSKCJDZ+mF1gBQS4p74h3jZXs83g8D4W6Te8g=
|
||||
maunium.net/go/mautrix v0.26.2/go.mod h1:CUxSZcjPtQNxsZLRQqETAxg2hiz7bjWT+L1HCYoMMKo=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`https?://(?:` +
|
||||
`(?:open\.)?spotify\.com/(?:track|album|artist|playlist)/[a-zA-Z0-9]+|` +
|
||||
`spoti\.fi/[a-zA-Z0-9]+|` +
|
||||
`(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|music\.youtube\.com/watch\?v=)[a-zA-Z0-9_-]{11}|` +
|
||||
`(?:music\.)?apple\.com/[a-z]{2}/(?:album|artist|playlist)/[^\s]+|` +
|
||||
`(?:music\.)?apple\.com/[a-z]{2}/(?:album|artist|playlist|song)/[^\s]+|` +
|
||||
`(?:www\.)?deezer\.com/(?:[a-z]{2}/)?(?:track|album|artist|playlist)/\d+|` +
|
||||
`(?:www\.)?soundcloud\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+|` +
|
||||
`(?:www\.)?tidal\.com/(?:browse/)?(?:track|album|artist|playlist)/\d+|` +
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package bot
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
|
@ -14,23 +14,23 @@ type Handler struct {
|
|||
resolver *resolver.Resolver
|
||||
}
|
||||
|
||||
// NewHandler creates a new message handler.
|
||||
func NewHandler(apiURL string) *Handler {
|
||||
// New creates a new message handler.
|
||||
func New(apiURL string) *Handler {
|
||||
return &Handler{
|
||||
detector: detector.New(),
|
||||
resolver: resolver.New(apiURL),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle processes a message and returns a response if music links were found.
|
||||
func (h *Handler) Handle(ctx context.Context, msg Message) *Message {
|
||||
// HandleText processes raw text and returns a response string when links are found.
|
||||
func (h *Handler) HandleText(ctx context.Context, text, username string) (string, bool) {
|
||||
// Detect music links in the message
|
||||
links := h.detector.Detect(msg.Text)
|
||||
links := h.detector.Detect(text)
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
return "", false
|
||||
}
|
||||
|
||||
log.Printf("Found %d music link(s) in message from %s: %s", len(links), msg.Username, links[0].URL)
|
||||
log.Printf("Found %d music link(s) in message from %s: %s", len(links), username, links[0].URL)
|
||||
|
||||
// Process the first link found
|
||||
link := links[0]
|
||||
|
|
@ -40,14 +40,14 @@ func (h *Handler) Handle(ctx context.Context, msg Message) *Message {
|
|||
resolved, err := h.resolver.Resolve(ctx, link.URL)
|
||||
if err != nil {
|
||||
log.Printf("Error resolving link %s: %v", link.URL, err)
|
||||
return nil
|
||||
return "", false
|
||||
}
|
||||
log.Printf("Resolver returned for %s (found %d links)", link.URL, len(resolved.Links))
|
||||
|
||||
// Only respond if we found links on other services
|
||||
if len(resolved.Links) <= 1 {
|
||||
log.Printf("No additional links found for %s", link.URL)
|
||||
return nil
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Format the response
|
||||
|
|
@ -55,11 +55,8 @@ func (h *Handler) Handle(ctx context.Context, msg Message) *Message {
|
|||
if resolved.Track != nil {
|
||||
title = resolved.Track.Title
|
||||
}
|
||||
text := resolver.Format(resolved, title)
|
||||
response := resolver.Format(resolved, title)
|
||||
log.Printf("Sending response for %s", link.URL)
|
||||
|
||||
return &Message{
|
||||
Text: text,
|
||||
Gateway: msg.Gateway,
|
||||
}
|
||||
return response, true
|
||||
}
|
||||
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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -76,6 +78,10 @@ func (c *Client) Resolve(ctx context.Context, link string) (*APIResponse, error)
|
|||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := readErrorBody(resp)
|
||||
if body != "" {
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
|
|
@ -153,3 +159,16 @@ func apiTypeToServiceType(t string) (ServiceType, bool) {
|
|||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func readErrorBody(resp *http.Response) string {
|
||||
const maxErrorBytes = 2048
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBytes))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.TrimSpace(string(data))
|
||||
if msg == "" {
|
||||
return ""
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ import (
|
|||
|
||||
// Config holds the complete application configuration.
|
||||
type Config struct {
|
||||
Matterbridge MatterbridgeConfig `toml:"matterbridge"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
}
|
||||
|
||||
// MatterbridgeConfig holds matterbridge connection settings.
|
||||
type MatterbridgeConfig struct {
|
||||
URL string `toml:"url"` // WebSocket URL, e.g., "ws://localhost:4242/api/websocket"
|
||||
Token string `toml:"token"` // API token for authentication
|
||||
Gateway string `toml:"gateway"` // Gateway name to send messages to
|
||||
Username string `toml:"username"` // Bot username shown in messages
|
||||
Avatar string `toml:"avatar"` // Avatar URL for the bot
|
||||
// MatrixConfig holds Matrix-native bot settings.
|
||||
type MatrixConfig struct {
|
||||
Shadow bool `toml:"shadow"` // Shadow mode (log only)
|
||||
HealthAddr string `toml:"healthAddr"` // Health server listen address
|
||||
Server string `toml:"server"` // Homeserver base URL
|
||||
AccessToken string `toml:"accessToken"` // Access token for bot user
|
||||
UserID string `toml:"userId"` // Full Matrix user ID
|
||||
Rooms []string `toml:"rooms"` // Allowlisted room IDs
|
||||
StateStorePath string `toml:"stateStorePath"` // Path to state store (sync token/dedupe)
|
||||
}
|
||||
|
||||
// Load reads and parses a TOML configuration file.
|
||||
|
|
@ -43,14 +45,20 @@ func Load(path string) (*Config, error) {
|
|||
|
||||
// Validate checks that required configuration fields are set.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Matterbridge.URL == "" {
|
||||
return fmt.Errorf("matterbridge.url is required")
|
||||
if c.Matrix.Server == "" {
|
||||
return fmt.Errorf("matrix.server is required")
|
||||
}
|
||||
if c.Matterbridge.Gateway == "" {
|
||||
return fmt.Errorf("matterbridge.gateway is required")
|
||||
if c.Matrix.AccessToken == "" {
|
||||
return fmt.Errorf("matrix.accessToken is required")
|
||||
}
|
||||
if c.Matterbridge.Username == "" {
|
||||
c.Matterbridge.Username = "MusicLink"
|
||||
if c.Matrix.UserID == "" {
|
||||
return fmt.Errorf("matrix.userId is required")
|
||||
}
|
||||
if len(c.Matrix.Rooms) == 0 {
|
||||
return fmt.Errorf("matrix.rooms must include at least one room")
|
||||
}
|
||||
if c.Matrix.StateStorePath == "" {
|
||||
c.Matrix.StateStorePath = "data/matrix-state.db"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue