Commit Graph

35 Commits

Author SHA1 Message Date
be24243507 release: 0.1.0 «Hygin» — the statistics release
The tally becomes a message history. Ordinal 9 lands on 0.1.0 by
arithmetic rather than by choice, which is a pleasant coincidence for a
release this size.

What changes for the group, in the four bullets they will read: photos
and videos now count (a caption-less photo was worth zero, so everyone's
numbers jumped), people who said nothing appear at 0, nine new commands,
and the bot remembers history from 30 August 2026.

What changes underneath: an append-only per-message log holding metadata
and never a body; days that are calendar days in Europe/Warsaw rather
than "since the last post"; records, crown streaks and weekday averages;
weekly and monthly summaries; and reports that survive an outage instead
of being lost.

Exercised in testbot before this bump, per DOCS-changelog.md: all nine
commands ran clean, and kuhy confirmed the rendering on a real phone --
the one check that could not be done from here, since Signal's
proportional font is why the columns are separated by a dash rather than
padded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 20:00:58 +02:00
dedc153cb4 docs: describe what the bot actually does now
The README still described a counter that increments and resets, which
stopped being true two commits ago. The behaviour that replaced it does
not fit in a README section, so it moves to DOCS-statistics.md: what is
recorded and what deliberately is not, why a day is a stored Warsaw date
string and what breaks if it stops being one, what happens to counts and
to reports across an outage, why 2026-08-30 is marked and excluded from
every average, and how to read a wire format with LOG_LEVEL=DEBUG.

README keeps eight lines and a pointer, which puts it at exactly 250.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:58:25 +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
bfc2bcb3b8 feat: nine commands for asking the statistics questions
!staty, !wczoraj, !tydzień, !miesiąc, !rok, !rekordy, !korona, !dni and
!ja, under 29 triggers with both Polish spellings of each.

None takes an argument. registry.resolve matches the whole folded body
against a trigger, so "!ja ala" resolves to nothing; teaching it to
split on whitespace would silently make "!kot pls" fire where today it
does nothing. Each question gets its own trigger instead.

!wczoraj renders through the same code path as the nightly post, which
is not a convenience: the bot cannot trigger its own 21:37 report, so
without byte-identical output there is no way to check the headline
feature of this release short of waiting for the evening.

Two guards worth naming:

- test_every_trigger_is_unique builds the whole registry.
  DuplicateTriggerError subclasses ValueError, and ValueError is in
  startup_retry.RETRYABLE -- so a colliding alias would not crash the
  bot, it would retry forever logging "signal-cli not reachable yet".
  Checked at test time instead of never.
- No trigger may contain a digit: fold() maps 0134578 onto letters, so
  "!7dni" would resolve as "!tdni". Also asserted.

Every command answers on an empty history rather than crashing, which is
the state the group will actually be in for the next few days: records,
the crown and the weekday table all say "za mało danych" because their
only day so far is the backfilled one, and they exclude it.

Verified against a copy of the live database -- all nine render
correctly on the real group's data.

One test bug caught while writing it: it seeded "today" and excluded
2026-08-30, which on the day it was written were the same date, so it
passed for the wrong reason. Fixed dates now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:49:41 +02:00
0e0c9ba619 feat: the nightly post notices things
Around twenty things could be said about a day. Saying all of them is a
wall nobody reads; saying whichever three are computed first is
arbitrary. So each candidate declares a priority and a magnitude and the
picker takes the top few, with two rules that make the result readable:
one line per family, so "set a record", "had 60% of the day" and "kept
the crown" do not become three sentences about the same person; and
fully deterministic ordering, so a day renders identically twice.

Nine candidates so far: personal records with what they beat, the
group's busiest day ever, a photo record, crown streaks (naming everyone
when it is shared), a silence broken, a monologue, one person dominating
the day, an unusual showing for that weekday, and a night owl.

Every candidate is (DayFacts) -> Highlight | None with the same
signature. On day one of this release almost all of them return None,
and a shape where "not yet" is the ordinary case is much easier to get
right than one where every guard is bespoke.

Two off-by-ones fixed by writing the sentence out and checking it:

- "odezwał się po 3 dniach ciszy" was counting four. The day somebody
  last spoke is not silent, and neither is the day they came back on, so
  the gap excludes both ends. The function is now named for that.
