diff --git a/docs/USAGE.md b/docs/USAGE.md index 849a92b..04864fe 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1696,6 +1696,27 @@ file (natural dedup). - Use `!kept clear` to delete all preserved files - On cancel/error, files are not deleted (needed for `!resume`) +### Extra Mumble Bots + +Run additional bot identities on the same Mumble server. Each extra bot +inherits the main `[mumble]` connection settings and overrides only what +differs (username, certificates, greeting). Extra bots share the plugin +registry but get their own state DB and do **not** run the voice trigger +by default (prevents double-processing). + +```toml +[[mumble.extra]] +username = "merlin" +certfile = "secrets/mumble/merlin.crt" +keyfile = "secrets/mumble/merlin.key" +greet = "The sorcerer has arrived." +``` + +- `username`, `certfile`, `keyfile` -- identity overrides +- `greet` -- TTS message spoken on first connect (optional) +- All other `[mumble]` keys (host, port, password, admins, etc.) are inherited +- Voice trigger is disabled unless the extra entry includes a `voice` key + ### Voice STT/TTS Transcribe voice from Mumble users via Whisper STT and speak text aloud @@ -1723,6 +1744,7 @@ TTS behavior: - Piper outputs 22050Hz WAV; ffmpeg resamples to 48kHz automatically - TTS shares the audio output with music playback - Text is limited to 500 characters +- Set `greet` in `[mumble]` or `[[mumble.extra]]` for automatic TTS on first connect ### Always-On Trigger Mode diff --git a/plugins/voice.py b/plugins/voice.py index c7f29e5..e0ec8a4 100644 --- a/plugins/voice.py +++ b/plugins/voice.py @@ -321,10 +321,22 @@ async def cmd_say(bot, message): async def on_connected(bot) -> None: - """Re-register listener after reconnect if listen or trigger is active.""" + """Re-register listener after reconnect; play TTS greeting on first join.""" if not _is_mumble(bot): return ps = _ps(bot) + + # TTS greeting on first connect + greet = bot.config.get("mumble", {}).get("greet") + if greet and not ps.get("_greeted"): + ps["_greeted"] = True + # Wait for audio subsystem to be ready + for _ in range(20): + if bot._is_audio_ready(): + break + await asyncio.sleep(0.5) + bot._spawn(_tts_play(bot, greet), name="voice-greet") + if ps["listen"] or ps["trigger"]: _ensure_listener(bot) _ensure_flush_task(bot) diff --git a/src/derp/cli.py b/src/derp/cli.py index f9ad621..228349b 100644 --- a/src/derp/cli.py +++ b/src/derp/cli.py @@ -155,6 +155,20 @@ def main(argv: list[str] | None = None) -> int: mumble_bot = MumbleBot("mumble", config, registry) bots.append(mumble_bot) + # Additional Mumble bots (e.g. merlin) + for extra in config.get("mumble", {}).get("extra", []): + extra_cfg = dict(config) + merged_mu = dict(config["mumble"]) + merged_mu.update(extra) + merged_mu.pop("extra", None) + extra_cfg["mumble"] = merged_mu + # Extra bots don't run voice trigger by default + if "voice" not in extra: + extra_cfg["voice"] = {} + username = extra.get("username", f"mumble-{len(bots)}") + bot = MumbleBot(username, extra_cfg, registry) + bots.append(bot) + names = ", ".join(b.name for b in bots) log.info("servers: %s", names) diff --git a/tests/test_voice.py b/tests/test_voice.py index 4ab5b53..23046c0 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -724,3 +724,75 @@ class TestTriggerMode: msg = _Msg(text="!listen") asyncio.run(_mod.cmd_listen(bot, msg)) assert any("Trigger: claude" in r for r in bot.replied) + + +# --------------------------------------------------------------------------- +# TestGreeting +# --------------------------------------------------------------------------- + + +class TestGreeting: + def test_greet_on_first_connect(self): + """TTS greeting fires on first connect when configured.""" + bot = _FakeBot() + bot.config = {"mumble": {"greet": "Hello there."}} + bot._is_audio_ready = lambda: True + + spawned = [] + + def fake_spawn(coro, *, name=None): + spawned.append(name) + coro.close() + task = MagicMock() + task.done.return_value = False + return task + + bot._spawn = fake_spawn + asyncio.run(_mod.on_connected(bot)) + assert "voice-greet" in spawned + + def test_greet_only_once(self): + """Greeting fires only on first connect, not on reconnect.""" + bot = _FakeBot() + bot.config = {"mumble": {"greet": "Hello there."}} + bot._is_audio_ready = lambda: True + + spawned = [] + + def fake_spawn(coro, *, name=None): + spawned.append(name) + coro.close() + task = MagicMock() + task.done.return_value = False + return task + + bot._spawn = fake_spawn + asyncio.run(_mod.on_connected(bot)) + assert spawned.count("voice-greet") == 1 + asyncio.run(_mod.on_connected(bot)) + assert spawned.count("voice-greet") == 1 + + def test_no_greet_without_config(self): + """No greeting when mumble.greet is not set.""" + bot = _FakeBot() + bot.config = {} + + spawned = [] + + def fake_spawn(coro, *, name=None): + spawned.append(name) + coro.close() + task = MagicMock() + task.done.return_value = False + return task + + bot._spawn = fake_spawn + asyncio.run(_mod.on_connected(bot)) + assert "voice-greet" not in spawned + + def test_no_greet_non_mumble(self): + """Greeting skipped for non-Mumble bots.""" + bot = _FakeBot(mumble=False) + bot.config = {"mumble": {"greet": "Hello there."}} + asyncio.run(_mod.on_connected(bot)) + # Should not raise or try to greet