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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user