signal-bot/DOCS-photo-pack.md
Krzysztof kuhy Rudnicki aefffb2fd9
Some checks are pending
CI / check (push) Waiting to run
feat: add a photo to the album from the chat, tagged, stripped and verified
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
2026-08-31 11:20:00 +02:00

11 KiB
Raw Blame History

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.json yet, 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_DIR that is missing, or whose manifest cannot be honoured, fails startup. load_config raising 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"]}
  ]
}
  • groups is 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_args takes the group and gives back nothing outside it.
  • key is a pseudonym, and the only identifier that reaches the journal or the command_uses table. A command is pack:p1; reading either says somebody asked for a photo and nothing about whose.
  • all_of means one photo with all of them; any_of means 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.py maps 0134578 onto letters, so !alfa7 is 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. !random takes the ordinary pack cooldown; !add_photo takes 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.json and adds.json 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 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_photo deletes 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.