signal-bot/signal_bot/pack_command.py
Krzysztof kuhy Rudnicki 48957a105d
Some checks are pending
CI / check (push) Waiting to run
feat: serve several photo albums at once, each scoped to its own groups
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
2026-08-31 11:58:50 +02:00

131 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.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.pack import Entry
from signal_bot.pack_holder import PackHolder
from signal_bot.registry import 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,
)