mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
130 lines
5.0 KiB
Python
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,
|
|
)
|