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
|
- Use `!kept clear` to delete all preserved files
|
||||||
- On cancel/error, files are not deleted (needed for `!resume`)
|
- 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
|
### Voice STT/TTS
|
||||||
|
|
||||||
Transcribe voice from Mumble users via Whisper STT and speak text aloud
|
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
|
- Piper outputs 22050Hz WAV; ffmpeg resamples to 48kHz automatically
|
||||||
- TTS shares the audio output with music playback
|
- TTS shares the audio output with music playback
|
||||||
- Text is limited to 500 characters
|
- Text is limited to 500 characters
|
||||||
|
- Set `greet` in `[mumble]` or `[[mumble.extra]]` for automatic TTS on first connect
|
||||||
|
|
||||||
### Always-On Trigger Mode
|
### Always-On Trigger Mode
|
||||||
|
|
||||||
|
|||||||
@@ -321,10 +321,22 @@ async def cmd_say(bot, message):
|
|||||||
|
|
||||||
|
|
||||||
async def on_connected(bot) -> None:
|
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):
|
if not _is_mumble(bot):
|
||||||
return
|
return
|
||||||
ps = _ps(bot)
|
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"]:
|
if ps["listen"] or ps["trigger"]:
|
||||||
_ensure_listener(bot)
|
_ensure_listener(bot)
|
||||||
_ensure_flush_task(bot)
|
_ensure_flush_task(bot)
|
||||||
|
|||||||
@@ -155,6 +155,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
mumble_bot = MumbleBot("mumble", config, registry)
|
mumble_bot = MumbleBot("mumble", config, registry)
|
||||||
bots.append(mumble_bot)
|
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)
|
names = ", ".join(b.name for b in bots)
|
||||||
log.info("servers: %s", names)
|
log.info("servers: %s", names)
|
||||||
|
|
||||||
|
|||||||
@@ -724,3 +724,75 @@ class TestTriggerMode:
|
|||||||
msg = _Msg(text="!listen")
|
msg = _Msg(text="!listen")
|
||||||
asyncio.run(_mod.cmd_listen(bot, msg))
|
asyncio.run(_mod.cmd_listen(bot, msg))
|
||||||
assert any("Trigger: claude" in r for r in bot.replied)
|
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