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
11 KiB
The photo pack
A pack is a private album the bot posts from: !alfa returns a random photo
of alfa, and !alfa beta returns one with both of them on it.
Nothing about a real pack is in this repository, and nothing ever will be. Who the people are, what somebody types to get their photo and which file shows whom live in one directory outside the repo. This document, the tests and the offline end-to-end run all use the same invented people — alfa, beta, gamma — so the mechanism can be public while the album stays private.
Where it lives
~/.local/share/signal-bot/pack/
people.json # the commands, and the only place names exist
tags.json # which file shows whom
media/ # the photos and videos themselves
Found in this order: PHOTO_PACK_DIR, then $STATE_DIRECTORY/pack (what
systemd hands a system unit), then $XDG_DATA_HOME/signal-bot/pack.
StateDirectory= is deliberately not in the unit this repo installs:
for a user unit systemd resolves it below $XDG_STATE_HOME, which is not
where the album is. Discovery finds the same directory, and leaving
PHOTO_PACK_DIR unset is what keeps a not-yet-filled-in pack from turning
Restart=always into a restart loop.
Two absences, deliberately different:
- a pack nobody named that is not there, or that has no
people.jsonyet, is an installation without an album: the commands do not exist, and that is the normal case on every machine and in the whole test suite; - a pack named in
PHOTO_PACK_DIRthat is missing, or whose manifest cannot be honoured, fails startup.load_configraising is an answer, and a bot that came up healthy and said "no such photo" forever would not be.
people.json
{
"groups": ["group.WlRKbExYZGhkR05vWldRdA=="],
"people": [
{"key": "p1", "triggers": ["!alfa", "!alfka"]},
{"key": "p2", "triggers": ["!beta"]}
],
"combos": [
{"key": "c1", "triggers": ["!oboje"], "all_of": ["p1", "p2"]},
{"key": "c2", "triggers": ["!ktokolwiek"], "any_of": ["p1", "p2"]}
]
}
groupsis the whole privacy guarantee. In any other group these are not commands at all: not answered, not counted as a command, not named in a log line. A handler that quietly returned would still leak all three, which is why the dispatcher enforces it —registry.resolve_with_argstakes the group and gives back nothing outside it.keyis a pseudonym, and the only identifier that reaches the journal or thecommand_usestable. A command ispack:p1; reading either says somebody asked for a photo and nothing about whose.all_ofmeans one photo with all of them;any_ofmeans either, and where a choice exists the bot picks among the readings that actually have a photo, so a combination that could be answered is not refused.- Triggers may not contain a digit —
folding.pymaps0134578onto letters, so!alfa7is not what you typed. Two spellings that fold the same are fine within one entry (!celina/!celinąis one person written twice) and refused across two.
tags.json
{"alfa.png": ["p1"], "both.png": ["p1", "p2"], "clip.mp4": ["p1"]}
A tag naming a file that is not in media/ fails startup: a renamed file
would otherwise drop out of the album in silence. An empty list means "seen,
nobody in it", which is how the tagger records a photo you skipped.
Videos work. The attachment goes out as
data:video/mp4;filename=<name>;base64,<data> — the spelling is the
container's own, from its /swagger/doc.json — because told it is a photo,
Signal renders a clip as a file nobody can play.
custom.json
{"t1": ["wakacje2019"], "t2": ["gorybieszczady"]}
Labels !add_photo learned from the chat that match no person. The key is a
pseudonym in the same family as p1 — it is what reaches tags.json, a log
line and a reply — and the label is the only place the typed word is kept.
Custom tags define no commands: !random <label> is the only door to one.
That is deliberate, and it is what makes a live album swap safe — a swap
replaces pack data only, and can never register a trigger or widen a
command's groups.
A label that folds onto a person's trigger, or onto another key's label, fails startup rather than shadowing the command it collides with.
adds.json
{"a3ae52c7….jpg": {"by": "<uuid>", "at": 1756600000}}
Who added which file. Nothing reads it — pack_load ignores the file
entirely — and it exists only so !undo_photo can be built later without a
gap in its history; the adder has to be recorded at add time or not at all.
It lives inside the album, which is 0700 and outside this repository, so it
is handled exactly like the photos.
Adding from the chat: !add_photo
Send a photo captioned !add_photo alfa wakacje2019 in one of the album's
groups. alfa resolves to that person's key; wakacje2019 matches nobody and
is minted as a custom tag. The photo is retrievable through !alfa and
!random wakacje2019 immediately, with no restart.
What holds:
- No typed label travels. Labels become keys in the registry's
arg_resolver, at the dispatcher, before the line that logs the command name. The reply is counts and pseudonyms —dodano · p1: 4 · t1: 1. - No cap. No cooldown, no size limit, no daily limit.
!randomtakes the ordinary pack cooldown;!add_phototakes no decorator at all. - At most six labels, the same ceiling
!alfa beta …has. - The attachment must be on the same message, never a quoted one: a reply
carries the quoted message's media in
quote.attachments, and taking it would let anyone re-file somebody else's photo. - A duplicate changes nothing. The stored name is a content hash, so a photo already in the album is reported and skipped; a mixed batch stores the new files and says how many were already there.
- Nothing is written until it works. Media,
tags.json,custom.jsonandadds.jsonare 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 album that was already working. - The second copy goes. signal-cli keeps its own copy of every inbound
attachment outside the album's
0700.!add_photodeletes it once the file is stored, and also when the upload was refused.
Stripping: exiftool, with a positive check
ffmpeg -map_metadata -1 -c copy was tried first and fails silently on
photos: given a JPEG with a real APP1 Exif segment it returned a
byte-identical file, GPS marker intact, and exited 0. -c copy passes the
compressed frame through as an opaque packet and a JPEG's Exif lives inside
it. Do not reintroduce ffmpeg for this.
So the gate is a positive check on the output, never an exit code:
exiftool -all= -o <dst> <src> # never --overwrite_original on the temp
exiftool -G0 -s -all <dst> # then read it back
and every surviving tag must be structural. Measured against exiftool
13.55, the plan's original "the surviving set must be empty" would have
rejected every format except JPEG — a valid PNG always reports its IHDR under
PNG, a GIF its screen descriptor under GIF, an MP4 some fifty QuickTime
tags that are the container. So the allowlist is per tag name
(signal_bot/media_tags.py), which keeps the property that matters: an atom
nobody anticipated arrives as a name that is not on the list, and the upload
is refused.
Allowlist, not blocklist, for the reason a naive gate misses: an MP4 geotag is
a QuickTime udta/©xyz atom reported under QuickTime, not GPS.
The list includes the codec's own configuration — CompressorName,
VendorID, and the HEVC parameter set (HEVCConfigurationVersion,
GeneralProfileIDC, BitDepthLuma, …). That was forced by measurement, not
chosen for convenience: with them off the list exiftool 13.55 could produce no
acceptable video and no acceptable HEIC, because CompressorName lives in
the avc1 sample description and exiftool reports it as not writable at all
(Sorry, QuickTime:CompressorName doesn't exist or isn't writable), and a real
HEIC reports sixteen HEVC config tags. Since HEIC is the iPhone default, the
choice was codec parameters or no phone photos. They describe how the picture
is encoded, never where it was taken or who took it; every location and
identity tag is still refused, and scripts/e2e.sh proves a QuickTime GPS atom
does not survive on a file the album actually stored.
Formats are decided by magic bytes, never by the filename: jpg, png, gif,
webp, mp4, mov, m4v, heic, heif, avif, webm and svg. Every one of them must
also be in pack.MEDIA_TYPES, or a file that uploaded cleanly is refused by
the next load and takes startup down.
⚠️ .webm and .svg are stored unstripped
exiftool 13.55 can write neither (exiftool -listwf lists no WEBM and no
SVG; a strip attempt returns Can't write SVG files). Its SVG reader does
not surface <metadata> content either, so there is no detection to fall back
on.
kuhy's decision of 2026-08-31 is to keep both on the allowlist and store them
as they arrive, metadata included. The consequence, stated plainly: an
.svg carrying <metadata> or an embedded
<image xlink:href="data:image/jpeg;base64,…"> keeps whatever is in it, and
every later !alfa re-sends that file verbatim. Every other format on the
list is stripped and verified.
Filling it in: tool/tagger.py
python tool/tagger.py --import ~/photos # copy the files in, then tag
python tool/tagger.py # carry on where you left off
It serves one page on 127.0.0.1:8140: the item, a numbered button per
person, 1–9 to toggle, →/← to move. Every toggle rewrites tags.json
atomically, so closing the tab loses nothing. Names are typed into the
page, never as arguments — an argument would land in the shell history —
and the group is picked from the live /v1/groups listing rather than pasted.
When you stop it, it prints how many items were never opened and how many
photos each command would have. A command showing 0 is dead on arrival;
that is the number to check before restarting the bot, not after somebody in
the group tries it.
Running the mechanism without an album
scripts/e2e.sh writes a synthetic pack with tool/e2e_pack.py and proves,
offline and with no Signal account, that one name posts a photo, two names
intersect, an impossible combination gets one line and no attachment, a video
leaves as a video, and the same command in another group produces zero
sends. It also drives !add_photo against the real exiftool, on media it
generates at run time, so no photo enters this repository.