signal-bot/signal_bot/poem_command.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

165 lines
6.5 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.analytics import probe
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)
# The Wolne Lektury slug, which is what the pool holds and all
# it holds -- the poem, its title and its author arrive over the
# wire and are never written down. See DOCS-privacy.md.
probe.note_item(slug)
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)