- A personal record on somebody's first ever day was being announced as
  a record. It is, trivially, and saying so every time is noise.

Verified against the live database, read-only on a copy: the real group
renders correctly at 324 messages, claims no average length for the
backfilled day, and fires no highlights at all -- which is right, since
there is one day of history for them to stand against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:43:55 +02:00
a9ffd02f33 feat: periods, records and the crown
The queries the statistics are made of, all derived rather than cached --
two sources of truth for one number is how a leaderboard comes to
disagree with itself, and this history is small enough that recomputing
is free.

Periods take an inclusive span of stored Warsaw day strings, never a
range of timestamps, so a daylight-saving change cannot move a boundary.
Trend reports None rather than 0 when there is no previous period:
"nothing to compare against yet" and "no change" are different
statements and the group will read them differently.

A tie shares the crown and keeps every tied run alive, per kuhy's call.
Streaks count *recorded* days, so a day nobody spoke does not end a run
-- there was no crown to lose -- and neither does a day the bot was
offline for, which it could not have judged.

Every record excludes the backfilled day. It is a 22-hour window filed
as a day, and leaving it in would hand somebody a personal best they
never set.

Two bugs found by running the code rather than reading it:

- current_streaks reported every streak as zero. Ending somebody's run
  dropped them from the tally along with the length they had already
  accumulated, so the surviving dict was empty by the time it returned.
  Now the run ends and the count it reached is kept.
- daily_winners depended on SQLite's grouping order to find a day's
  leader. It happened to work because uuids sorted conveniently in the
  fixtures. Ordering by messages DESC makes the first row per day the
  leader by construction, which is both correct and obviously so.

No suppressions: the period queries compare an exclusion parameter
against '' instead of pasting half a WHERE clause into an f-string,
which is a SQL-injection shape even when every input is a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:38:54 +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
50f6a587c2 feat: the bot can describe an envelope without quoting anybody
DOCS-signal-envelopes.md exists because this wire format is not obvious
and guessing at it has cost real bugs -- the sticker read off the wrong
nesting level, and 0.0.3's !gekon shipping green on an assumption nobody
had checked against the live API. The recurring cost is that answering
"what does a frame with an attachment actually look like?" needed a code
change, a restart, and somebody sending one at the right moment.

So at DEBUG the dispatcher now logs each frame's *shape*: keys and value
types, never values. Two fields keep their values because they are wire
format rather than anything a person said -- a MIME type and a groupInfo
type. A message body, a name, a number, a uuid, quoted text and a
sticker id all come out as "str". An album collapses to one element and
a count instead of printing the same dict twenty times.

Verified against a live frame: the body, the quoted text, the uuid and
the phone number do not survive, and contentType does.

This also had to fix DEBUG being unusable. LOG_LEVEL=DEBUG set the root
logger, which turns on httpx, httpcore and asyncio; the bot polls a
group listing every 30 seconds, so the one interesting line arrived
buried and journald started dropping. Libraries are now held at WARNING
whatever the bot is set to -- at INFO httpx was already emitting an
"HTTP Request: GET ... 200 OK" line every thirty seconds, forever.
Measured on the running unit: 17 library lines per minute down to 2.

Two bugs in that setup found by its own tests rather than by review:
basicConfig is a documented no-op once the root logger has a handler, so
the level silently did not apply under pytest; and the floor belongs at
INFO too, not only below it.

A self-send does not produce a capturable attachment envelope, by the
way -- signal-cli emits no syncMessage for a send made over the same
connection. Two probe images returned HTTP 201 and no frame came back,
so the inbound shape still needs a human to post one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:29:06 +02:00
63f262bddd feat: survive an outage, and keep the counts that predate the event log
Two gaps kuhy pointed at, both about not losing messages the bot was not
awake for.

Missed *counts* already worked and now have a test saying so: an event
is keyed by Signal's own send timestamp, signal-cli keeps receiving in
json-rpc mode while the bot is down, and the replay on reconnect is
absorbed by the primary key. Messages sent during an outage come back
filed under the day they were actually sent.

