- matrix: drop cryptohelper LoginAs so device.json creds aren't invalidated on every restart by Init's re-login - matrix: thread ctx through PostThreadedReply; bot dispatcher derives per-command ctx from app lifecycle so SIGTERM cancels in-flight work - matrix: isTokenValid verifies /whoami user_id matches configured one - matrix: loadDevice distinguishes missing file from corrupt parse; refuse to silently overwrite a corrupt device.json - matrix: Start checks Syncer type assertion and returns error - arr: parseLookup skips empty-title items and extracts existing id - bot: skip Add when Result.Exists; reply "already in library"
Bellhop
A Matrix bot that adds movies, TV, and music to Radarr/Sonarr/Lidarr from chat. Invite the bot to a room, allowlist the room ID, and any member can type !movie dune to add the top search hit.
Command UX
!movie <query> — add the top Radarr hit
!tv <query> — add the top Sonarr hit
!music <query> — add the top Lidarr hit
!help — show the command list
The bot replies in a thread under the request so a busy room stays readable. No numbered picker, no reaction selector — the top search result is what gets added. If you want something other than the top hit, narrow the query.
Authorization
Any member of an allowlisted room may issue commands. The bot auto-joins on invite, but the room ID must appear under matrix.allowed_rooms in the config before commands are honored.
Configuration
Copy config.example.yaml to config.yaml and edit. The loader expands ${ENV_VAR} references at load time, so secrets can come from the environment.
Required:
matrix.homeserver,matrix.user_id,matrix.passwordmatrix.allowed_rooms(at least one room ID)- At least one of
services.radarr/services.sonarr/services.lidarr
Per-service: url, api_key, quality_profile_id, root_folder. Lidarr also needs metadata_profile_id.
Omit a service block to disable its command — !movie with no radarr block replies "Radarr is not configured".
Finding quality profile IDs:
curl -H "X-Api-Key: YOUR_KEY" https://radarr.example.com/api/v3/qualityprofile
Running
Local
go build -tags goolm -o bellhop ./
./bellhop -config config.yaml
The goolm tag uses the pure-Go olm implementation so libolm isn't needed.
Docker
docker build -t bellhop .
docker run -d \
--name bellhop \
-v "$PWD/config.yaml:/app/config.yaml:ro" \
-v bellhop-data:/app/data \
bellhop
The data volume holds the Matrix device file and the E2EE crypto store. Losing it forces a re-login and re-verification on next start.
E2EE notes
The bot bootstraps cross-signing on first run and persists Olm/Megolm sessions in data/crypto.db. If you rotate the bot's password or wipe data/, the bot logs in as a new device — existing rooms will need to re-share keys, which mautrix handles automatically on the next message.
Project layout
main.go
internal/
config/ — YAML loader with ${ENV_VAR} expansion
matrix/ — mautrix client: login, device persistence, E2EE, sync loop
arr/ — Radarr/Sonarr/Lidarr HTTP clients
bot/ — command parser + dispatch + threaded replies
License
See repository for license details.