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