Commit Graph

19 Commits

Author SHA1 Message Date
86ea1b3220 fix: the evening report is one message, not one per thing it has to say
Tomorrow's run would have posted four times into the same group: the day
the bot owed from the catch-up, the week that closed inside it, today, and
August's summary. Four notifications in one evening is how a daily tally
becomes something people mute.

They are blocks in a single message now, separated by a rule because Signal
renders no markdown. That also makes the send atomic: the group gets all of
it or none of it, and nothing is marked either way, where before a day was
marked before its own weekly summary had even been attempted -- so a summary
that failed was simply lost.

The composition moved to reporting.py, both because bot.py hit the 250-line
cap and because it is worth having pure: "what would the group be told?" is
now answerable with no transport and no send.

The closing-time caveat reads the clock instead of REPORT_HOUR. The
scheduled run fires at the configured minute and still says exactly that; a
run started by hand at some other hour now tells the truth rather than
repeating a time nobody waited for.

The 0.1.6 entry gains a bullet rather than taking a new version: no group has
been told about 0.1.6 yet -- every one of them is still recorded at 0.1.5 --
so the release it describes has not happened from their side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:49:51 +02:00
35102be863 release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it
Per-person photo commands, where the mechanism is public and the album is
not. Nothing identifying is in this repository: who the people are, what
fires their photos and which file shows whom all live in one directory
outside it, and the tests, the docs and the offline e2e run use three
invented people instead.

Several names in one message intersect -- "!alfa beta" is a photo with both
-- and where an entry offers a choice the bot picks among the readings that
actually have a photo, so a question the album could answer is not refused.
A combination nobody photographed gets one line rather than silence, which
from the group's side is indistinguishable from a broken bot.

Scoping is enforced in the dispatcher, not in the handler: a command the
group is not allowed resolves to nothing, so it is not answered, not counted
as a command, and never named in a log line outside its own groups. A
handler that quietly returned would leak all three. Commands are named by
pseudonym (pack:p1), so the journal and command_uses say somebody asked for
a photo and nothing about whose.

Video works: the attachment goes out as data:video/mp4;filename=...;base64,
which is the container's own spelling, read off its /swagger/doc.json.
Told it is a photo, Signal renders a clip as an unplayable file.

tool/tagger.py serves one page on loopback to fill the album in -- names are
typed into the page, never as arguments, and it prints how many photos each
command would have when it stops, because a command with zero is dead on
arrival.

Also renames the real people who were still sitting in the stats test
fixtures. They remain in the history; the point is that nothing new is
added to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:35:41 +02:00
9db3687752 release: 0.1.7 «Callistus I» — a version now says which pope, and when
"Wersja 0.1.7 «Callistus I»" told nobody whether Callistus was the
fifteenth pope or the hundredth, and the changelog was the one place the
ladder is actually read. pope_line() derives both facts from popes.json --
the ordinal, and the `reign` string exactly as the table spells it, so the
one reigning pope keeps his single year instead of being padded into a
range he never had.

It appears in all four places a version is named: the group announcement
(header and every block of a backlog), each section of DOCS-changelog.md,
/healthz, and the !papiez caption, which now describes the release's pope
in the same sentence it uses for every other one.

The changelog's copy is checked rather than trusted, like the heading name
already was: a test walks the raw file and compares each line against
popes.json, so a hand-edited ordinal fails here instead of in a group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:12:09 +02:00
50bd390b00 release: 0.1.6 «Zephyrinus» — 21:37 reports today, not yesterday
The scheduler fired exactly on time tonight and posted nothing, because
publish_report covered the previous full day and the bot's first recorded
day was today: "no messages in group.WTI5dVptbG... on 2026-08-29". Correct by
code, a day late to everybody reading the group.

So the report covers the day it runs on. The count closes at the report
time and render_day says so, because a total that quietly omits three hours
is worse than one that admits to it. Everything that made a late day
recoverable stays: the per-group marker still prevents a double post, and
days_to_report still yields every day an outage swallowed.

