The first pass shipped the tagging checkboxes and nothing else, which was a
regression dressed as an absorption: an album with no people.json has an
empty roster, so the checkboxes rendered an empty list and there was no way
to add anybody. The old page gated a setup form on `configured` for exactly
that reason. So does this one now.
Restored, behaviour-for-behaviour:
- The roster grammar, in src/api/setup.ts. One person per line, aliases by
comma, `=` for a combination with `+` meaning both and `|` meaning either.
Keys are reused for anyone already listed and otherwise handed out one past
the highest in use -- never the count, or a key deleted by hand gets
reissued and inherits somebody else's photos. Mirrors pack_people.next_key.
- The add-person box, for the usual case of noticing a new face in the photo
already on screen.
- `--untagged`, which was a no-op: it is a view filter, and anything seen
this session stays navigable, or tagging the photo you are looking at
removes it from the list you are walking.
DOCS-web-ui.md claimed group ids were "picked, never typed" while settings
rendered a plain text box. That was the mitigation cited for accepting an
.env editor at all, so the claim is now backed by a <select> filled from
/api/groups -- and a failed listing leaves it empty rather than unhandled.
parseSetup returns a result instead of throwing, and readCombos has one
return type: a try/catch in the view carried an "and if it was some other
error" branch that could not happen, and an untestable branch is an untested
one. Same reason the album view splits into roster.tsx and tagger.tsx.
Still 100% on all four metrics with no exclude list, and verified against the
real album: 185 items, 4 groups in the picker, guard still answering 421.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.
No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.
TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.
Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
is exactly what it is for.
The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
--untagged hid an item the moment it gained a tag, including from the back
button, so the one photo you most want to return to -- the one you just
mis-tagged -- was the one you could not reach. The only way back was to
restart without --untagged.
Anything drawn this session stays navigable. Forward still skips work done
in earlier sessions, which is the point of the flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
The shell entry points got this in 99aa194; running the .py directly still
died with ModuleNotFoundError, which is the command the file's own docstring
had been telling people to use for months.
tool/venv_bootstrap.py creates .venv, installs the project and re-executes
the same command line through it. It is stdlib-only by necessity: it runs on
an interpreter where this package does not import yet, so it cannot route
through signal_bot.external, the one module allowed to start a process. That
is why it gets the second scoped per-file-ignore rather than the rule being
bent -- kuhy's call. test_external.py now asserts a closed list of two, so a
third file still fails the gate.
Two things that are less obvious than they look:
It runs on import rather than exposing a function to call. A call would have
to happen before the caller's own third-party imports, and code before
imports is E402 -- so a call-based design needs a lint suppression in every
file it protects. Importing the module is the whole interface.
And isort would sort "venv_bootstrap" after "fastapi", silently breaking the
ordering the module depends on. It gets its own isort section ahead of the
standard library, so the formatter enforces the order instead of undoing it.
Caught while verifying: ensure() used one check to decide both whether to
re-exec and whether to install. The interpreter you typed never grows a
fastapi, so every launch reinstalled the project first. Splitting them
(ready() probes the venv, missing() probes the caller) takes a warm run from
a full pip install to 0.47s. Pinned by a test.
Verified on a clone with no .venv: first run installs and prints one line,
second and third print nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
"python tool/tagger.py --untagged" died with ModuleNotFoundError: fastapi.
The dependencies live in .venv and nothing said so. run.sh had the same hole
and answered it by printing the two commands to type and exiting 1 -- which
is the same rule broken one step further along: a script owns its
dependencies, and making the user be the installer is not owning them.
scripts/ensure_venv.sh creates .venv and installs the project editable if the
interpreter cannot already import fastapi and signal_bot. Idempotent and
cheap: the normal case is two file tests and one import check, 0.17s, so
entry points call it unconditionally. tag.sh is the tagger's entry point and
run.sh now uses the same helper.
Verified on a clone with no .venv at all: ./tag.sh --help created the
environment, installed the project and printed the usage.
Editable on purpose -- a later git pull updates what the tagger runs, which
is the same reason install.sh does it that way in the sibling repos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.
--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.
Two things that made "strip on import" impossible until now:
media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.
And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.
The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.
--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.
Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
You asked for !add_photo in testbot without the real album being reachable
there. One album cannot do that: its people.json "groups" list *is* the
privacy guarantee, so making the commands exist in a second group necessarily
makes the real photos postable there. So the bot now loads several albums.
PHOTO_PACK_DIR takes a comma-separated list, the spelling GROUP_IDS and
TRUSTED_INVITERS already use. Each album carries its own group scope, its own
people, its own tags and its own custom labels; they share nothing but the
process they run in.
That makes trigger collisions between albums legal for the first time, and the
registry had to learn it. Two albums are two different sets of people and both
may reasonably call somebody "!alfa" -- no message can ever mean both, because
a command a group is not scoped to is not a command there at all. So a trigger
may be claimed more than once, and DuplicateTriggerError now fires only where
the group sets *overlap*. Overlapping albums are refused at startup rather than
resolved by registration order: a coin toss about which private album receives
a photo is the outcome this whole design exists to prevent.
Two consequences worth naming:
- registry.resolve() takes an optional group. Without one it still answers
"does anything claim this word", which is what pack_add's mint check wants;
with one it answers "what runs here", which is what the dispatcher wants.
resolve_with_args no longer resolves-then-checks, because with two claimants
the first one found may be the wrong group's.
- CommandContext moved to signal_bot/context.py to keep registry.py under the
250-line cap. A filing change: that file is "which word runs what", this one
is "what the thing that runs may do".
DOCS-add-photo.md is likewise a split, not new prose -- DOCS-photo-pack.md had
grown past the cap and the ingest path is its own subject now.
Verified live: the unit serves both albums, the real group resolves its 13
album commands and testbot its 7, and !losowe / !alfa / !dodaj all fire in
testbot through the live dispatcher. 997 tests, 100% branch coverage, 28 e2e
scenarios.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own
groups and it lands in the pack tagged with both, retrievable through !alfa
and the new !losowe immediately -- no restart, and no tagger session on the PC.
A label matching nobody becomes a custom tag, and !losowe is the only door to
one, which is what keeps a live swap a *data* swap: it can never register a
trigger or widen a command's groups.
No typed label travels. Labels become pseudonym keys in a new registry
arg_resolver, at the dispatcher, before the line that logs the command name --
so no handler, journal entry, command_uses row or reply body ever holds a word
somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1".
Stripping is exiftool with a *positive* check on the output, never an exit
code. ffmpeg was tried first and fails silently on photos: given a JPEG with a
real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0.
The surviving-tag allowlist is per tag *name* rather than per group, because
measurement showed a group-level "must be empty" rule rejects every format
except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime
tags that are the container itself. Codec configuration (CompressorName,
VendorID, the HEVC parameter set) is on the list by decision: CompressorName
lives in the avc1 sample description and exiftool cannot write it at all, so
the alternative was refusing every video and every iPhone HEIC. Location and
identity tags are still refused, and the e2e run re-reads the stored files to
prove a QuickTime geotag did not survive.
.webm and .svg stay on the format allowlist and are stored *unstripped*:
exiftool 13.55 can write neither, and its SVG reader does not even surface
<metadata>. That is a deliberate, documented hole, not an oversight.
Two things this had to defuse along the way:
- personal_data_sources now reads custom.json, so "!add_photo kot <photo>"
would have outlawed the word "kot" and refused every future commit, fixable
only by hand-editing an album the committer cannot see. Minting now refuses
a word that is already a command, and the gate subtracts the repo's own
alias vocabulary from custom labels only -- curated people.json names are
never excused.
- signal_bot/external.py is now the single subprocess site in the repository,
so ruff's S603 needs exactly one scoped per-file-ignore instead of one per
module that shells out. A test asserts nothing else imports subprocess.
A failed add changes nothing: media and the three manifests are written, the
album is reloaded, and only a load that succeeds is published; a load that
fails rolls all four back and keeps serving the pack that was already working,
without raising -- Restart=always would turn a ConfigError into a loop.
977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh
gains 8 scenarios driven against the real exiftool on media generated at run
time, and asserts the invariants a reply cannot: that a refused upload is not
stored, that a message with no attachment leaves custom.json byte-identical,
and that signal-cli's own copy of every ingested attachment is gone.
Closes TODO-add-photo.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
This repository is public, and five things in it were not invented.
The worst was mine, today: the tagger's help text used the group's actual
first names and nicknames as its worked example, in a file that is on
GitHub. It uses ala/basia/celina now, like every other example here.
The rest predate this work. DOCS-signal-envelopes.md carried the operator's
own phone number and the bot's, captured from a live envelope; conftest.py
had the same number as its trusted inviter; and test_trust.py pinned a group
member's full Signal uuid as a fixture. Numbers keep their shape so the
documents still show what a real envelope looks like, but no digit of them
belongs to anybody.
Comments that reached for a real first name to illustrate the diacritic case
now use an invented one.
None of this removes them from the history, which is a separate decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
Clicking "dalej" on the last photo did nothing, because move() clamped at
the end. Everything had in fact been saved -- each toggle writes tags.json
on the spot -- but the only way to find that out was to stop the tagger and
read its summary, so the last photo answered "did that work?" with silence.
The end of the album is now a screen: it says Gotowe, prints how many photos
each command has, and offers a way back into the photos.
The counts are distinct files rather than a sum per reading. A photo showing
two of the three people an "either of them" entry covers is one photo that
command can return, and summing claimed it twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
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
The first group in the account's listing was checked by default, and the
first group in this account's listing is a test group. Somebody who typed
the names, hit save and never looked at the radios would have scoped a
private album to the wrong chat -- silently, because the commands then
simply do nothing in the chat they were meant for. Nothing is pre-selected
now, and saving without a group is refused.
Also replaces a test of mine that failed one run in sixteen: it drew a
two-way random choice five times and demanded both outcomes. The property
worth pinning is that a choice lands on a reading that *has* photos, which
is deterministic when only one of them does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
Two things it could not do, both found trying to preview this release.
It read SELFTEST_GROUPS off os.environ before anything had loaded .env, so
its own documented invocation refused every group unless the variable had
been exported by hand. Fail-closed, so it never posted anywhere wrong -- it
simply never posted at all.
And it always rendered the delta from the *running* build, which is empty
once the bump is committed. Three releases landing together are still one
announcement to a group recorded three versions back, so --seen renders what
that group will actually be shown rather than what the current build would
add.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
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
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
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