feat: voice profiles, rubberband FX, per-bot plugin filtering
- Add rubberband package to container for pitch-shifting FX - Split FX chain: rubberband CLI for pitch, ffmpeg for filters - Configurable voice profile (voice, fx, piper params) in [voice] - Extra bots inherit voice config (minus trigger) for own TTS - Greeting is voice-only, spoken directly by the greeting bot - Per-bot only_plugins/except_plugins filtering on Mumble - Alias plugin, core plugin tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,20 @@ class TestDecorators:
|
||||
assert handler._derp_event == "PRIVMSG"
|
||||
|
||||
|
||||
def test_command_decorator_aliases(self):
|
||||
@command("skip", help="skip track", aliases=["next", "s"])
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_aliases == ["next", "s"]
|
||||
|
||||
def test_command_decorator_aliases_default(self):
|
||||
@command("ping", help="ping")
|
||||
async def handler(bot, msg):
|
||||
pass
|
||||
|
||||
assert handler._derp_aliases == []
|
||||
|
||||
def test_command_decorator_admin(self):
|
||||
@command("secret", help="admin only", admin=True)
|
||||
async def handler(bot, msg):
|
||||
@@ -208,6 +222,46 @@ class TestRegistry:
|
||||
assert registry.commands["secret"].admin is True
|
||||
assert registry.commands["public"].admin is False
|
||||
|
||||
def test_load_plugin_aliases(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "aliased.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("skip", help="Skip track", aliases=["next", "s"])
|
||||
async def cmd_skip(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
count = registry.load_plugin(plugin_file)
|
||||
assert count == 3 # primary + 2 aliases
|
||||
assert "skip" in registry.commands
|
||||
assert "next" in registry.commands
|
||||
assert "s" in registry.commands
|
||||
# Aliases point to the same callback
|
||||
assert registry.commands["next"].callback is registry.commands["skip"].callback
|
||||
assert registry.commands["s"].callback is registry.commands["skip"].callback
|
||||
# Alias help text references the primary command
|
||||
assert registry.commands["next"].help == "alias for !skip"
|
||||
|
||||
def test_unload_removes_aliases(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "aliased.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
from derp.plugin import command
|
||||
|
||||
@command("skip", help="Skip track", aliases=["next"])
|
||||
async def cmd_skip(bot, msg):
|
||||
pass
|
||||
"""))
|
||||
|
||||
registry = PluginRegistry()
|
||||
registry.load_plugin(plugin_file)
|
||||
assert "next" in registry.commands
|
||||
|
||||
registry.unload_plugin("aliased")
|
||||
assert "skip" not in registry.commands
|
||||
assert "next" not in registry.commands
|
||||
|
||||
def test_load_plugin_stores_path(self, tmp_path: Path):
|
||||
plugin_file = tmp_path / "pathed.py"
|
||||
plugin_file.write_text(textwrap.dedent("""\
|
||||
@@ -677,6 +731,71 @@ class TestChannelFilter:
|
||||
assert bot._plugin_allowed("encode", "&local") is False
|
||||
|
||||
|
||||
class TestAliasDispatch:
|
||||
"""Test alias fallback in _dispatch_command."""
|
||||
|
||||
@staticmethod
|
||||
def _make_bot_with_alias(alias_name: str, target_cmd: str) -> tuple[Bot, list]:
|
||||
"""Create a Bot with a command and an alias pointing to it."""
|
||||
config = {
|
||||
"server": {"host": "localhost", "port": 6667, "tls": False,
|
||||
"nick": "test", "user": "test", "realname": "test"},
|
||||
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
|
||||
}
|
||||
registry = PluginRegistry()
|
||||
called = []
|
||||
|
||||
async def _handler(bot, msg):
|
||||
called.append(msg.text)
|
||||
|
||||
registry.register_command(target_cmd, _handler, plugin="test")
|
||||
bot = Bot("test", config, registry)
|
||||
bot.conn = _FakeConnection()
|
||||
bot.state.set("alias", alias_name, target_cmd)
|
||||
return bot, called
|
||||
|
||||
def test_alias_resolves_command(self):
|
||||
"""An alias triggers the target command handler."""
|
||||
bot, called = self._make_bot_with_alias("s", "skip")
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!s"], tags={})
|
||||
|
||||
async def _run():
|
||||
bot._dispatch_command(msg)
|
||||
await asyncio.sleep(0.05) # let spawned task run
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(called) == 1
|
||||
|
||||
def test_alias_ignored_when_command_exists(self):
|
||||
"""Direct command match takes priority over alias."""
|
||||
bot, called = self._make_bot_with_alias("skip", "stop")
|
||||
# "skip" is both a real command and an alias to "stop"; real wins
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!skip"], tags={})
|
||||
|
||||
async def _run():
|
||||
bot._dispatch_command(msg)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(called) == 1
|
||||
# Handler was the "skip" handler, not "stop"
|
||||
|
||||
def test_no_alias_no_crash(self):
|
||||
"""Unknown command with no alias silently returns."""
|
||||
config = {
|
||||
"server": {"host": "localhost", "port": 6667, "tls": False,
|
||||
"nick": "test", "user": "test", "realname": "test"},
|
||||
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
|
||||
}
|
||||
bot = Bot("test", config, PluginRegistry())
|
||||
bot.conn = _FakeConnection()
|
||||
msg = Message(raw="", prefix="nick!u@h", nick="nick",
|
||||
command="PRIVMSG", params=["#ch", "!nonexistent"], tags={})
|
||||
bot._dispatch_command(msg) # should not raise
|
||||
|
||||
|
||||
class TestSplitUtf8:
|
||||
"""Test UTF-8 safe message splitting."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user