signal-bot/DOCS-selftest.md
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
Bot.listen awaited every frame to completion, so one Commons fetch or one
attachment upload held up every text reply behind it (DOCS-latency.md).
Each frame now runs in its own task, never more than LIMIT at once; the
reader waits for a slot before spawning, so a burst applies back-pressure
at the websocket instead of fanning out upstream. One person's commands
stay serialised (user_lock.py): two quick !kot would otherwise both pass
the cooldown gate before either recorded a use.

Every served command reports its timings split into the bot's own time
and everybody else's (served.py); /_selftest carries that block and
tool/latency.py holds own_ms to 100 ms at p95, with the command's
cooldown switched off for the run and put back afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:35:34 +02:00

2.2 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 answers with

delivered, the group id, the text -- and, when the text was a command, served: what that one reply cost, split into the bot's own time and the time spent on signal-cli and upstream (signal_bot/served.py, and the table in DOCS-latency.md). A plain message or an unknown word is delivered and counted but has no served, because nothing ran. tool/latency.py reads that block repeatedly to hold the bot to its budget.

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.