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>
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_GROUPSis 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.