mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
A custom alias is data the command table is built from, so it cannot be a live switch like the ones already on the tab. Instead the management page writes aliases_custom.json in the bot's state directory and then asks the bot to reload, in one step: whenever the answer is anything but `reloaded` -- a word another command has, a name no command has, a bot still connecting -- the file is restored byte for byte and the bot's own reason is shown next to the field. What is on disk is therefore always what the bot serves, and a typo cannot sit in the file poisoning every later reload. The registry appends the operator's words to the shipped list at registration, which keeps `!pomoc` on the shipped spelling and routes the new words through the same clash check as aliases.json. An alias for a name no command has fails the whole build (ConfigError -> `kept`), because a silently ignored entry is how a file drifts. The tagger treats custom aliases as reserved words too, so a person cannot be given one. registry.py crossed the 250-line cap and now re-exports the record and the error types from command.py; the bot's state directory lookup, previously duplicated in pack.py and the suggestions export, is state_dir.py. Proven end to end: !e2ekot is unknown, the file is written and reload.sh runs, !e2ekot posts a cat, a file naming a command that does not exist is refused with 409 and the previous table still answers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Trigger resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.models import parse_envelope
|
|
from signal_bot.registry import CommandRegistry, DuplicateTriggerError
|
|
from signal_bot.transport_fake import FakeTransport
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
|
|
|
|
async def _noop(ctx: CommandContext) -> None:
|
|
return None
|
|
|
|
|
|
def test_resolves_a_registered_trigger() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!cat", "!kot"], _noop, "cat description")
|
|
assert registry.resolve("!kot") is not None
|
|
assert registry.resolve("!cat").name == "cat"
|
|
|
|
|
|
def test_resolution_is_case_insensitive_and_trims() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!cat"], _noop, "cat description")
|
|
assert registry.resolve(" !CAT ") is not None
|
|
|
|
|
|
def test_unknown_and_prefixless_text_resolves_to_nothing() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!cat"], _noop, "cat description")
|
|
assert registry.resolve("!nope") is None
|
|
assert registry.resolve("cat") is None
|
|
|
|
|
|
@pytest.mark.parametrize("text", [None, ""])
|
|
def test_empty_text_resolves_to_nothing(text: str | None) -> None:
|
|
assert CommandRegistry().resolve(text) is None
|
|
|
|
|
|
def test_duplicate_trigger_is_rejected() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!cat"], _noop, "cat description")
|
|
with pytest.raises(DuplicateTriggerError, match="!cat"):
|
|
registry.register("feline", ["!cat"], _noop, "feline description")
|
|
|
|
|
|
def test_the_operators_aliases_are_appended_and_the_shipped_spelling_stays_primary() -> None:
|
|
registry = CommandRegistry({"cat": ["!kotek"], "nosuch": ["!x"]})
|
|
command = registry.register("cat", ["!kot", "!cat"], _noop, "cat description")
|
|
assert registry.resolve("!kotek") is command
|
|
assert command.primary == "!kot"
|
|
assert registry.unclaimed_aliases == frozenset({"nosuch"})
|
|
|
|
|
|
def test_an_operators_alias_that_is_another_commands_word_is_a_clash() -> None:
|
|
registry = CommandRegistry({"dog": ["!kot"]})
|
|
registry.register("cat", ["!kot"], _noop, "cat description")
|
|
with pytest.raises(DuplicateTriggerError, match="'cat'"):
|
|
registry.register("dog", ["!pies"], _noop, "dog description")
|
|
|
|
|
|
def test_commands_lists_in_registration_order() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!cat"], _noop, "cat description")
|
|
registry.register("dog", ["!dog"], _noop, "dog description")
|
|
assert [c.name for c in registry.commands] == ["cat", "dog"]
|
|
|
|
|
|
def _ctx(transport: FakeTransport, store: Store, config: Config) -> CommandContext:
|
|
message = parse_envelope(envelope(text="!papiez"))
|
|
assert message is not None
|
|
return CommandContext(
|
|
message=message,
|
|
group_id=WATCHED_GROUP,
|
|
transport=transport,
|
|
store=store,
|
|
config=config,
|
|
command_name="pope",
|
|
now=100.0,
|
|
day="2026-08-30",
|
|
)
|
|
|
|
|
|
async def test_reply_image_defaults_to_no_caption(store: Store, config: Config) -> None:
|
|
transport = FakeTransport()
|
|
await _ctx(transport, store, config).reply_image("Zm9v")
|
|
assert transport.sent[0].caption == ""
|
|
|
|
|
|
async def test_reply_image_passes_the_caption_through(store: Store, config: Config) -> None:
|
|
"""The caption rides in the same payload, so Signal shows one message."""
|
|
transport = FakeTransport()
|
|
await _ctx(transport, store, config).reply_image("Zm9v", "8. Telesfor")
|
|
sent = transport.sent[0]
|
|
assert sent.caption == "8. Telesfor"
|
|
assert sent.image_base64 == "Zm9v"
|
|
assert sent.quote is not None
|
|
|
|
|
|
async def _nothing(ctx: CommandContext) -> None:
|
|
"""A handler that does nothing, for tests about resolution only."""
|
|
|
|
|
|
def test_a_command_with_no_groups_exists_everywhere() -> None:
|
|
registry = CommandRegistry()
|
|
command = registry.register("open", ["!open"], _nothing, "everywhere")
|
|
assert command.allowed_in("group-a==")
|
|
assert registry.resolve_with_args("!open", "group-b==") is not None
|
|
|
|
|
|
def test_a_scoped_command_does_not_exist_elsewhere() -> None:
|
|
"""Not refused -- absent. A refusal would announce that it exists."""
|
|
registry = CommandRegistry()
|
|
registry.register("shy", ["!shy"], _nothing, "here only", groups=frozenset({"group-a=="}))
|
|
assert registry.resolve_with_args("!shy", "group-a==") is not None
|
|
assert registry.resolve_with_args("!shy", "group-b==") is None
|
|
|
|
|
|
def test_words_after_a_trigger_are_arguments_only_where_they_are_wanted() -> None:
|
|
""" "!cat blah" has always resolved to nothing, and still does."""
|
|
registry = CommandRegistry()
|
|
registry.register("plain", ["!plain"], _nothing, "no arguments")
|
|
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
|
|
assert registry.resolve_with_args("!plain blah", "g==") is None
|
|
resolved = registry.resolve_with_args("!takes one two", "g==")
|
|
assert resolved is not None
|
|
assert resolved.args == ("one", "two")
|
|
|
|
|
|
def test_a_bare_trigger_has_no_arguments() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
|
|
resolved = registry.resolve_with_args("!takes", "g==")
|
|
assert resolved is not None
|
|
assert resolved.args == ()
|
|
|
|
|
|
def test_nothing_resolves_out_of_an_empty_message() -> None:
|
|
registry = CommandRegistry()
|
|
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
|
|
assert registry.resolve_with_args(None, "g==") is None
|
|
assert registry.resolve_with_args(" ", "g==") is None
|
|
assert registry.resolve_with_args("!nobody has this", "g==") is None
|
|
|
|
|
|
def test_triggers_in_returns_only_what_this_group_actually_has() -> None:
|
|
"""What the near-miss search reads.
|
|
|
|
Scoped rather than global, and that is the whole point: a command
|
|
another group owns must not be offerable as a suggestion here, because
|
|
offering it would name it.
|
|
"""
|
|
registry = CommandRegistry()
|
|
everywhere = registry.register("cat", ["!kot", "!cat"], _noop, "kot")
|
|
scoped = registry.register("pack:p1", ["!alfa"], _noop, "zdjęcia", groups=frozenset({"g1"}))
|
|
here = registry.triggers_in("g1")
|
|
assert here == {"!kot": everywhere, "!cat": everywhere, "!alfa": scoped}
|
|
assert registry.triggers_in("g2") == {"!kot": everywhere, "!cat": everywhere}
|
|
|
|
|
|
def test_triggers_in_maps_a_folded_trigger_to_its_command() -> None:
|
|
"""Folded, so both sides of a comparison went through the same function."""
|
|
registry = CommandRegistry()
|
|
registry.register("pope", ["!papież"], _noop, "papież")
|
|
assert set(registry.triggers_in("g1")) == {"!papiez"}
|