release: 0.1.4 «Eleuteriusz» — the list of commands lives in the chat now

Thirteen commands, and the only list of them was a README table nobody in a
Signal group can read — which had itself gone stale two releases earlier,
still advertising four commands after nine more had shipped. 0.1.1 gave each
command an output that says what the command is; that only helps once you
already know to type it.

!pomoc (also !pomocy, !help, !komendy, !commands) prints all fourteen,
grouped, one Polish line each.

The interesting part is that it cannot go stale. CommandRegistry.register now
takes the description as a REQUIRED argument, so a command that does not
explain itself does not compile; and render_help prints any command that
help_command.GROUPS forgot to file, under no heading, rather than dropping
it. Two independent ways to fail closed, because the README proved that a
hand-maintained second list is a list that drifts.

Command also gained `primary`, the first raw alias. The trigger set could not
supply it: those are folded skeletons in an unordered frozenset, so all that
survives of "!papież" there is "!papiez", and the styled Unicode aliases come
out as zalgo. aliases.json already lists the canonical Polish spelling first.

The full trigger table moves to DOCS-commands.md. It did not fit in README —
that file was already sitting exactly on the shared 250-line cap, and the
gate caught the overflow on the first commit attempt.

Exercised in testbot at the unbumped version before this bump: the live
dispatcher ran it and posted the list, journal clean. pytest 708 green at
100% branch coverage, e2e green including a new scenario that asserts !pomoc
mentions a command registered by a different module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 21:08:02 +02:00
parent 032284bab3
commit ca38147576
18 changed files with 389 additions and 43 deletions

View File

@ -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

51
DOCS-commands.md Normal file
View File

@ -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.

View File

@ -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
```

View File

@ -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" }

View File

@ -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"

View File

@ -83,5 +83,12 @@
"!ja",
"!moje",
"!mnie"
],
"help": [
"!pomoc",
"!pomocy",
"!help",
"!komendy",
"!commands"
]
}

View File

@ -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)

View File

@ -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,

100
signal_bot/help_command.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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")

View File

@ -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,

View File

@ -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,

144
tests/test_help_command.py Normal file
View File

@ -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

View File

@ -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"]

View File

@ -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]