signal-bot/signal_bot/pack_command.py
Krzysztof kuhy Rudnicki 35102be863 release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it
Per-person photo commands, where the mechanism is public and the album is
not. Nothing identifying is in this repository: who the people are, what
fires their photos and which file shows whom all live in one directory
outside it, and the tests, the docs and the offline e2e run use three
invented people instead.

Several names in one message intersect -- "!alfa beta" is a photo with both
-- and where an entry offers a choice the bot picks among the readings that
actually have a photo, so a question the album could answer is not refused.
A combination nobody photographed gets one line rather than silence, which
from the group's side is indistinguishable from a broken bot.

Scoping is enforced in the dispatcher, not in the handler: a command the
group is not allowed resolves to nothing, so it is not answered, not counted
as a command, and never named in a log line outside its own groups. A
handler that quietly returned would leak all three. Commands are named by
pseudonym (pack:p1), so the journal and command_uses say somebody asked for
a photo and nothing about whose.

Video works: the attachment goes out as data:video/mp4;filename=...;base64,
which is the container's own spelling, read off its /swagger/doc.json.
Told it is a photo, Signal renders a clip as an unplayable file.

tool/tagger.py serves one page on loopback to fill the album in -- names are
typed into the page, never as arguments, and it prints how many photos each
command would have when it stops, because a command with zero is dead on
arrival.

Also renames the real people who were still sitting in the stats test
fixtures. They remain in the history; the point is that nothing new is
added to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:35:41 +02:00

112 lines
4.1 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, Pack
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."
# 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(pack: Pack, entry: Entry) -> Handler:
"""Build the handler that answers one entry's trigger."""
async def handler(ctx: CommandContext) -> None:
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,
pack: Pack,
*,
decorate: Decorator | None = None,
) -> None:
"""Register one command per pack entry, scoped to the pack's groups.
Args:
registry: Registry to populate.
pack: The loaded album.
decorate: Optional callable applied to each handler, used to attach
the cooldown gate without hard-wiring policy into the commands.
"""
for entry in pack.entries:
handler = pack_handler(pack, 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,
)