mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
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
112 lines
4.1 KiB
Python
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,
|
|
)
|