Missed *reports* did not. A bot that was down at 21:37 never posted that
day and never went back for it. The last day reported is now written
down per group, so returning from an outage posts every day it owes,
oldest first, bounded at a week. A day nobody spoke on is marked settled
rather than reconsidered forever; a day whose send *failed* is
deliberately left unmarked so the next run retries it.

The pre-0.1.0 counter is carried over rather than discarded: 342
messages across four groups, 319 of them in the real group. What the
counter cannot say is when, so before assuming, four places were checked
for a real inbound timestamp -- journald (commands only), signal-cli's
account.db (19 tables, none inbound; message_send_log is the bot's own
outgoing resend buffer), Signal Desktop (unlinked, empty, and it does
not backfill on link), and signal-api/attachments (the one dated
artifact). The attachment mtimes put 12 files on 29 Aug against 79 on
30 Aug inside the counter's window, which is why the whole counter is
filed under 30 Aug rather than split.

That day is marked in group_meta, and a marked day claims no average
message length -- those messages have a count but no lengths, so
"średnio 0 znaków" would be a measurement nobody took. The mark is also
what will keep the day out of personal records: a merged 22-hour day
would otherwise hand somebody a record they never set.

render_day's options move into a DayView value object; the keyword list
had reached eight and this renderer is about to be shared by several
commands that each want a different subset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:23:53 +02:00
dc1995b558 feat: the message event log, and the nightly report built on it
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
2026-08-30 19:14:43 +02:00
558dd297a4 release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00
0d7cb99f5c release: 0.0.6 «Sykstus I»
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:44:42 +02:00
72482629d9 fix: a signal-api that is not up yet no longer kills the bot at boot
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:23:42 +02:00
a1855a453d release: 0.0.5 «Aleksander I»
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:00:27 +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
b70730fc7a fix: commands resolve when typed on another alphabet's keyboard
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:22:43 +02:00
6f5a703824 release: 0.0.4 «Ewaryst»
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:41:31 +02:00
a413ab59ec fix: !gekon really is random (iNaturalist ignores order_by=random)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:35:53 +02:00
f4efcd97e2 release: 0.0.3 «Klemens I»
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:28:38 +02:00
79dab5cc01 feat: !gekon posts a research-grade gecko photo
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:26:40 +02:00
a202317072 feat: answer the message that asked, not just the room
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
2026-08-29 23:01:01 +02:00
f92502d18b feat: name every release after a pope, and tell the group when one lands
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
2026-08-29 22:51:20 +02:00
14c17a11b4 chore: drop the last references to a command this repo no longer has
The vote and rate-limit gates were salvaged from an old command that is gone;
the docstring still named it, and test_store.py used its name as the arbitrary
string for the vote-dedup fixture. Neither reference means anything now, and
the pre-rewrite history that did contain it has been dropped, so these were the
only two left in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-29 22:32:12 +02:00
a9f6875abe fix: never let the trust gate un-watch a group it already adopted
_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
2026-08-29 19:04:39 +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
0c4b259c17 feat: discover groups from the account, so adding the bot is the whole setup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:30:08 +02:00
f4f8c32329 fix: make prime_profile.sh fail loudly instead of exiting 0 or dying silently
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:09:21 +02:00
bb72f0ae93 fix: commit the five modules the last commit only referenced
The wrap-up script stages dirty *tracked* files, so every newly created
file in the multi-group change -- the group-id module, the membership
verifier, their tests, the isolation scenario and prime_profile.sh --
was listed as untracked and then left behind. The tree compiled locally
because the files exist on disk; CI checked out a repo where config.py
imported signal_bot.groups and nothing provided it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 15:05:18 +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
b48c945d89 feat: make the bot actually reply on Signal, and gate every way it went silent
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.

Four failures, each of which left the bot looking perfectly healthy:

