diff --git a/DOCS-changelog.md b/DOCS-changelog.md index 4f3ac5b..cc50324 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -38,6 +38,11 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about `/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can reach, and it was rightly called out. +## 0.1.4 — Eleuteriusz + +- `!pomoc` (albo `!help`) wypisuje wszystkie komendy i mówi jednym zdaniem, + co każda robi — do tej pory nigdzie na czacie nie było ich listy + ## 0.1.3 — Soter - "POCZEKAJ 1 SEKUND" odmienia się wreszcie po polsku — teraz jest diff --git a/DOCS-commands.md b/DOCS-commands.md new file mode 100644 index 0000000..fd13e35 --- /dev/null +++ b/DOCS-commands.md @@ -0,0 +1,51 @@ +# Commands + +The copy that matters is not this file: `!pomoc` prints the same list **in the +group**, in Polish, one line of description per command. That version is built +from the command registry at run time and cannot drift, because +`CommandRegistry.register` takes the description as a required argument and +`help_command.render_help` prints any command its `GROUPS` table forgot to +file anyway, under no heading. + +This file is the trigger reference — every spelling that resolves, which +`!pomoc` deliberately does not print. + +| Command | Aliases | +|---|---| +| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants | +| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants | +| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` | +| pope | `!papież` `!papiez` `!papieze` `!papieża` `!papa` `!pope` `!popes` `!pontifex` + styled variants | +| stats_today | `!staty` `!statystyki` `!stats` `!dziś` `!dzis` | +| stats_yesterday | `!wczoraj` `!yesterday` | +| stats_week | `!tydzień` `!tydzien` `!week` | +| stats_month | `!miesiąc` `!miesiac` `!month` | +| stats_year | `!rok` `!year` | +| stats_records | `!rekordy` `!rekord` `!records` | +| stats_crown | `!korona` `!crown` | +| stats_weekdays | `!dni` `!days` | +| stats_me | `!ja` `!moje` `!mnie` | +| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` | + +Aliases live in `signal_bot/aliases.json`, not in source, because they are +data: adding one should never mean touching Python. + +## Why `!pomoc` shows the first alias and not a trigger + +`Command.triggers` is a `frozenset` of **folded skeletons** — `folding.py` +runs NFKC, strips diacritics and maps Cyrillic/Greek/small-caps/leet +lookalikes onto ASCII, so that `!gecko` typed on a Russian keyboard still +resolves. Everything that makes those triggers robust also makes them +unprintable: the set has no order, `!papież` survives only as `!papiez`, and +the styled variants come out as zalgo. + +So `Command.primary` holds the first *raw* trigger instead, which +`aliases.json` lists as the canonical Polish spelling for every command. + +Two consequences worth remembering, both from `folding.py`: + +- A command name can never contain a digit — `fold` maps `0134578` onto + letters, so `!7dni` would resolve as `!tdni`. +- The styled Unicode aliases in the table above are redundant now; folding + covers them. They are kept because removing an alias can only take + something away. diff --git a/README.md b/README.md index e359931..f86568e 100644 --- a/README.md +++ b/README.md @@ -193,12 +193,12 @@ container, and `TRUSTED_INVITERS` decides who may add this bot to a group: ## Commands -| Command | Aliases | -|---|---| -| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants | -| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants | -| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` | -| pope | `!papież` `!papiez` `!papa` `!pope` `!popes` `!pontifex` + styled variants | +`!pomoc` (or `!help`) prints every command **in the group**, in Polish, one +line of description each. It is built from the registry at run time and +`CommandRegistry.register` takes that description as a required argument, so a +command cannot be added without appearing there. Every trigger that resolves, +and why the printed spelling is not one of them: +[DOCS-commands.md](DOCS-commands.md). Aliases live in `signal_bot/aliases.json`, not in source. Each command is rate-limited per user, per command — the original bot shared one 10-second window across everybody. @@ -234,7 +234,7 @@ things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona` ## Develop ```sh -pytest # 377 tests, 100% branch coverage, no network +pytest # 708 tests, 100% branch coverage, no network pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates ./scripts/e2e.sh # the real bot against the fake server ``` diff --git a/fake_signal/scenarios/help_command.yaml b/fake_signal/scenarios/help_command.yaml new file mode 100644 index 0000000..2e64c92 --- /dev/null +++ b/fake_signal/scenarios/help_command.yaml @@ -0,0 +1,7 @@ +# Fresh uuid: one bot process serves every scenario and the cooldown lives in +# SQLite, so a shared actor would leak a refusal into this file. +name: "!pomoc lists every command, including ones from other modules" +inject: + - { text: "!pomoc", uuid: uuid-helen, name: Helen } +expect: + - { kind: text, to: reply, contains: "!gekon" } diff --git a/pyproject.toml b/pyproject.toml index c99eeff..62f2aee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "signal-bot" -version = "0.1.3" +version = "0.1.4" description = "A Signal group bot: image commands and daily message tallies." readme = "README.md" license = "MIT" diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index aacac8e..0921162 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -83,5 +83,12 @@ "!ja", "!moje", "!mnie" + ], + "help": [ + "!pomoc", + "!pomocy", + "!help", + "!komendy", + "!commands" ] } diff --git a/signal_bot/animals.py b/signal_bot/animals.py index 4683dc2..204b8e3 100644 --- a/signal_bot/animals.py +++ b/signal_bot/animals.py @@ -27,6 +27,8 @@ class ImageSource: api_url: str url_path: tuple[str | int, ...] + # What !pomoc says this command does, in the group's language. + description: str rewrite: tuple[str, str] | None = None page_limit: int | None = None @@ -41,11 +43,12 @@ INATURALIST_PAGE_LIMIT: Final = 10_000 # ignoring it -- five requests in a row returned the same gecko -- so the # randomness has to come from asking for a random page instead. SOURCES: Final[dict[str, ImageSource]] = { - "cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url")), - "dog": ImageSource(DEFAULT_DOG_API_URL, ("message",)), + "cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url"), "losowe zdjęcie kota"), + "dog": ImageSource(DEFAULT_DOG_API_URL, ("message",), "losowe zdjęcie psa"), "gecko": ImageSource( DEFAULT_GECKO_API_URL, ("results", 0, "photos", 0, "url"), + "losowe zdjęcie gekona", ("/square.", "/large."), INATURALIST_PAGE_LIMIT, ), @@ -120,4 +123,4 @@ def register_animal_commands( ) if decorate is not None: handler = decorate(handler) - registry.register(name, table[name], handler) + registry.register(name, table[name], handler, source.description) diff --git a/signal_bot/bot.py b/signal_bot/bot.py index d3af5c4..2998cf9 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -19,6 +19,7 @@ from signal_bot.announce import announce_all from signal_bot.changelog import load as load_changelog from signal_bot.decorators import cooldown from signal_bot.dispatcher import Dispatcher +from signal_bot.help_command import register_help_command from signal_bot.http_client import ImageFetcher from signal_bot.popes_command import register_pope_command from signal_bot.portraits import PortraitCache @@ -203,6 +204,9 @@ def build_bot( decorate=rate_limit, ) register_stats_commands(registry, decorate=rate_limit) + # Last, so !pomoc trails the list it prints; it reads the registry + # when it runs, so the order is presentation only. + register_help_command(registry, decorate=rate_limit) dispatcher = Dispatcher( config=config, directory=directory, diff --git a/signal_bot/help_command.py b/signal_bot/help_command.py new file mode 100644 index 0000000..ab806e4 --- /dev/null +++ b/signal_bot/help_command.py @@ -0,0 +1,100 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The !pomoc command: every command the bot has, and what each one does. + +Nothing in the chat used to say the commands existed. The README's table was +the only list, it was stale within two releases, and nobody in a Signal group +reads a README anyway -- so the list is built from the registry itself. A +command cannot be registered without a description (``CommandRegistry.register`` +requires one), and any command this module's ``GROUPS`` does not mention is +printed anyway, under no heading. Between those two, a new command cannot +quietly fail to appear here. + +The trigger printed is ``Command.primary``, the first *raw* alias, because +``Command.triggers`` holds folded skeletons in an unordered set: "!papież" +survives there only as "!papiez", and the styled Unicode aliases would come +out as the zalgo they are. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.animals import load_aliases + +if TYPE_CHECKING: + from collections.abc import Sequence + + from signal_bot.decorators import Decorator + from signal_bot.registry import Command, CommandContext, CommandRegistry + +COMMAND_NAME: Final = "help" +DESCRIPTION: Final = "ta lista" + +HEADING: Final = "Komendy" + +# Which commands sit under which heading, in the order they are printed. +# Anything missing from here still gets printed, in a block of its own -- see +# the module docstring. +GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( + ("Obrazki", ("cat", "dog", "gecko")), + ("Papież", ("pope",)), + ( + "Statystyki", + ( + "stats_today", + "stats_yesterday", + "stats_week", + "stats_month", + "stats_year", + "stats_records", + "stats_crown", + "stats_weekdays", + "stats_me", + ), + ), +) + +# Only the canonical spelling is listed above, but half the group types the +# English one. Without this line "!cat" looks broken to somebody holding a +# list that says "!kot". +SPELLINGS: Final = "Każda komenda ma też inne pisownie, np. !cat, !stats, !week." + + +def _line(command: Command) -> str: + """One command as !pomoc prints it.""" + return f"{command.primary} — {command.description}" + + +def render_help(commands: Sequence[Command]) -> str: + """Render the whole command list, grouped, in registration order.""" + by_name = {command.name: command for command in commands} + # Indexed, not .get(): a name in GROUPS that no command answers to is a + # typo in this file, and it should fail loudly the first time the command + # is used rather than silently drop a line. + grouped = {name for _, names in GROUPS for name in names} + blocks = [HEADING] + for title, names in GROUPS: + blocks.append("\n".join([title, *(_line(by_name[name]) for name in names)])) + blocks.append("\n".join(_line(c) for c in commands if c.name not in grouped)) + blocks.append(SPELLINGS) + return "\n\n".join(blocks) + + +def register_help_command( + registry: CommandRegistry, + *, + decorate: Decorator | None = None, + aliases: dict[str, list[str]] | None = None, +) -> None: + """Register !pomoc, which reads the registry it is registered on. + + The registry is read when the command *runs*, not when it is registered, + so this may be wired up in any order and still list everything. + """ + table = load_aliases() if aliases is None else aliases + + async def handler(ctx: CommandContext) -> None: + await ctx.reply(render_help(registry.commands)) + + wrapped = decorate(handler) if decorate is not None else handler + registry.register(COMMAND_NAME, table[COMMAND_NAME], wrapped, DESCRIPTION) diff --git a/signal_bot/popes_command.py b/signal_bot/popes_command.py index 3f1cd3d..b93198e 100644 --- a/signal_bot/popes_command.py +++ b/signal_bot/popes_command.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from signal_bot.versioning import Pope COMMAND_NAME: Final = "pope" +DESCRIPTION: Final = "losowy papież z portretem i imieniem po łacinie" WIKI_BASE: Final = "https://pl.wikipedia.org/wiki/" @@ -71,4 +72,4 @@ def register_pope_command( handler = pope_handler(cache) if decorate is not None: handler = decorate(handler) - registry.register(COMMAND_NAME, table[COMMAND_NAME], handler) + registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION) diff --git a/signal_bot/registry.py b/signal_bot/registry.py index 2e030d7..3c1f009 100644 --- a/signal_bot/registry.py +++ b/signal_bot/registry.py @@ -59,11 +59,20 @@ Handler = Callable[[CommandContext], Awaitable[None]] @dataclass(frozen=True, slots=True) class Command: - """A named command and every phrase that triggers it.""" + """A named command, every phrase that triggers it, and how to explain it.""" name: str triggers: frozenset[str] handler: Handler + # Group-facing Polish, one line, for !pomoc. Required at registration so + # that a command cannot exist without one -- a parallel help table would + # go stale exactly the way the README's command list did. + description: str + # The spelling !pomoc shows. It cannot come from `triggers`: those are + # folded skeletons in an unordered set, so "!papiez" is all that survives + # of "!papież". This is the first raw trigger instead, which is the + # canonical Polish one for every entry in aliases.json. + primary: str class DuplicateTriggerError(ValueError): @@ -88,18 +97,27 @@ class CommandRegistry: self._by_trigger: dict[str, Command] = {} self._commands: list[Command] = [] - def register(self, name: str, triggers: Iterable[str], handler: Handler) -> Command: + def register( + self, name: str, triggers: Iterable[str], handler: Handler, description: str + ) -> Command: """Register one command under every one of its triggers. Raises: DuplicateTriggerError: A trigger is already claimed. """ - normalised = frozenset(fold(t) for t in triggers) + raw = list(triggers) + normalised = frozenset(fold(t) for t in raw) for trigger in sorted(normalised): if trigger in self._by_trigger: msg = f"trigger {trigger!r} is already registered" raise DuplicateTriggerError(msg) - command = Command(name=name, triggers=normalised, handler=handler) + command = Command( + name=name, + triggers=normalised, + handler=handler, + description=description, + primary=raw[0], + ) for trigger in normalised: self._by_trigger[trigger] = command self._commands.append(command) diff --git a/signal_bot/stats/commands.py b/signal_bot/stats/commands.py index 118fd70..b054fdb 100644 --- a/signal_bot/stats/commands.py +++ b/signal_bot/stats/commands.py @@ -43,7 +43,7 @@ from signal_bot.stats.render_period import render_period if TYPE_CHECKING: from signal_bot.decorators import Decorator - from signal_bot.registry import CommandContext, CommandRegistry + from signal_bot.registry import CommandContext, CommandRegistry, Handler from signal_bot.stats.periods import Period @@ -193,16 +193,19 @@ async def me(ctx: CommandContext) -> None: ) -HANDLERS = { - "stats_today": today, - "stats_yesterday": yesterday, - "stats_week": week, - "stats_month": month, - "stats_year": year, - "stats_records": all_records, - "stats_crown": crown, - "stats_weekdays": weekdays, - "stats_me": me, +# Handler plus the one line !pomoc prints for it, in the group's language. +# Paired here rather than in the help module so that adding a command and +# explaining it are the same edit. +HANDLERS: dict[str, tuple[Handler, str]] = { + "stats_today": (today, "ile kto dziś napisał, na razie"), + "stats_yesterday": (yesterday, "wczorajszy raport, ten sam, który wpada o 21:37"), + "stats_week": (week, "ten tydzień na tle poprzedniego"), + "stats_month": (month, "ten miesiąc, z podium"), + "stats_year": (year, "ten rok na tle poprzedniego"), + "stats_records": (all_records, "rekordy grupy: najgłośniejszy dzień, najdłuższa cisza"), + "stats_crown": (crown, "kto danego dnia pisze najwięcej i kto trzymał koronę najdłużej"), + "stats_weekdays": (weekdays, "od poniedziałku do niedzieli, który dzień jest najgadatliwszy"), + "stats_me": (me, "twoje własne liczby w tej grupie"), } @@ -214,5 +217,7 @@ def register_stats_commands( ) -> None: """Register every statistics command under its triggers from aliases.json.""" table = load_aliases() if aliases is None else aliases - for name, handler in HANDLERS.items(): - registry.register(name, table[name], decorate(handler) if decorate else handler) + for name, (handler, description) in HANDLERS.items(): + registry.register( + name, table[name], decorate(handler) if decorate else handler, description + ) diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index 55c94a9..15aa50f 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -28,7 +28,7 @@ PATCHES_PER_MINOR: Final = 8 # Which release this build is. Bump by one per release; everything else, # including the version string in pyproject.toml, follows from it. -RELEASE_ORDINAL: Final = 12 +RELEASE_ORDINAL: Final = 13 _POPES_FILE: Final = Path(__file__).with_name("popes.json") diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index a357be7..cd0d2f0 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -40,7 +40,7 @@ def registry(runs: list[str]) -> CommandRegistry: runs.append(ctx.command_name) reg = CommandRegistry() - reg.register("cat", ["!cat"], handler) + reg.register("cat", ["!cat"], handler, "cat description") return reg @@ -151,7 +151,7 @@ async def test_a_failing_command_does_not_kill_the_loop( raise RuntimeError(msg) registry = CommandRegistry() - registry.register("cat", ["!cat"], boom) + registry.register("cat", ["!cat"], boom, "cat description") dispatcher = Dispatcher( config=config, directory=directory, @@ -179,7 +179,7 @@ async def test_a_reply_goes_back_to_the_group_that_asked( await ctx.reply("pong") registry = CommandRegistry() - registry.register("cat", ["!cat"], answer) + registry.register("cat", ["!cat"], answer, "cat description") dispatcher = Dispatcher( config=config, directory=directory, diff --git a/tests/test_dispatcher_gates.py b/tests/test_dispatcher_gates.py index 8d50256..e7e088d 100644 --- a/tests/test_dispatcher_gates.py +++ b/tests/test_dispatcher_gates.py @@ -34,7 +34,7 @@ async def test_a_refused_command_records_nothing( runs.append(ctx.command_name) registry = CommandRegistry() - registry.register("cat", ["!cat"], cooldown(10, "wait {seconds}")(handler)) + registry.register("cat", ["!cat"], cooldown(10, "wait {seconds}")(handler), "kot") dispatcher = Dispatcher( config=config, directory=directory, @@ -66,7 +66,7 @@ async def test_waiting_the_stated_time_lets_the_command_run( runs.append(ctx.command_name) registry = CommandRegistry() - registry.register("cat", ["!cat"], cooldown(10, "{seconds}")(handler)) + registry.register("cat", ["!cat"], cooldown(10, "{seconds}")(handler), "kot") dispatcher = Dispatcher( config=config, directory=directory, diff --git a/tests/test_help_command.py b/tests/test_help_command.py new file mode 100644 index 0000000..22db509 --- /dev/null +++ b/tests/test_help_command.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""!pomoc, and the two gates that keep it from going stale.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import httpx +import pytest + +from signal_bot.animals import register_animal_commands +from signal_bot.config import DEFAULT_COMMONS_API_URL +from signal_bot.help_command import ( + COMMAND_NAME, + GROUPS, + HEADING, + SPELLINGS, + register_help_command, + render_help, +) +from signal_bot.http_client import ImageFetcher +from signal_bot.models import parse_envelope +from signal_bot.popes_command import register_pope_command +from signal_bot.portraits import PortraitCache +from signal_bot.registry import CommandContext, CommandRegistry +from signal_bot.stats.commands import register_stats_commands +from tests.conftest import WATCHED_GROUP, envelope + +if TYPE_CHECKING: + from signal_bot.config import Config + from signal_bot.store import Store + from signal_bot.transport_fake import FakeTransport + + +def _offline_fetcher() -> ImageFetcher: + """A fetcher wired to a transport that refuses to be called. + + !pomoc never fetches anything, and the other commands are registered here + only so that the help text has something to list. + """ + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(request.url) + + return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + +def full_registry() -> CommandRegistry: + """Every command the live bot registers, wired the way build_bot wires it.""" + registry = CommandRegistry() + fetcher = _offline_fetcher() + register_animal_commands(registry, fetcher) + register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL)) + register_stats_commands(registry) + register_help_command(registry) + return registry + + +async def _noop(ctx: CommandContext) -> None: + """A handler that does nothing.""" + + +def test_every_registered_command_carries_a_description() -> None: + """The whole point of making description a required argument.""" + for command in full_registry().commands: + assert command.description.strip(), command.name + + +def test_help_lists_every_command_exactly_once() -> None: + """A command missing from !pomoc, or listed twice, fails here. + + Asserted on the whole line rather than the bare trigger, so prose that + happens to mention "!stats" can neither satisfy nor break it. + """ + registry = full_registry() + text = render_help(registry.commands) + for command in registry.commands: + assert text.count(f"\n{command.primary} — {command.description}") == 1, command.name + + +def test_help_prints_the_polish_spelling_not_the_folded_one() -> None: + """`triggers` holds skeletons; only `primary` still carries the diacritics.""" + text = render_help(full_registry().commands) + assert "!papież" in text + assert "!papiez" not in text + assert "!tydzień" in text + + +def test_headings_appear_in_order() -> None: + """Obrazki, Papież, Statystyki, then the ungrouped block, then the note.""" + text = render_help(full_registry().commands) + positions = [text.index(f"\n{title}\n") for title, _ in GROUPS] + assert positions == sorted(positions) + assert text.startswith(HEADING) + assert text.endswith(SPELLINGS) + + +def test_a_command_outside_the_groups_is_still_listed() -> None: + """The safety net: forgetting to add a command to GROUPS cannot hide it.""" + registry = full_registry() + registry.register("ping", ["!ping"], _noop, "odpowiada pong") + assert "\n!ping — odpowiada pong" in render_help(registry.commands) + + +def test_a_group_naming_an_unknown_command_fails_loudly() -> None: + """A typo in GROUPS raises rather than silently dropping a line.""" + registry = CommandRegistry() + register_help_command(registry) + with pytest.raises(KeyError): + render_help(registry.commands) + + +async def test_the_handler_replies_with_the_rendered_list( + transport: FakeTransport, store: Store, config: Config +) -> None: + """!pomoc answers in the group that asked, quoting the asker.""" + registry = full_registry() + command = registry.resolve("!pomoc") + assert command is not None + assert command.name == COMMAND_NAME + message = parse_envelope(envelope(text="!pomoc")) + assert message is not None + await command.handler( + CommandContext( + message=message, + group_id=WATCHED_GROUP, + transport=transport, + store=store, + config=config, + command_name=COMMAND_NAME, + now=100.0, + day="2026-08-30", + ) + ) + assert transport.sent[0].recipient == WATCHED_GROUP + assert transport.sent[0].text == render_help(registry.commands) + + +@pytest.mark.parametrize("trigger", ["!pomoc", "!pomocy", "!help", "!komendy", "!commands"]) +def test_every_alias_resolves(trigger: str) -> None: + """All five spellings reach the same command.""" + command = full_registry().resolve(trigger) + assert command is not None + assert command.name == COMMAND_NAME diff --git a/tests/test_registry.py b/tests/test_registry.py index 9591687..f60c728 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -23,20 +23,20 @@ async def _noop(ctx: CommandContext) -> None: def test_resolves_a_registered_trigger() -> None: registry = CommandRegistry() - registry.register("cat", ["!cat", "!kot"], _noop) + 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) + 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) + registry.register("cat", ["!cat"], _noop, "cat description") assert registry.resolve("!nope") is None assert registry.resolve("cat") is None @@ -48,15 +48,15 @@ def test_empty_text_resolves_to_nothing(text: str | None) -> None: def test_duplicate_trigger_is_rejected() -> None: registry = CommandRegistry() - registry.register("cat", ["!cat"], _noop) + registry.register("cat", ["!cat"], _noop, "cat description") with pytest.raises(DuplicateTriggerError, match="!cat"): - registry.register("feline", ["!cat"], _noop) + registry.register("feline", ["!cat"], _noop, "feline description") def test_commands_lists_in_registration_order() -> None: registry = CommandRegistry() - registry.register("cat", ["!cat"], _noop) - registry.register("dog", ["!dog"], _noop) + registry.register("cat", ["!cat"], _noop, "cat description") + registry.register("dog", ["!dog"], _noop, "dog description") assert [c.name for c in registry.commands] == ["cat", "dog"] diff --git a/tests/test_stats_commands.py b/tests/test_stats_commands.py index 5cdde18..4b5bf3f 100644 --- a/tests/test_stats_commands.py +++ b/tests/test_stats_commands.py @@ -59,7 +59,8 @@ def seed_from_counter_rows(store: Store, uuid: str, name: str, count: int) -> No async def run(store: Store, name: str, uuid: str = KUHY[0]) -> str: """Run one command and return what it said.""" ctx = FakeContext(store=store, message=Sender(source_uuid=uuid)) - await HANDLERS[name](ctx) + handler, _ = HANDLERS[name] + await handler(ctx) return ctx.replies[0]