A "!cat" in a busy group produced a cat picture floating loose in the chat with
nothing tying it to whoever asked. Replies now quote the triggering message.
The container advertises this and always could: /v1/about reports
capabilities {"v2/send": ["quotes", "mentions"]}, and /v2/send takes
quote_timestamp, quote_author and quote_message. What was missing was on this
side -- parse_envelope discarded the envelope's timestamp, which is the id
Signal quotes a message by, so nothing downstream could have built a quote even
in principle. IncomingMessage now carries it, preferring the payload's own
value over the envelope's for the case where they ever disagree.
Quote is a value object rather than three more parameters threaded through
every send: the Transport protocol has five methods and two of them send.
CommandContext grows reply_image() so quoting lives in one place instead of
each handler reaching for ctx.transport itself, which is what animals.py did.
quote_of() returns None when the timestamp or author is missing. Answering the
room is a worse reply than a quoted one but a much better outcome than a
payload the container rejects.
The e2e gate learned to tell the two apart: Expectation.quotes demands a send
either does or does not point at a message, defaulting to None so the ten
existing scenarios stay indifferent. Without it the fake would have accepted an
unquoted reply as a pass.
Verified in the testbot group on a real Signal client before this bump, per
kuhy's rule that a bump is a publish rather than a checkpoint: he sent !cat and
confirmed the picture renders attached to his message as a reply.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
Release n is pope n, so the version is a fact about the ordinal rather than a
judgement about the size of a change. The constraint that fixes the scheme is
Jan Pawel II at 2.13.7: eight patches per minor with majors opening at ordinals
33 and 153 gives (264 - 153) = 13 * 8 + 7 exactly, and both earlier majors
happen to divide evenly by eight too, so no bracket needs a fudge factor. The
anchors are not arbitrary -- 33 is Sylwester I, pope through the Edict of Milan
and Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism.
popes.json is scraped by tool/gen_popes.py rather than recalled, because
antipopes, Benedykt IX's three separate reigns and the Stefan II question all
shift the count and being wrong by one moves Jan Pawel II off 2.13.7 silently.
The generator refuses to write a list that is not 267 contiguous entries with
Jan Pawel II at 264. A second, independent parse of the same page agreed on
every ordinal, which is the only reason to believe either.
On startup each watched group is told about the releases it has not seen. A
group with no record has seen none of them, so the first announcement starts at
0.0.0 "Swiety Piotr Apostol" rather than skipping to whatever is current --
watching the ladder start at Peter is the point of naming releases after popes.
A group added years from now would otherwise be handed the whole backlog, so
the message is capped at ten releases and says how many it left out. The
version is recorded before the message is sent: with Restart=always and
RestartSec=5, a crash between the two would re-announce on every restart, and a
missed announcement is far cheaper than a looping one.
DOCS-changelog.md is the single source -- the bot parses it, so there is no
second machine-readable copy to drift. It is DOCS-, not CHANGELOG.md, because
the md-naming gate allows four namespaces and that is not one of them.
Deliberately NOT bumping SCHEMA_VERSION for the new group_meta table:
_apply_schema drops every table on a bump, so bumping to add a table would have
thrown away the live tallies to gain nothing. Verified against the real
database -- group_meta appeared and all four counts survived the restart.
The schema moves to its own module so store.py stays under the 250-line cap,
and /healthz now names the build, because there was previously no way to ask a
running bot which version it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
The build-system pin was setuptools==80.9.0, which GHSA flags for an sdist
MANIFEST.in exclusion bypass via Unicode NFC/NFD collision on macOS APFS/HFS+
(fixed in 83.0.0). Build-backend requirements are a real dependency surface
and the freshness sweep had not covered them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.
Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.
Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read
Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.
Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.
Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.
Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy