signal-bot/DOCS-commands.md
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
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>
2026-09-04 16:20:05 +02:00

129 lines
7.2 KiB
Markdown

# 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` |
| art | `!sztuka` `!sztukę` `!sztuki` `!art` |
| poem | `!wiersz` `!wiersze` `!wiersza` `!wierszyk` `!poem` `!poems` `!poezja` `!poezje` `!poetry` |
| trap | `!pułapka` `!pulapka` `!pułapki` `!pulapki` `!pułapkę` `!pulapke` `!trap` `!traps` `!trapy` `!trapper` `!sidła` `!sidla` `!sidło` `!sidlo` `!wnyk` `!wnyki` `!potrzask` `!zasadzka` `!klatka` `!samołówka` `!samolowka` `!beartrap` `!bear_trap` `!snare` `!snares` `!mousetrap` |
| 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` |
| command_stats | `!analityka` `!command_stats` `!commandstats` `!analytics` `!uzycie` `!użycie` `!ranking` |
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
| random | `!losowe` `!losuj` `!random` `!rand` |
| suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` |
| mute | `!cisza` `!cicho` `!mute` |
| unmute | `!odcisz` `!gadaj` `!wracaj` |
Aliases live in `signal_bot/aliases.json`, not in source, because they are
data: adding one should never mean touching Python. Two constraints on a new
one. It is subtracted from the personal-data gate's word list by
`_repo_vocabulary()`, so it must not be name-shaped; and
`tests/test_nearest.py` fails if it lands within edit distance of a
*different* command's trigger, because the near-miss suggestion below would
then be a coin toss. `!poems` is the one trigger in the table that is only
just legal: it sits two edits from `!pies`, and passes solely because `!poem`
is one edit away and wins the tie. Deleting `!poem` would fail the suite at
`!poems`, nowhere near where the change was made.
The operator can add words of their own without a commit: the Commands tab of
the management page writes `aliases_custom.json` in the bot's state directory
(`CUSTOM_ALIASES_FILE` names another path), keyed by command name, and the
bot appends them to the shipped list when it builds the table. They are
extra spellings only -- `!pomoc` keeps showing the shipped one -- and they go
through the same clash check as `aliases.json`, so a word another command
already answers to is refused, as is a name no command has. That is what
makes the page's save a reload: the bot proves the file before it is kept.
The tagger refuses to give a person a trigger that is already somebody's
custom alias, for the same reason it refuses the shipped ones.
## A word nothing claims
An unrecognised `!word` used to be dropped in silence, which made a typo
indistinguishable from the bot being down. Now it is answered, and kept:
- within one edit (two, from six characters up) of a real trigger, the bot
asks whether that is what you meant — `!cta` → *chodziło o !kot?* The
distance is optimal string alignment rather than `difflib`'s ratio,
because a transposition costs one edit there and shreds the matching
blocks a ratio counts.
- otherwise it points at `!zaproponuj`, which files the idea.
- either way the word and the message go into `unknown_commands`, which the
management page shows. That is the list of commands worth writing next.
Three things stay silent, and each for its own reason. `!kot ładny` is a
real trigger with words after it, and always has been silent. `!!` and `!?`
are punctuation, not a mistyped command. And a command scoped to some *other*
group — the album's, whose triggers are real people's names — is neither
answered nor recorded, because a refusal would announce that the album
exists.
`random` exists **only in the photo album's own groups**, and only on an
installation that has an album at all — everywhere else it is not a command,
not refused and never logged. It takes arguments, and those
arguments are resolved to pseudonym keys by the registry before any handler,
log line or `command_uses` row sees them. See `DOCS-photo-pack.md`.
`mute` (`!cisza [1-10]`) silences the bot in **that group only**, for the
minutes asked or ten by default, and ten at most. It confirms once and then
answers nothing: no commands, no near-miss suggestion, and the nightly report
is held back (not marked, so the next run still says that day). Messages are
still counted. Anybody may ask -- ten quiet minutes is the whole downside.
`unmute` (`!odcisz`) is the one command that runs through a mute, and only
for `TRUSTED_INVITERS`; anyone else is refused without a reply, because a
refusal is a reply. With `TRUSTED_INVITERS` unset, anybody may. There is no
`!unmute` alias: it sits one edit from `!mute`, inside the near-miss budget.
`add_photo` (`!dodaj`) is **unwired since 2026-09-04**: a photo posted to a
group must not reach the album without somebody approving it, and no approval
queue exists yet. It comes back when one does; `DOCS-add-photo.md` describes
the path as it was.
Any command can be **switched off** from the management page (Commands tab),
outright or until a moment, and given its own cooldown. The bot reads the
switch on every attempt, so it is live without a reload; a command that is
off answers `BEEP BOOP !kot WYŁĄCZONE.` (or `… WYŁĄCZONE DO 14:01.`, Warsaw
time) rather than going quiet, and the refusal neither starts the cooldown nor
counts as a use. `!pomoc` still lists it. `DOCS-web-ui.md` has the page, and
the same row is where a custom alias is typed.
## 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.