signal-bot/signal_bot/pack_command.py
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

130 lines
5.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The photo commands a private pack defines, and how one message is answered.
One command per entry, so the cooldown, the log line and the rate-limit row
are per person -- and every one of them is named by the entry's pseudonym
(``pack:p1``), never by what was typed. Reading the journal or the database
tells you somebody asked for a photo, and nothing about whose.
Several names in one message mean "a photo with all of them": the trigger
takes the rest of the line as further names, each resolved through the same
table the trigger came from, and the sets are intersected. Where an entry
offers a choice, the choice is made among the options that actually have a
photo -- picking blindly and then reporting failure would be a "no" the
album could have answered.
"""
from __future__ import annotations
import base64
import secrets
from typing import TYPE_CHECKING, Final
from signal_bot.pack import candidate_keysets, media_type
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.decorators import Decorator
from signal_bot.pack import Entry
from signal_bot.pack_holder import PackHolder
from signal_bot.registry import CommandContext, CommandRegistry, Handler
# The prefix every pack command's name carries, so one glance at a log line
# or a command_uses row says where it came from without saying who.
NAME_PREFIX: Final = "pack:"
NO_PHOTO: Final = "BEEP BOOP NIE MAM TAKIEGO ZDJĘCIA."
# What an argument resolver yields for a word it cannot turn into a key. It
# can never collide with a real one: pack_load requires person keys to be
# ascii alphanumeric, and a custom key is "t" followed by digits.
UNKNOWN: Final = "?"
# Deliberately says nothing about who the entry is: !pomoc prints this next
# to the trigger, and the trigger is the only place a name may appear.
ONE_PERSON: Final = "losowe zdjęcie tej osoby"
EVERYBODY: Final = "losowe zdjęcie, na którym są oni wszyscy"
EITHER: Final = "losowe zdjęcie jednego z nich"
def description_for(entry: Entry) -> str:
"""What !pomoc says an entry does, without naming anybody."""
if len(entry.options) > 1:
return EITHER
return ONE_PERSON if len(entry.options[0]) == 1 else EVERYBODY
def pick(items: Sequence[str]) -> str:
"""One item, uniformly at random.
secrets rather than random for the reason animals.paged_url gives: the
choice is not security-sensitive, but ruff's ALL rule set flags `random`
and this repo forbids the noqa that would silence it.
"""
return items[secrets.randbelow(len(items))]
def pack_handler(holder: PackHolder, entry: Entry) -> Handler:
"""Build the handler that answers one entry's trigger.
Reads ``holder.pack`` at call time rather than closing over the album it
was built with: a photo !add_photo stored a second ago has to be visible
to this person's own command, and a restart is not an acceptable price.
"""
async def handler(ctx: CommandContext) -> None:
pack = holder.pack
wanted = candidate_keysets(pack, entry, ctx.args)
viable = [keys for keys in wanted or () if pack.matching(keys)]
if not viable:
# One line and no photo, for a name nobody has, a combination
# nobody photographed, and a tail of six names alike: from the
# group's side those are the same question with the same answer.
await ctx.reply(NO_PHOTO)
return
filename = pick(pack.matching(pick_keys(viable)))
payload = base64.b64encode(pack.media_path(filename).read_bytes()).decode("ascii")
await ctx.reply_media(payload, media_type(filename), filename)
return handler
def pick_keys(viable: Sequence[frozenset[str]]) -> frozenset[str]:
"""One of the readings that has photos, chosen at random."""
return viable[secrets.randbelow(len(viable))]
def register_pack_commands(
registry: CommandRegistry,
holder: PackHolder,
*,
decorate: Decorator | None = None,
) -> None:
"""Register one command per pack entry, scoped to the pack's groups.
The entries and the groups are read once, here, and never again: a swap
only ever replaces Pack *data*. If it could add a command it could also
add one to the wrong group, which is the failure this album exists to
make impossible.
Args:
registry: Registry to populate.
holder: The album, in the mutable box every handler reads through.
decorate: Optional callable applied to each handler, used to attach
the cooldown gate without hard-wiring policy into the commands.
"""
pack = holder.pack
for entry in pack.entries:
handler = pack_handler(holder, entry)
if decorate is not None:
handler = decorate(handler)
registry.register(
f"{NAME_PREFIX}{entry.key}",
entry.triggers,
handler,
description_for(entry),
groups=pack.groups,
takes_args=True,
)