From 46d0e984243b43fbf5da48d0947f8e4198262181 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 29 Oct 2026 12:51:56 -0400 Subject: [PATCH] voice-bridge: Discord voice <-> Lotus voice room (patched nether-voicebridge) Production setup for the Discord voice bridge on LXC 151: config and appservice-registration templates (secrets rendered on the server), an idempotent install script (one Synapse restart on first install, creates the bot account so it can be invited), and a hardened systemd unit. voice-limit-guard: VOICE_LIMIT_EXEMPT_USERS lets the bridge's own bot listen in a call without taking a slot; relayed Discord speakers still count. Unit tests added (61 pass); verified live on the dev stack. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- README.md | 34 ++++++++++ livekit/test_voice_limit_guard.py | 37 +++++++++++ livekit/voice-limit-guard.py | 20 +++++- systemd/lotus-voice-bridge.service | 32 +++++++++ systemd/voice-limit-guard.service | 2 + voice-bridge/config.toml.template | 34 ++++++++++ voice-bridge/install.sh | 87 +++++++++++++++++++++++++ voice-bridge/registration.yaml.template | 15 +++++ 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 systemd/lotus-voice-bridge.service create mode 100644 voice-bridge/config.toml.template create mode 100755 voice-bridge/install.sh create mode 100644 voice-bridge/registration.yaml.template diff --git a/README.md b/README.md index f2550f3..fa56979 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,40 @@ while the bridge or Synapse was down are bridged when it starts again. --- +## Voice Bridge (nether-voicebridge, Lotus-patched) + +Bridges Discord voice into a Lotus voice room. People on Discord appear **inside the Matrix call** +under their Discord names, with a speaking indicator. People in Lotus appear in the Discord channel +as `[Matrix] `, with mute mirrored to Discord. + +| | Text | Voice | +|---|---|---| +| Discord (Lotus Guild server) | #matrix-bridge | matrix-bridge | +| Matrix (Lotus) | Discord Bridge (mautrix-discord) | Discord Voice Bridge (`!yS_ZIHL3sOKQJBt4NiIQyiHKt3cv6HrIDeq7OxDpSM0`) | + +- **Build:** upstream [nether-voicebridge](https://nether.codes/nether/nether-voicebridge) `cf52945` + plus 11 Lotus patches (reported upstream). Patches, bug log and issue drafts live in + `/root/code/voice-bridge-trial/`. **Don't run the unpatched release**. Without the patches the + bridge can't see anyone using current Element Call or Lotus Call (`livekit_alias`), and encrypted + voice breaks after a restart on Synapse. +- **Accounts:** the bot is `@lotus-voice_bot`, shown in encrypted calls as "Discord Voice Bridge". + Discord speakers are `@lotus-voice_`. The appservice registration is + `/etc/matrix-synapse/lotus-voice-registration.yaml`. +- **Discord bots:** Lotus Voice 1–3, one per Matrix person speaking at once. Each has only Connect, + Speak, Use Voice Activity and Change Nickname, and none can see #matrix-bridge text. +- **Files on LXC 151:** + - binary: `/opt/lotus-voice/lotus-voice-bridge` + - config: `/etc/lotus-voice/config.toml` (0600, holds the bot tokens) + - state: `/var/lib/lotus-voice` + - service: `lotus-voice-bridge`, logs via `journalctl -u lotus-voice-bridge` +- **Voice limits:** `voice-limit-guard` ignores `@lotus-voice_bot` (`VOICE_LIMIT_EXEMPT_USERS`), so + the bot never takes a slot. Discord speakers count like anyone else. When a room is full the + bridge stops trying cleanly, and the Discord user just isn't heard. +- **Install / upgrade:** on the Proxmox node hosting LXC 151, run + `bash voice-bridge/install.sh `. The first install + registers the appservice, restarts Synapse once and creates the bot account. Then invite + `@lotus-voice_bot:matrix.lotusguild.org` to the voice room. Upgrades just swap the binary. + ## Bot profiles Every bot account has a proper name and an avatar in one style (white glyph on a coloured square). Image sources are in `avatars/` and `discord-bridge/bot-avatar.png`. diff --git a/livekit/test_voice_limit_guard.py b/livekit/test_voice_limit_guard.py index 9093650..a1b19d3 100644 --- a/livekit/test_voice_limit_guard.py +++ b/livekit/test_voice_limit_guard.py @@ -495,3 +495,40 @@ class TestReconcileRoomLimit(unittest.TestCase): guard.livekit_list_participants = lambda a: (_ for _ in ()).throw(AssertionError("listed")) guard.reconcile_room("alias", "!room:x") self.assertEqual(self.removed, []) + + +class LimitExemptTests(unittest.TestCase): + """A voice bridge's bot listens in the call but isn't a person.""" + + BOT = "@lotus-voice_bot:matrix.lotusguild.org" + GHOST = "@lotus-voice_1565220670265360514:matrix.lotusguild.org" + + def setUp(self): + self._saved = guard.LIMIT_EXEMPT + guard.LIMIT_EXEMPT = guard.re.compile(r"@lotus-voice_bot:matrix\.lotusguild\.org") + + def tearDown(self): + guard.LIMIT_EXEMPT = self._saved + + def test_bot_does_not_use_a_slot(self): + self.assertFalse(guard.should_block(2, {"@a:x", self.BOT}, "@b:x")) + self.assertTrue(guard.should_block(2, {"@a:x", "@c:x", self.BOT}, "@b:x")) + + def test_bot_is_never_blocked(self): + self.assertFalse(guard.should_block(1, {"@a:x"}, self.BOT)) + + def test_relayed_discord_speakers_still_count(self): + self.assertTrue(guard.should_block(2, {"@a:x", self.GHOST}, "@b:x")) + + def test_reconcile_never_removes_or_counts_the_bot(self): + parts = [ + {"identity": "@a:x:D1", "joined_at_ms": 1}, + {"identity": self.BOT + ":voice-1", "joined_at_ms": 3}, + ] + removals, users = guard.over_limit_removals(parts, 1, {"@a:x"}) + self.assertEqual(removals, []) + self.assertEqual(users, {"@a:x"}) + + def test_no_exemption_configured_counts_everyone(self): + guard.LIMIT_EXEMPT = None + self.assertTrue(guard.should_block(2, {"@a:x", self.BOT}, "@b:x")) diff --git a/livekit/voice-limit-guard.py b/livekit/voice-limit-guard.py index 806cb22..ad7b529 100644 --- a/livekit/voice-limit-guard.py +++ b/livekit/voice-limit-guard.py @@ -52,6 +52,7 @@ import hashlib import hmac import json import os +import re import threading import time import urllib.error @@ -72,6 +73,11 @@ MATRIX_TOKEN = os.environ.get("MATRIX_TOKEN", "") # from participants who joined BEFORE a policy tightened. New joins are already # handled by the JWT re-sign at issue time; this closes the mid-call-flip gap. RECONCILE_ENABLED = os.environ.get("GUARD_RECONCILE", "1") not in ("0", "false", "") +# Matrix users the participant limit ignores (full-match regex), e.g. a voice +# bridge's own bot, which joins the call to listen but is not a person. The +# Discord speakers the bridge relays (its ghost users) are people and still count. +_EXEMPT = os.environ.get("VOICE_LIMIT_EXEMPT_USERS", "") +LIMIT_EXEMPT = re.compile(_EXEMPT) if _EXEMPT else None # Floor the interval so a misconfigured 0 can't busy-loop the admin/Synapse APIs. RECONCILE_INTERVAL = max(0.5, float(os.environ.get("GUARD_RECONCILE_INTERVAL", "3.0"))) @@ -131,18 +137,26 @@ def room_id_from_request(path: str, data: dict) -> str: return data.get("room") or "" +def limit_exempt(user: str) -> bool: + """True for users the participant limit ignores (VOICE_LIMIT_EXEMPT_USERS).""" + return bool(LIMIT_EXEMPT and user and LIMIT_EXEMPT.fullmatch(user)) + + def should_block(limit: int, present_users: set, requester: str) -> bool: """Pure decision: should this token be refused? - limit <= 0 -> never block (no limit configured) + - requester exempt -> never block (e.g. a bridge's bot) - requester already present -> never block (rejoin / extra device) - - distinct users >= limit -> block + - distinct non-exempt users >= limit -> block """ if limit <= 0: return False + if limit_exempt(requester): + return False if requester and requester in present_users: return False - return len(present_users) >= limit + return len({u for u in present_users if not limit_exempt(u)}) >= limit # [matrix #25] Tokens issued but not yet connected weren't counted, so two @@ -480,7 +494,7 @@ def over_limit_removals(participants: list, limit: int, seen): for p in participants: identity = p.get("identity") or "" user = matrix_user(identity) - if not user: + if not user or limit_exempt(user): continue identities.setdefault(user, []).append(identity) joined = _joined_at(p) diff --git a/systemd/lotus-voice-bridge.service b/systemd/lotus-voice-bridge.service new file mode 100644 index 0000000..554c573 --- /dev/null +++ b/systemd/lotus-voice-bridge.service @@ -0,0 +1,32 @@ +[Unit] +Description=Lotus voice bridge (Discord voice <-> Matrix calls, nether-voicebridge) +After=network-online.target matrix-synapse.service +Wants=network-online.target + +[Service] +Type=simple +User=lotus-voice +Group=lotus-voice +Environment=CONFIG_PATH=/etc/lotus-voice/config.toml +Environment=RUST_LOG=info +ExecStart=/opt/lotus-voice/lotus-voice-bridge +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=lotus-voice-bridge + +# Resource limits (Opus encode/decode + LiveKit + Discord voice per call) +MemoryMax=1G +CPUQuota=150% + +# Hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/var/lib/lotus-voice /etc/lotus-voice + +[Install] +WantedBy=multi-user.target diff --git a/systemd/voice-limit-guard.service b/systemd/voice-limit-guard.service index 4e6d9ce..db1ae7e 100644 --- a/systemd/voice-limit-guard.service +++ b/systemd/voice-limit-guard.service @@ -19,6 +19,8 @@ Environment=GUARD_BIND_PORT=8070 Environment=GUARD_UPSTREAM=http://127.0.0.1:8071 Environment=LIVEKIT_API=http://127.0.0.1:7880 Environment=SYNAPSE_API=http://127.0.0.1:8008 +# The voice bridge's bot listens in calls but is not a person (Discord speakers still count). +Environment=VOICE_LIMIT_EXEMPT_USERS=@lotus-voice_bot:matrix\\.lotusguild\\.org [Install] WantedBy=multi-user.target diff --git a/voice-bridge/config.toml.template b/voice-bridge/config.toml.template new file mode 100644 index 0000000..9c99b33 --- /dev/null +++ b/voice-bridge/config.toml.template @@ -0,0 +1,34 @@ +# Lotus Guild voice bridge (nether-voicebridge, Lotus-patched build) — LXC 151. +# Rendered by voice-bridge/install.sh into /etc/lotus-voice/config.toml (0600). +# @AS_TOKEN@ and @PUPPET_TOKENS@ are filled in on the server; never commit them. + +data_dir = "/var/lib/lotus-voice" + +[server.lotus] +homeserver_url = "http://127.0.0.1:8008" +# MXIDs and .well-known discovery (https://matrix.lotusguild.org) use this domain. +matrix_domain = "matrix.lotusguild.org" +# Bot = @lotus-voice_bot, Discord speakers = @lotus-voice_. +sender_localpart = "lotus-voice" +appservice_id = "lotus-voice" +appservice_token = "@AS_TOKEN@" + +[puppets] +# Lotus Voice 1–3 (Discord apps 1565219639640858746 / 1565220427297722378 / +# 1565220532830470207). One per Matrix person speaking at the same time. +tokens = [@PUPPET_TOKENS@] +nickname_prefix = "[Matrix] " + +[[bridge]] +name = "discord-voice-bridge" +server = "lotus" +# Matrix: "Discord Voice Bridge" voice room. +room_id = "!yS_ZIHL3sOKQJBt4NiIQyiHKt3cv6HrIDeq7OxDpSM0" +[bridge.discord] +# Lotus Guild server, voice channel "matrix-bridge". +guild_id = "1565159371355197570" +channel_id = "1565159372122882071" +[bridge.matrix] +# Shown as a call participant in encrypted calls (it receives the media keys). +bot_display_name = "Discord Voice Bridge" +bot_avatar_path = "/opt/lotus-voice/avatar.png" diff --git a/voice-bridge/install.sh b/voice-bridge/install.sh new file mode 100755 index 0000000..2e81449 --- /dev/null +++ b/voice-bridge/install.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# One-time install / upgrade of the Lotus voice bridge on LXC 151. +# Run on the Proxmox node hosting LXC 151 (and LXC 109 is not needed: the bridge +# keeps its state in sqlite under /var/lib/lotus-voice): +# +# bash install.sh +# +# The secrets file holds the three Lotus Voice bot tokens as BOT1=…, BOT2=…, BOT3=… +# (shell syntax); it is copied in, used to render the config, then deleted. +# Needs this repo's main pulled to /opt/matrix-config on LXC 151 (auto-deploy). +# First install restarts Synapse once (new appservice registration). Re-running +# with only a new binary just swaps it and restarts the bridge. Safe to re-run. +set -euo pipefail + +BIN=${1:?usage: install.sh } +SECRETS=${2:?usage: install.sh } +CT=151 +[ -x "$BIN" ] || { echo "binary not found/executable: $BIN" >&2; exit 1; } + +echo "== [1/4] copy binary + secrets into LXC $CT" +pct push "$CT" "$BIN" /root/lotus-voice-bridge.new --perms 0755 +pct push "$CT" "$SECRETS" /root/lotus-voice.secrets --perms 0600 + +echo "== [2/4] install files, user, config, registration" +pct exec "$CT" -- bash -s <<'IN' +set -euo pipefail +SRC=/opt/matrix-config +id lotus-voice >/dev/null 2>&1 || useradd --system --home /var/lib/lotus-voice --shell /usr/sbin/nologin lotus-voice +install -d -o lotus-voice -g lotus-voice -m 0750 /var/lib/lotus-voice /etc/lotus-voice +install -d -m 0755 /opt/lotus-voice +mv /root/lotus-voice-bridge.new /opt/lotus-voice/lotus-voice-bridge +install -m 0644 "$SRC/discord-bridge/bot-avatar.png" /opt/lotus-voice/avatar.png + +REG=/etc/matrix-synapse/lotus-voice-registration.yaml +if [ ! -f "$REG" ]; then + AS=$(openssl rand -hex 32); HS=$(openssl rand -hex 32) + sed -e "s/@AS_TOKEN@/$AS/" -e "s/@HS_TOKEN@/$HS/" "$SRC/voice-bridge/registration.yaml.template" > "$REG" + chown root:matrix-synapse "$REG"; chmod 0640 "$REG" + echo "registration written" +fi +AS=$(awk '/^as_token:/ {gsub(/"/, "", $2); print $2; exit}' "$REG") + +set -a; . /root/lotus-voice.secrets; set +a +rm -f /root/lotus-voice.secrets +for v in BOT1 BOT2 BOT3; do [ -n "${!v:-}" ] || { echo "secrets file is missing $v" >&2; exit 1; }; done +TOKENS="\"$BOT1\", \"$BOT2\", \"$BOT3\"" +umask 077 +sed -e "s/@AS_TOKEN@/$AS/" -e "s|@PUPPET_TOKENS@|$TOKENS|" "$SRC/voice-bridge/config.toml.template" > /etc/lotus-voice/config.toml +unset BOT1 BOT2 BOT3 TOKENS +chown lotus-voice:lotus-voice /etc/lotus-voice/config.toml +install -m 0644 "$SRC/systemd/lotus-voice-bridge.service" /etc/systemd/system/lotus-voice-bridge.service +systemctl daemon-reload +echo "files ok" +IN + +echo "== [3/4] register with Synapse (restart only if newly added)" +pct exec "$CT" -- bash -s <<'RS' +set -euo pipefail +HSY=/etc/matrix-synapse/homeserver.yaml +if grep -q lotus-voice-registration.yaml "$HSY"; then + echo "already registered — no Synapse restart" +else + cp "$HSY" "$HSY.bak-$(date +%Y%m%d%H%M%S)-voicebridge" + sed -i '/^ - \/etc\/matrix-synapse\/discord-registration.yaml/a \ - /etc/matrix-synapse/lotus-voice-registration.yaml' "$HSY" + grep -A4 '^app_service_config_files' "$HSY" + systemctl restart matrix-synapse + for i in $(seq 1 60); do curl -fsS -o /dev/null http://127.0.0.1:8008/health && break; sleep 2; done + curl -fsS http://127.0.0.1:8008/health && echo " (synapse healthy)" +fi +# Create the bot account now so it can be invited before the bridge first starts. +AS=$(awk '/^as_token:/ {gsub(/"/, "", $2); print $2; exit}' /etc/matrix-synapse/lotus-voice-registration.yaml) +curl -sS -X POST -H "Authorization: Bearer $AS" -H 'Content-Type: application/json' \ + -d '{"type":"m.login.application_service","username":"lotus-voice_bot","inhibit_login":true}' \ + http://127.0.0.1:8008/_matrix/client/v3/register | grep -qE 'user_id|M_USER_IN_USE' && echo "bot account @lotus-voice_bot ready" +RS + +echo "== [4/4] (re)start the bridge" +pct exec "$CT" -- bash -s <<'ST' +set -euo pipefail +systemctl enable lotus-voice-bridge >/dev/null 2>&1 +systemctl restart lotus-voice-bridge +sleep 8 +systemctl --no-pager --lines=0 status lotus-voice-bridge | head -5 +journalctl -u lotus-voice-bridge --since "-15 sec" --no-pager -o cat | grep -E "puppet connected|initial call snapshot|LiveKit focus|bridge failed|ERROR" | sed -E 's/\x1b\[[0-9;]*m//g' | cut -c1-160 | head -10 +ST +echo +echo "Done. In Lotus, invite @lotus-voice_bot:matrix.lotusguild.org to the Discord Voice Bridge room." diff --git a/voice-bridge/registration.yaml.template b/voice-bridge/registration.yaml.template new file mode 100644 index 0000000..62f3c6d --- /dev/null +++ b/voice-bridge/registration.yaml.template @@ -0,0 +1,15 @@ +# Synapse appservice registration for the voice bridge. Rendered by install.sh to +# /etc/matrix-synapse/lotus-voice-registration.yaml with fresh random tokens. +# The bridge talks to Synapse only as a client (as_token + ?user_id=), so no URL. +id: lotus-voice +url: null +as_token: "@AS_TOKEN@" +hs_token: "@HS_TOKEN@" +sender_localpart: lotus-voice_as +namespaces: + users: + - exclusive: true + regex: "@lotus-voice_.*:matrix\\.lotusguild\\.org" + aliases: [] + rooms: [] +rate_limited: false -- 2.47.3