signal-bot/DOCS-selftest.md
Krzysztof kuhy Rudnicki 558dd297a4 release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

1.8 KiB

Testing a command without a second Signal account

Signal never delivers an account its own sends. The bot cannot type !kot at itself, so until now every command was verified one of two weak ways: by asking a human to type it, or by rebuilding the object graph in a scratch script. The first proves nothing about the path between the websocket and the handler; the second is not the wiring that runs in production.

POST /_selftest injects a real envelope frame into the live dispatcher. Everything downstream of the socket read is the code that actually serves the group: envelope parsing, the scoping gate, the tally, trigger resolution, the cooldown decorator and the real REST transport. Only the socket read is skipped.

Using it

Name the groups it may post into -- in .env, because the service reads it:

SELFTEST_GROUPS=testbot,catbot,finaltestbot

Then:

scripts/selftest.sh testbot '!papież' 3

SELFTEST_INTERVAL (default 11s) spaces the calls past the per-user cooldown; the self-test is always the same author, so a tighter loop just exercises the cooldown instead of the command.

What it will not do

It posts for real, into a real group. Two things keep that from becoming a way to make the bot say anything anywhere:

  • The endpoint lives on the same loopback bind as /healthz, never a public one.
  • SELFTEST_GROUPS is empty by default, and any group not named in it is refused with 403. So a fresh checkout cannot post into the production group even by accident.

Why the author matters

The reply quotes whoever asked, and quote_author has to be somebody Signal can resolve -- a made-up uuid comes back as The user ... is not registered and the whole send fails. resolve_group therefore attributes the message to a real member of the target group who is not the bot.