closing_periods keeps taking a day rather than reading a clock, so a week
now closes on the Sunday evening it ends -- off a day that is itself still
running -- and a catch-up on a week-old Sunday still gets it right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:09:13 +02:00
ba54092e0a feat: preview the next release's announcement in a test group, without releasing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:41:44 +02:00
3d6ae0680c feat: releases are named in Latin, and land with the pope's portrait and article
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:28:41 +02:00
ca38147576 release: 0.1.4 «Eleuteriusz» — the list of commands lives in the chat now
Thirteen commands, and the only list of them was a README table nobody in a
Signal group can read — which had itself gone stale two releases earlier,
still advertising four commands after nine more had shipped. 0.1.1 gave each
command an output that says what the command is; that only helps once you
already know to type it.

!pomoc (also !pomocy, !help, !komendy, !commands) prints all fourteen,
grouped, one Polish line each.

The interesting part is that it cannot go stale. CommandRegistry.register now
takes the description as a REQUIRED argument, so a command that does not
explain itself does not compile; and render_help prints any command that
help_command.GROUPS forgot to file, under no heading, rather than dropping
it. Two independent ways to fail closed, because the README proved that a
hand-maintained second list is a list that drifts.

Command also gained `primary`, the first raw alias. The trigger set could not
supply it: those are folded skeletons in an unordered frozenset, so all that
survives of "!papież" there is "!papiez", and the styled Unicode aliases come
out as zalgo. aliases.json already lists the canonical Polish spelling first.

The full trigger table moves to DOCS-commands.md. It did not fit in README —
that file was already sitting exactly on the shared 250-line cap, and the
gate caught the overflow on the first commit attempt.

Exercised in testbot at the unbumped version before this bump: the live
dispatcher ran it and posted the list, journal clean. pytest 708 green at
100% branch coverage, e2e green including a new scenario that asserts !pomoc
mentions a command registered by a different module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 21:08:02 +02:00
116671236a fix: a refused command no longer re-arms its own cooldown
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:14 +02:00
0c29583db3 release: 0.1.2 «Anicet» — the favourite hour is a real one
Ships the fix in 40538b8, which had been running unreleased since the
live database was migrated.

!ja was reporting the same hour, 12:00, to every member of the group.
That was the placeholder hour on the 342 messages carried over from the
pre-0.1.0 counter, which have no timestamps — and favourite_hour was the
one query that never excluded the backfilled day, so the synthetic noons
outvoted every real evening. The genuine answer is 19:00-21:00.

The absent hour is now -1 rather than a real-looking 12, and hour
queries filter it out, so forgetting the exclusion can no longer invent
an answer. Somebody whose only messages are backfilled gets no hour line
at all instead of a made-up one.

Exercised in testbot before the bump; kuhy confirmed the release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 20:18:22 +02:00
ad93bbfdf7 release: 0.1.1 «Pius I» — commands that explain themselves
Two things the group hit within minutes of 0.1.0, both reported by real
users rather than found by a test.

Somebody typed !korona, got "Za mało danych — wróć za kilka dni." and
nothing else. That answer tells them neither what the command does nor
what it will eventually show, which makes it worse than useless on a
release day when almost every command is empty by design. Every command
now leads with what it is, and the empty answer says what it will show
once there is history and which date it is counting from.

And somebody asked whether "Najczęściej piszesz o 12:00" meant the whole
hour 12-13. It did, and the label did not say so. It now reads "między
12:00 a 13:00". Same fix in kind for the nightly summary line, which
said "średnio 47 znaków" without saying per what -- now "na wiadomość".

A one-line wording fix takes the next pope exactly like a feature does,
per DOCS-changelog.md. Exercised in testbot at the unbumped version
first: !korona, !ja, !dni and !rekordy all ran clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 20:09:14 +02:00
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
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
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
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
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
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