- Invited is not joined. Signal only adds an account outright if the adder
  holds its profile key; with no profile the add becomes a pending invite,
  and signal-cli cannot accept one addressed to a phone number ("Cannot find
  service ID for self"). Group listed, websocket up, /healthz OK, zero
  messages. register_bot.sh now sets the profile *before* the add, and
  startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
  every attachment 400s with "Can't load library: awt". Both mode guards now
  demand exactly json-rpc, and start_container replaces a wrong-mode
  container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
  envelopes carry what that decodes to -- so the scoping gate rejected the
  bot's own group. e2e.sh now models both spellings; reverting the fix fails
  4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
  a .env. The bind address comes from Config now.

A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.

The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.

100% branch coverage, 230 tests, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 14:37:27 +02:00
7984c65f8a feat: register the bot as its own Signal account, no phone app needed
Shelter cannot work on this phone, and it would not have helped anyway.

It cannot work: setting a Device Owner makes Android enable
no_add_managed_profile, no_add_private_profile and no_add_clone_profile on
every user, which kills Shelter, Island, Private Space and app cloning
alike. Verified on-device rather than inferred -- `pm create-user
--profileOf 0 --managed` returns "Cannot add user: no_add_managed_profile is
enabled". This is the Android 11 COMP deprecation, enforced in three places
in DevicePolicyManagerService and unchanged through Android 16.

It would not have helped: a second Signal *app* is not a second Signal
*account*. Every account needs its own phone number, so the second number
was always the real requirement -- and once you have one, signal-cli can
register it as a PRIMARY device with no phone app, no QR scan and no work
profile at all. That is strictly better than both the linked-device setup
and the Shelter plan: the bot cannot read personal DMs and never posts as
the account owner.

scripts/register_bot.sh drives that flow. It gates on signal-cli >= 0.14.7
rather than hoping: stable container tag 0.100 bundles 0.14.5, and on
2026-03-06 Signal made the post-quantum ratchet mandatory and de-registered
every account whose client lacked it -- a stale client is how a bot dies
silently months later. Verified that the recommended CI tag really does
bundle 0.14.7 instead of assuming it.

Rehearsed offline, not written blind. fake_signal grew /v1/accounts,
/v1/register/{number}, /v1/register/{number}/verify/{token} and
/v1/groups/{number}, so the whole runbook including every error path
(missing captcha, stray verify, wrong code, unregistered number, empty group
list, out-of-range and non-numeric picks) runs against the fake. The picker
was proven to resolve the group the user actually pointed at.

Prompts now read from $SIGNAL_TTY instead of /dev/tty directly. A hard-coded
/dev/tty made the interactive path untestable without a real terminal; the
seam is the same idea as the injected clock and transport on the Python side,
and is what let the picker be rehearsed at all.

live_smoke.sh and register_bot.sh share scripts/lib_signal.sh so the two
cannot drift, and shellcheck now runs with -x so the sourced lib is actually
linted from its callers.

201 tests, 100% branch coverage, all 23 gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:41:13 +02:00
79649737a1 feat: gate suppressions mechanically instead of relying on discipline
This repo has zero `# noqa` and zero `# type: ignore`, and the only reason it
stays that way is a gate. Nothing was enforcing it: testsAndMisc has a
no-noqa pre-commit hook and signal-bot did not, so the rule lived entirely in
whoever was editing. During this rewrite a suppression was reflexively typed
three times before being caught by hand, which is exactly the failure a hook
makes impossible.

Blocked in both places: a pygrep pre-commit hook, and a CI step mirroring it
so a commit made without hooks installed cannot land one on main. Verified
by canary: a file containing `# noqa: F401` exits 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:09:16 +02:00
7548a60cf1 fix: pin setuptools to 84.0.0, closing the dependabot alert
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
2026-08-29 13:08:02 +02:00
c9061b27a8 fix: make shellcheck agree between local and CI, and fix the group picker
CI failed on shellcheck where the local hook passed: the runner's shellcheck
reports SC2015 (`A && B || C` is not if-then-else) and mine did not. Both now
run with --severity=style so a version skew cannot pass locally and fail in
CI. The cleanup traps are plain ifs, which is what the finding was about --
`kill` on an already-dead pid makes B fail and C run while A is true.

live_smoke.sh choose_group had two bugs that only surface when it is actually
run, neither reachable from the offline suite:
- it fetched /v1/groups twice, once to display and once to resolve the picked
  index, so an unstable order would hand back a different group than the one
  shown. Fetched once into a variable now.
- `read -r -p` wrote its prompt to stdout, which the caller captures with
  $(...), so the prompt text would have been swallowed into the group id.
  Prompt and listing both go to stderr; stdout carries only the id.
Also validates that the pick is a number and in range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:07:28 +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