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