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
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
131 lines
5.0 KiB
Python
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,
|
|
)
|