mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +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
184 lines
6.8 KiB
Python
184 lines
6.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Custom tags: labels the album learned from the chat rather than a manifest.
|
|
|
|
``!add_photo`` never writes a person. A label that matches nobody becomes a
|
|
custom tag instead -- a pseudonym key (``t1``, ``t2``) plus the raw spellings
|
|
that reach it -- and the only way to that key is ``!random <label>``. Keeping
|
|
people and custom tags in different files is what makes that guarantee cheap:
|
|
``people.json`` is edited by the tagger and defines commands, ``custom.json``
|
|
is appended to from the chat and defines no commands at all.
|
|
|
|
Written atomically, temp-then-replace, exactly as ``tool/tagger.py::Album.write``
|
|
already does, because the alternative is a half-written manifest that fails the
|
|
next load and takes startup down.
|
|
|
|
This module also owns the validation ``pack_load`` would otherwise grow: that
|
|
file was at 243 of the 250-line cap before this feature existed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
from signal_bot.folding import fold
|
|
from signal_bot.groups import ConfigError
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
|
|
CUSTOM_FILE: Final = "custom.json"
|
|
|
|
# The pseudonym shape. "t" for tag, mirroring "p" for person, and a digit
|
|
# suffix so a key can never be mistaken for something somebody typed.
|
|
KEY_PATTERN: Final = re.compile(r"^t([1-9][0-9]*)$")
|
|
|
|
# Manifests are written 0600: the album is 0700 already, but a mode is
|
|
# cheaper to keep right than to explain.
|
|
FILE_MODE: Final = 0o600
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CustomTags:
|
|
"""Every custom tag the album knows, in both directions."""
|
|
|
|
# Key to its raw spellings, canonical first. The spellings are the only
|
|
# place a typed label is kept, and that file never leaves the album.
|
|
by_key: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
# Folded label to key, which is how a typed word becomes a pseudonym.
|
|
by_label: Mapping[str, str] = field(default_factory=dict)
|
|
|
|
@property
|
|
def keys(self) -> frozenset[str]:
|
|
"""Every custom key, for validating tags.json against."""
|
|
return frozenset(self.by_key)
|
|
|
|
def resolve(self, label: str) -> str | None:
|
|
"""The key one typed label means, or None when nothing does."""
|
|
return self.by_label.get(fold(label))
|
|
|
|
def next_key(self) -> str:
|
|
"""The key a new label would mint, without minting it.
|
|
|
|
One past the highest number in use rather than the count, so
|
|
deleting ``t2`` by hand can never make a later mint reuse it and
|
|
silently inherit its photos.
|
|
"""
|
|
used = [int(match.group(1)) for key in self.by_key if (match := KEY_PATTERN.match(key))]
|
|
return f"t{max(used, default=0) + 1}"
|
|
|
|
def with_label(self, key: str, label: str) -> CustomTags:
|
|
"""This table plus one more label, as a new value."""
|
|
by_key = dict(self.by_key)
|
|
by_key[key] = (*by_key.get(key, ()), label)
|
|
by_label = dict(self.by_label)
|
|
by_label[fold(label)] = key
|
|
return CustomTags(by_key=by_key, by_label=by_label)
|
|
|
|
|
|
def load(directory: Path, person_triggers: frozenset[str]) -> CustomTags:
|
|
"""Read custom.json, or return an empty table when there is none.
|
|
|
|
A missing file is the normal state of an album nobody has run
|
|
``!add_photo`` on, so it is not an error. A malformed one is: it would
|
|
otherwise drop every custom tag silently and make ``!random`` answer "no
|
|
such photo" for labels that are right there on disk.
|
|
|
|
Args:
|
|
directory: The pack directory.
|
|
person_triggers: Folded triggers already claimed by people, so a
|
|
label can never shadow a person's own command.
|
|
|
|
Raises:
|
|
ConfigError: The file is unreadable, malformed, or ambiguous.
|
|
"""
|
|
path = directory / CUSTOM_FILE
|
|
if not path.exists():
|
|
return CustomTags()
|
|
document = _document(path)
|
|
by_key: dict[str, tuple[str, ...]] = {}
|
|
by_label: dict[str, str] = {}
|
|
for raw_key, raw_labels in document.items():
|
|
key = _key(str(raw_key))
|
|
labels = _labels(key, raw_labels)
|
|
by_key[key] = labels
|
|
for label in labels:
|
|
_claim(by_label, fold(label), key, person_triggers)
|
|
return CustomTags(by_key=by_key, by_label=by_label)
|
|
|
|
|
|
def write(directory: Path, tags: CustomTags) -> None:
|
|
"""Replace custom.json atomically, so a crash cannot halve it."""
|
|
path = directory / CUSTOM_FILE
|
|
temporary = path.with_suffix(".tmp")
|
|
temporary.write_text(
|
|
json.dumps({key: list(labels) for key, labels in tags.by_key.items()}, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
temporary.chmod(FILE_MODE)
|
|
temporary.replace(path)
|
|
|
|
|
|
def _document(path: Path) -> dict[str, Any]:
|
|
"""One JSON object, read strictly."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError as error:
|
|
msg = f"{CUSTOM_FILE} cannot be read: {error}"
|
|
raise ConfigError(msg) from error
|
|
try:
|
|
document = json.loads(text)
|
|
except json.JSONDecodeError as error:
|
|
msg = f"{CUSTOM_FILE} is not valid JSON: {error}"
|
|
raise ConfigError(msg) from error
|
|
if not isinstance(document, dict):
|
|
msg = f"{CUSTOM_FILE} must be an object"
|
|
raise ConfigError(msg)
|
|
return document
|
|
|
|
|
|
def _key(key: str) -> str:
|
|
"""One custom key, which has to look like a pseudonym and not a name."""
|
|
if not KEY_PATTERN.match(key):
|
|
msg = f"{CUSTOM_FILE} key {key!r} must be 't' followed by a number"
|
|
raise ConfigError(msg)
|
|
return key
|
|
|
|
|
|
def _labels(key: str, raw: object) -> tuple[str, ...]:
|
|
"""The spellings one custom key answers to."""
|
|
if not isinstance(raw, list) or not raw:
|
|
msg = f"{CUSTOM_FILE} entry {key!r} needs a non-empty list of labels"
|
|
raise ConfigError(msg)
|
|
labels = tuple(str(label) for label in raw)
|
|
for label in labels:
|
|
if not fold(label).strip("!"):
|
|
msg = f"{CUSTOM_FILE} label {label!r} folds away to nothing"
|
|
raise ConfigError(msg)
|
|
return labels
|
|
|
|
|
|
def _claim(
|
|
by_label: dict[str, str],
|
|
folded: str,
|
|
key: str,
|
|
person_triggers: frozenset[str],
|
|
) -> None:
|
|
"""Record one label, refusing a spelling that already means something.
|
|
|
|
Both collisions matter and neither is recoverable at run time: two custom
|
|
keys sharing a label would make ``!random`` pick one arbitrarily, and a
|
|
label that folds onto a person's trigger would quietly shadow the command
|
|
that person's photos are reached by.
|
|
"""
|
|
if folded in person_triggers or f"!{folded}" in person_triggers:
|
|
msg = f"{CUSTOM_FILE} label {folded!r} is already a person's trigger"
|
|
raise ConfigError(msg)
|
|
owner = by_label.setdefault(folded, key)
|
|
if owner != key:
|
|
msg = f"{CUSTOM_FILE} label {folded!r} is claimed by {owner!r} and {key!r}"
|
|
raise ConfigError(msg)
|