voice-bridge: Discord voice <-> Lotus voice room (patched nether-voicebridge)
Lint / Shell (shellcheck) (push) Successful in 2m0s
Lint / JS (eslint) (push) Successful in 2m1s
Lint / No secrets in webhook configs (push) Successful in 18s
Lint / Landing page is rendered (matrix (push) Successful in 18s
Lint / Python (ruff) (push) Successful in 25s
Lint / Secret scan (gitleaks) (push) Successful in 14s
Lint / Shell (shellcheck) (pull_request) Successful in 9s
Lint / JS (eslint) (pull_request) Successful in 9s
Lint / No secrets in webhook configs (pull_request) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 1m7s
Lint / Landing page is rendered (matrix (pull_request) Successful in 4s
Lint / Python (ruff) (pull_request) Successful in 6s
Lint / Secret scan (gitleaks) (pull_request) Successful in 6s
Lint / Python deps (pip-audit) (pull_request) Successful in 30s
Lint / Shell (shellcheck) (push) Successful in 2m0s
Lint / JS (eslint) (push) Successful in 2m1s
Lint / No secrets in webhook configs (push) Successful in 18s
Lint / Landing page is rendered (matrix (push) Successful in 18s
Lint / Python (ruff) (push) Successful in 25s
Lint / Secret scan (gitleaks) (push) Successful in 14s
Lint / Shell (shellcheck) (pull_request) Successful in 9s
Lint / JS (eslint) (pull_request) Successful in 9s
Lint / No secrets in webhook configs (pull_request) Successful in 7s
Lint / Python deps (pip-audit) (push) Successful in 1m7s
Lint / Landing page is rendered (matrix (pull_request) Successful in 4s
Lint / Python (ruff) (pull_request) Successful in 6s
Lint / Secret scan (gitleaks) (pull_request) Successful in 6s
Lint / Python deps (pip-audit) (pull_request) Successful in 30s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
db2f523028
commit
46d0e98424
@@ -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] <name>`, 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_<discord id>`. 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 <binary> <secrets file with BOT1..BOT3>`. 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`.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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_<discord id>.
|
||||
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"
|
||||
Executable
+87
@@ -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 <path to lotus-voice-bridge binary> <secrets file>
|
||||
#
|
||||
# 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 <binary> <secrets file>}
|
||||
SECRETS=${2:?usage: install.sh <binary> <secrets file>}
|
||||
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."
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user