mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
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:
parent
032284bab3
commit
ca38147576
@ -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
51
DOCS-commands.md
Normal 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.
|
||||
14
README.md
14
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
|
||||
```
|
||||
|
||||
7
fake_signal/scenarios/help_command.yaml
Normal file
7
fake_signal/scenarios/help_command.yaml
Normal 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" }
|
||||
@ -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"
|
||||
|
||||
@ -83,5 +83,12 @@
|
||||
"!ja",
|
||||
"!moje",
|
||||
"!mnie"
|
||||
],
|
||||
"help": [
|
||||
"!pomoc",
|
||||
"!pomocy",
|
||||
"!help",
|
||||
"!komendy",
|
||||
"!commands"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
100
signal_bot/help_command.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
144
tests/test_help_command.py
Normal 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
|
||||
@ -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"]
|
||||
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user