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

57 lines
2.2 KiB
Markdown

# 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:
```sh
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.