feat: auto-resume music on reconnect, sorcerer tier, cert auth

Auto-resume: save playback position on stream errors and cancellation,
restore automatically after reconnect or container restart once the
channel is silent. Plugin lifecycle hook (on_connected) ensures the
reconnect watcher starts without waiting for user commands.

Sorcerer tier: new permission level between oper and admin. Configured
via [mumble] sorcerers list in derp.toml.

Mumble cert auth: pass certfile/keyfile to pymumble for client
certificate authentication.

Fixes: stream_audio now re-raises CancelledError and Exception so
_play_loop detects failures correctly. Subprocess cleanup uses 3s
timeout. Graceful shutdown cancels background tasks before stopping
pymumble. Safe getattr for _opers in core plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 02:14:43 +01:00
parent f899241d73
commit ec55c2aef1
10 changed files with 764 additions and 16 deletions

View File

@@ -107,7 +107,7 @@ def _msg(text: str, prefix: str = "nick!user@host") -> Message:
class TestTierConstants:
def test_tier_order(self):
assert TIERS == ("user", "trusted", "oper", "admin")
assert TIERS == ("user", "trusted", "oper", "sorcerer", "admin")
def test_index_comparison(self):
assert TIERS.index("user") < TIERS.index("trusted")

View File

@@ -3,6 +3,7 @@
import asyncio
import importlib.util
import sys
import time
from unittest.mock import AsyncMock, MagicMock, patch
# -- Load plugin module directly ---------------------------------------------
@@ -40,6 +41,7 @@ class _FakeBot:
self.sent: list[tuple[str, str]] = []
self.replied: list[str] = []
self.state = _FakeState()
self.config: dict = {}
self._pstate: dict = {}
self._tasks: set[asyncio.Task] = set()
if mumble:
@@ -648,3 +650,359 @@ class TestFmtTime:
def test_large_value(self):
assert _mod._fmt_time(3661) == "61:01"
# ---------------------------------------------------------------------------
# TestDuckCommand
# ---------------------------------------------------------------------------
class TestDuckCommand:
def test_show_status(self):
bot = _FakeBot()
msg = _Msg(text="!duck")
asyncio.run(_mod.cmd_duck(bot, msg))
assert any("Duck:" in r for r in bot.replied)
assert any("floor=1%" in r for r in bot.replied)
assert any("restore=30s" in r for r in bot.replied)
def test_toggle_on(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = False
msg = _Msg(text="!duck on")
asyncio.run(_mod.cmd_duck(bot, msg))
assert ps["duck_enabled"] is True
assert any("enabled" in r for r in bot.replied)
def test_toggle_off(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_vol"] = 5.0
msg = _Msg(text="!duck off")
asyncio.run(_mod.cmd_duck(bot, msg))
assert ps["duck_enabled"] is False
assert ps["duck_vol"] is None
assert any("disabled" in r for r in bot.replied)
def test_set_floor(self):
bot = _FakeBot()
msg = _Msg(text="!duck floor 10")
asyncio.run(_mod.cmd_duck(bot, msg))
ps = _mod._ps(bot)
assert ps["duck_floor"] == 10
assert any("10%" in r for r in bot.replied)
def test_set_floor_invalid(self):
bot = _FakeBot()
msg = _Msg(text="!duck floor 200")
asyncio.run(_mod.cmd_duck(bot, msg))
assert any("0-100" in r for r in bot.replied)
def test_set_silence(self):
bot = _FakeBot()
msg = _Msg(text="!duck silence 30")
asyncio.run(_mod.cmd_duck(bot, msg))
ps = _mod._ps(bot)
assert ps["duck_silence"] == 30
assert any("30s" in r for r in bot.replied)
def test_set_restore(self):
bot = _FakeBot()
msg = _Msg(text="!duck restore 45")
asyncio.run(_mod.cmd_duck(bot, msg))
ps = _mod._ps(bot)
assert ps["duck_restore"] == 45
assert any("45s" in r for r in bot.replied)
def test_non_mumble(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!duck")
asyncio.run(_mod.cmd_duck(bot, msg))
assert any("Mumble-only" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestDuckMonitor
# ---------------------------------------------------------------------------
class TestDuckMonitor:
def test_voice_detected_ducks_to_floor(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_floor"] = 5
bot._last_voice_ts = time.monotonic()
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
await asyncio.sleep(1.5)
assert ps["duck_vol"] == 5.0
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_silence_begins_smooth_restore(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_floor"] = 1
ps["duck_restore"] = 10 # 10s total restore
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
ps["duck_vol"] = 1.0 # already ducked
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
await asyncio.sleep(1.5)
# After ~1s into a 10s ramp from 1->50, vol should be ~5-6
vol = ps["duck_vol"]
assert vol is not None and vol > 1.0
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_full_restore_sets_none(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_floor"] = 1
ps["duck_restore"] = 1 # 1s restore -- completes quickly
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
ps["duck_vol"] = 1.0
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
# First tick starts restore, second tick sees elapsed >= dur
await asyncio.sleep(2.5)
assert ps["duck_vol"] is None
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_reduck_during_restore(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = True
ps["duck_floor"] = 5
ps["duck_restore"] = 30
ps["volume"] = 50
bot._last_voice_ts = time.monotonic() - 100
ps["duck_vol"] = 30.0 # mid-restore
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
await asyncio.sleep(0.5)
# Simulate voice arriving now
bot._last_voice_ts = time.monotonic()
await asyncio.sleep(1.5)
assert ps["duck_vol"] == 5.0 # re-ducked to floor
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_disabled_no_ducking(self):
bot = _FakeBot()
ps = _mod._ps(bot)
ps["duck_enabled"] = False
bot._last_voice_ts = time.monotonic()
async def _check():
task = asyncio.create_task(_mod._duck_monitor(bot))
await asyncio.sleep(1.5)
assert ps["duck_vol"] is None
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
# ---------------------------------------------------------------------------
# TestAutoResume
# ---------------------------------------------------------------------------
class TestAutoResume:
def test_resume_on_silence(self):
"""Auto-resume loads saved state when channel is silent."""
bot = _FakeBot()
bot._connect_count = 2
bot._last_voice_ts = 0.0
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 120.0)
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._auto_resume(bot))
mock_loop.assert_called_once_with(bot, seek=120.0)
ps = _mod._ps(bot)
assert len(ps["queue"]) == 1
assert ps["queue"][0].url == "https://example.com/a"
# Resume state cleared after loading
assert _mod._load_resume(bot) is None
def test_no_resume_if_playing(self):
"""Auto-resume returns early when already playing."""
bot = _FakeBot()
ps = _mod._ps(bot)
ps["current"] = _mod._Track(url="x", title="Playing", requester="a")
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 60.0)
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._auto_resume(bot))
mock_loop.assert_not_called()
def test_no_resume_if_no_state(self):
"""Auto-resume returns early when nothing is saved."""
bot = _FakeBot()
bot._last_voice_ts = 0.0
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod._auto_resume(bot))
mock_loop.assert_not_called()
def test_abort_if_voice_active(self):
"""Auto-resume aborts if voice never goes silent within deadline."""
bot = _FakeBot()
now = time.monotonic()
bot._last_voice_ts = now
ps = _mod._ps(bot)
ps["duck_silence"] = 15
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 60.0)
async def _check():
# Patch monotonic to jump past the 60s deadline; keep voice active
mono_val = [now]
_real_sleep = asyncio.sleep
def _fast_mono():
return mono_val[0]
async def _fast_sleep(s):
mono_val[0] += s
bot._last_voice_ts = mono_val[0]
await _real_sleep(0)
with patch.object(time, "monotonic", side_effect=_fast_mono):
with patch("asyncio.sleep", side_effect=_fast_sleep):
with patch.object(_mod, "_ensure_loop") as mock_loop:
await _mod._auto_resume(bot)
mock_loop.assert_not_called()
asyncio.run(_check())
def test_reconnect_watcher_triggers_resume(self):
"""Watcher detects connect_count increment and calls _auto_resume."""
bot = _FakeBot()
bot._connect_count = 1
async def _check():
with patch.object(_mod, "_auto_resume", new_callable=AsyncMock) as mock_ar:
task = asyncio.create_task(_mod._reconnect_watcher(bot))
await asyncio.sleep(0.5)
# Simulate reconnection
bot._connect_count = 2
await asyncio.sleep(3)
mock_ar.assert_called_once_with(bot)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_watcher_ignores_first_connect(self):
"""Watcher does not trigger on initial connection (count 0->1) without saved state."""
bot = _FakeBot()
bot._connect_count = 0
async def _check():
with patch.object(_mod, "_auto_resume", new_callable=AsyncMock) as mock_ar:
task = asyncio.create_task(_mod._reconnect_watcher(bot))
await asyncio.sleep(0.5)
bot._connect_count = 1
await asyncio.sleep(3)
mock_ar.assert_not_called()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_watcher_boot_resume_with_saved_state(self):
"""Watcher triggers boot-resume on first connect when state exists."""
bot = _FakeBot()
bot._connect_count = 0
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
_mod._save_resume(bot, track, 30.0)
async def _check():
with patch.object(_mod, "_auto_resume", new_callable=AsyncMock) as mock_ar:
task = asyncio.create_task(_mod._reconnect_watcher(bot))
await asyncio.sleep(0.5)
bot._connect_count = 1
await asyncio.sleep(3)
mock_ar.assert_called_once_with(bot)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_check())
def test_on_connected_starts_watcher(self):
"""on_connected() starts the reconnect watcher task."""
bot = _FakeBot()
spawned = []
def fake_spawn(coro, *, name=None):
task = MagicMock()
task.done.return_value = False
spawned.append(name)
# Close the coroutine to avoid RuntimeWarning
coro.close()
return task
bot._spawn = fake_spawn
asyncio.run(_mod.on_connected(bot))
assert "music-reconnect-watcher" in spawned
ps = _mod._ps(bot)
assert ps["_watcher_task"] is not None
def test_on_connected_no_double_start(self):
"""on_connected() does not start a second watcher."""
bot = _FakeBot()
spawned = []
def fake_spawn(coro, *, name=None):
task = MagicMock()
task.done.return_value = False
spawned.append(name)
coro.close()
return task
bot._spawn = fake_spawn
asyncio.run(_mod.on_connected(bot))
asyncio.run(_mod.on_connected(bot))
assert spawned.count("music-reconnect-watcher") == 1