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
4.9 KiB
Adding a photo from the chat: !add_photo
How a member of an album's group puts a photo into it by sending one, and
what keeps that path from leaking anything. The album itself — where it
lives, what its manifests look like, how several albums coexist — is
DOCS-photo-pack.md.
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.