The tally was one integer per person, deleted after each post. Nothing
dated survived it, so weeks, months, records, streaks and weekday
averages were all unanswerable. This replaces it with an append-only
per-message log holding metadata only -- never a message body -- and
rebuilds the nightly report on top.
What changes for the group: a photo posted without a caption now counts
(it was worth zero), stickers count, the bot's own messages no longer
do, people who said nothing appear with a 0, and the report covers the
previous full calendar day in Europe/Warsaw instead of "since the last
post" -- which used to merge two days whenever a group went quiet.
Notable decisions, each with the trap it avoids:
- Events and people are PERMANENT_TABLES: neither forget_group nor a
future SCHEMA_VERSION bump may drop them. Both paths used to cost a
day's counter and would now cost the entire history, and forget_group
fires on a listing that merely omits members.
- is_own comes from which envelope branch carried the payload, not from
comparing identities. The account is spelled as a uuid on some frames
and a +E.164 number on others, so any comparison misses one of them.
- The roster is stashed in refresh(), not _adopt(), which returns early
once the watched set stops changing -- i.e. always, in the steady
state.
- The roster matches on uuid OR number because /v1/groups spells kuhy as
+48123456789 while his messages carry an ACI uuid. Joining on uuid
alone would have listed him twice every night, once with 0.
- Members whose display name we have never seen are counted, not named:
their only spellings are a uuid or a phone number.
- (group, uuid, sent_at_ms) is the primary key, so a websocket
redelivery after a reconnect cannot inflate the counts.
- Days come from a stored Warsaw date string, never a timestamp range:
2026-03-29 is 23 hours long and 2026-10-25 is 25.
- Polish number agreement is a real rule with a 12-14 exception, so
"44 znaki" and "45 znaków" are both spelled correctly.
config.py and startup.py were two lines under the 250-line cap and both
had to grow, so config_env and startup_retry come out of them first, as
pure moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
_permitted feeds both _join_pending and _adopt, and _adopt treats anything
missing from its result as a removal: it drops the group, purges its tally
with store.forget_group and logs "no longer a member". So one listing whose
membership no longer vouches -- the same group has been seen spelling a
member as +E.164 in one row and as a bare uuid in another, and a row can
come back with no members field at all -- would silently un-watch a live
group and delete its counts, all three claims false.
That is exactly the narrowing discovery.py's own docstring forbids ("a
failed refresh never narrows the set"), reintroduced by the gate one commit
earlier. Adoption is itself the vouch, so an already-watched id short-
circuits the check; only groups the bot has never adopted are tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
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