Commit Graph

7 Commits

Author SHA1 Message Date
37a27c6dff feat: refuse to commit personal data, learned from outside the repository
The leak this repository had was not carelessness with a secret. It was
somebody reaching for a real name because it was the example in front of
them -- a fixture built from a live database, an envelope pasted out of a
running container, a help text written while looking at the actual group.
Three different people-shaped mistakes, none of which a rule about "be
careful" would have stopped.

So it is a gate. The hard part is that the thing being checked must not
contain the list of what to refuse, or the check *is* the leak. It reads the
names at run time from outside: the photo pack's manifest, the message
database, the local .env. It never prints the word it matched, because this
output reaches CI logs. On a machine with none of that private data it says
"shapes only" out loud rather than passing quietly, and CI enforces the half
that needs nothing -- phone numbers, uuids and Signal group ids, compared
against an allowlist of values invented for the fixtures.

It runs on staged files, on the commit message -- where two of the leaks
actually travelled -- and over the whole tree in CI.

It paid for itself twice before it was wired up. The live test group's id was
in tests/test_config.py in two spellings, and truncated in three more files,
after four rounds of grepping by hand had pronounced the tree clean -- nobody
thinks to grep for base64. Then it refused the commit that introduced it,
because DOCS-privacy.md had used a real name as the example of a name.

An allowlist line may be scoped to one file, which is how the given names
that are also popes stay legal in popes.json and refused in every fixture
that has no business carrying them. The threshold is three characters,
because one of this group's given names is three characters long.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 23:28:29 +02:00
8df2646377 feat: weekly, monthly and year-in-review posts, keyed off the day reported
A Sunday closes a week, a month's last day closes the month, and
31 December closes the year. Keyed off the day being *reported* rather
than off today, which is the only reading that stays right when the bot
has been offline: catching up on a week-old Sunday now also posts that
week's summary, and there is a test asserting the five messages a
three-day outage produces.

One scheduler, a date predicate. Three background tasks firing at the
same wall-clock time into the same group is three independent ways for
an evening to go wrong, for no gain over an if-statement. A failed
period summary is deliberately best-effort and runs after the day has
been marked, so it cannot make the bot re-post the daily one tomorrow.

Also: the quote frame arrived while this was being written, and it
carries a trap. `quote` has its own nested `attachments` array holding
the *quoted* message's media -- so a text reply to somebody's photo has
attachments in the payload that belong to somebody else. Reading them
would credit the replier with a photo they did not post, in a group
where people reply to photos constantly, inflating one number without
ever looking wrong.

The parser already read the top level only and is correct. It is now
pinned by a test built from the exact captured frame, and written up in
DOCS-signal-envelopes.md, because "it happens to be right" and "it
cannot silently stop being right" are different things. This is the same
wrong-nesting-level mistake that once made the bot ignore its own
stickers.

test_bot.py went past the 250-line cap on the way, so the report and
catch-up tests move into files named for what they cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:56:25 +02:00
0f824c441e feat: block binaries from a public repo, and write down the real attachment frame
Two halves of the same ask: the message database is private and must
stay that way.

The database was already gitignored, and the event table is pinned by a
test that asserts its exact column set so no message body can be quietly
added. What was missing was any defence against a binary reaching this
*public* repo at all -- and the proof it was missing was sitting in the
tree: three `bottles/**/indexeddb.sqlite` files, committed in "register
the bot as its own Signal account". Their contents turned out to be
harmless (one row, 120 bytes, a userPreferences store from
developer.android.com), but the path into history was real.

So the shared gate from ~/utils is wired in, the three files are
untracked, and .gitignore now blocks *.sqlite3 along with its -wal and
-shm companions rather than only the one filename.

The second half is the attachment envelope, captured from the live API
rather than assumed -- which is what 0.0.3 taught. A caption-less PNG
posted in the real group confirmed `attachments[].contentType`, and
settled something that was not guessable: a photo with no caption sends
`message: null`. That is exactly the frame `should_count` used to value
at zero.

