feat: add extra Mumble bot instances and TTS greeting

Support [[mumble.extra]] config for additional Mumble identities that
inherit connection settings from the main [mumble] section. Extra bots
get their own state DB and do not run the voice trigger by default.

Add TTS greeting on first connect via mumble.greet config option.
Merlin joins as a second identity with his own client certificate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 04:34:10 +01:00
parent 165938a801
commit 9783365b1e
4 changed files with 121 additions and 1 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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