signal-bot/signal_bot/poem_command.py
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

160 lines
6.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !wiersz command: one short public-domain Polish poem, whole.
Shaped exactly like !pułapka, and for the same reason. The repository commits
a pool of **identifiers** -- Commons page ids there, Wolne Lektury slugs here
-- and the words arrive over the wire at the moment somebody asks. What
``commons.caption_for`` says is the rule the whole bot follows:
The text is fetched at run time and posted to the group; none of it is
ever written into this repository, which is why it may be free text
where traps.json may not.
This command was written the other way first, committing the poems
themselves, and that is worth recording because it looked entirely
reasonable and failed hard. ``scripts/check_personal_data.py`` refused the
pack: three lines carried a real group member's name, and two still did after
a filter that had already discarded 31% of the corpus. The names were at the
start of a line, and a filter cannot reject those -- Polish verse capitalises
every line, so a line-initial capital is the norm and not a signal. Committing
several hundred Polish poems means committing several hundred Polish given
names, and one collision refuses every commit on the maintainer's machine
while CI, which has no private data to compare against, stays green.
Slugs fixed that structurally rather than by degree, and the version that
survived is also the better one: no name filter means no false positives, so
the pool is far larger and poems that were being thrown away for containing
an ordinary word come back.
There is no cap on the rendered message, which is a decision rather than an
oversight. ``commons.CAPTION_LIMIT`` bounds a caption at 220 characters
because a caption is a label on a picture. A poem is the message, truncating
one was ruled out when the length filter was chosen, and the worst case is
bounded anyway by the caps the harvest applied: 24 lines and 1200 characters,
plus a title, an author and a link.
"""
from __future__ import annotations
import json
import secrets
from pathlib import Path
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
from signal_bot.wolnelektury import CATALOGUE, parse
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.http_client import ImageFetcher
from signal_bot.registry import CommandRegistry, Handler
from signal_bot.wolnelektury import Poem
COMMAND_NAME: Final = "poem"
DESCRIPTION: Final = "losowy wiersz"
POEMS_FILE: Final = Path(__file__).with_name("poems.json")
# Whole poems only, never an excerpt: a truncated poem is worse than no poem,
# so these reject at harvest time rather than cutting at post time. They live
# here rather than in the generator because they are the promise the pack
# makes -- every committed slug is a poem this short -- and the tests assert
# the shipped pack against them.
MAX_LINES: Final = 24
MAX_CHARS: Final = 1200
def load_slugs(path: Path = POEMS_FILE) -> tuple[str, ...]:
"""Read the slug pool.
The file holds Wolne Lektury slugs and nothing else, which is checked
here rather than assumed -- the whole privacy argument for this command
rests on the pack carrying no poem text, and this is the guard that keeps
it that way when somebody regenerates it.
Args:
path: Where to read the pool from. Defaults to the shipped file.
Returns:
Every slug in the pool.
Raises:
ValueError: If the pool is empty or holds anything but non-empty
strings.
"""
document = json.loads(path.read_text(encoding="utf-8"))
slugs = document["poems"]
if not slugs or not all(isinstance(slug, str) and slug for slug in slugs):
msg = f"{path} must hold a non-empty list of Wolne Lektury slugs"
raise ValueError(msg)
return tuple(slugs)
def random_slug(slugs: Sequence[str]) -> str:
"""Return one slug, uniformly at random.
No memory of what came before, the same as !pułapka and for the same
reason: a pool this size is not one a group works through, so a
no-repeat window would be bookkeeping that never changes an answer.
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 repository forbids the noqa that would silence it.
"""
return slugs[secrets.randbelow(len(slugs))]
def render(poem: Poem, slug: str) -> str:
"""The message as the group reads it: title, poem, who wrote it, where.
The author's full name is safe to post and would not have been safe to
commit -- see the module docstring. Posting it is the whole point of
fetching it.
"""
return f"{poem.title}\n\n{poem.text}\n\n— {poem.author}\n{CATALOGUE.format(slug=slug)}"
def poem_handler(
fetcher: ImageFetcher,
text_url: str,
slugs: Sequence[str] | None = None,
) -> Handler:
"""Build the handler that posts one random poem.
Raises:
ValueError: If Wolne Lektury served something that is not the poem
the harvest committed. The dispatcher logs and stays silent, the
same as any other upstream failure -- a half-parsed poem is not
something to post.
"""
pool = load_slugs() if slugs is None else tuple(slugs)
async def handler(ctx: CommandContext) -> None:
slug = random_slug(pool)
poem = parse(await fetcher.fetch_text(text_url.format(slug=slug)))
if poem is None:
msg = f"wolnelektury.pl no longer serves a usable poem at {slug!r}"
raise ValueError(msg)
await ctx.reply(render(poem, slug))
return handler
def register_poem_command(
registry: CommandRegistry,
fetcher: ImageFetcher,
text_url: str,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
slugs: Sequence[str] | None = None,
) -> None:
"""Register !wiersz under every trigger aliases.json gives it."""
table = load_aliases() if aliases is None else aliases
handler = poem_handler(fetcher, text_url, slugs)
if decorate is not None:
handler = decorate(handler)
registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)