Verified end to end on production data, not a fixture: the frame at
19:31:16 was recorded as media_kind=image, media_count=1, char_len=0,
filed under 2026-08-30 hour 19.

The doc also records the fields nothing reads yet, and one open question
left deliberately open: `caption` is a field on the attachment and was
null here, so whether a captioned photo puts its text in `message` or in
`attachments[].caption` has not been observed. If it is the latter,
char_len is recording 0 for those. DEBUG stays on until a `caption: str`
settles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:32:41 +02:00
e102e413ff docs: record the traps this session hit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:44:28 +02:00
d5c63b423b feat: refuse to look healthy while deaf, and heal the container by itself
Today the bot was up, healthy and answering nothing for three hours. A host
firewall change (fixed in testsAndMisc 334c0cd9) cut every Docker container
off the internet; signal-cli lost Signal and reconnected in a loop from
15:32:50 onward. Nothing noticed:

  - the container's own healthcheck answered 204
  - /v1/groups answered 200 out of local state, so discovery saw no change
  - the bot's WebSocket to the container stayed OPEN, so the transport's
    reconnect logic never fired
  - /healthz answered {"status": "ok"} the entire time

The only symptom was silence, which is also what a quiet evening looks like.

signal_bot/liveness.py asks the one question that cannot be answered from a
cache -- GET /v1/devices, which needs a live round trip -- but only after
SILENCE_SECONDS (120) with no frame of any kind, so a busy group costs
nothing and an idle one costs one request per window. Three consecutive
failures and /healthz answers 503 with the reason. Verified end to end in
scripts/e2e.sh against the real process: 503 after 84s with the fake's
connection cut, back to 200 on its own 30s after restoring it.

scripts/signal_api_watchdog.sh + a two-minute user timer do the recovery the
bot cannot do for itself, since a wedged bot cannot restart the container it
depends on. Two tiers, so the steady state costs no Signal round trip at
all: the bot's own /healthz first, the authenticated probe only when that is
not 200. It stops after 3 restarts in 6 hours and escalates instead -- a
deregistered account or a blocked network is not fixable by restarting, and
a restart loop would hide it. Its own bash suite stubs curl and docker and
drives every branch including the give-up cap.

signal_bot/trust.py answers the other half: TRUSTED_INVITERS decides who may
put this bot in a group. The gate sits in GroupDirectory._permitted, which
both _join_pending and _adopt already route through -- gating only invites
would gate the door nobody uses, since prime_profile.sh exists precisely so
that an add becomes a direct join. Evidence is either the group-update
envelope, which is the only frame naming an inviter, or a trusted identity
being a full member in the listing. A refusal is logged once, with that
group's members, so the operator can see which string to add.

verify_allowlist moved to membership.py to keep discovery.py under the
250-line gate; it was already the module that owns MembershipError.

Verified live, by stopping the container by hand and touching nothing else:

  18:54:35  docker stop signal-api
  18:56:36  liveness: failure 1 of 3 after 123s of silence
  18:57:36  failure 3 of 3 -> /healthz answers 503 from 18:57:45
  18:58:18  watchdog: probe failed (1/2)
  19:00:28  watchdog: probe failed (2/2) -> docker restart signal-api
  19:00:36  liveness: receive path recovered after 8 failed probe(s)
  19:01:13  watchdog: signal-api is back

Six minutes, unattended, against three hours of silence this afternoon. All
four real groups are still adopted with TRUSTED_INVITERS=+48123456789 set,
and no group was refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
2026-08-29 19:01:55 +02:00
5d76adf4db feat: watch several groups, and answer only where you were asked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:03:24 +02:00
6bde3493a4 feat: rewrite the bot so it runs, and gate it like the other repos
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
2026-08-29 13:05:42 +02:00