mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
!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.
247 lines
9.2 KiB
Python
247 lines
9.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The !wiersz command: one short public-domain Polish poem, whole."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.decorators import DISPLAY_MARGIN_SECONDS, cooldown
|
|
from signal_bot.http_client import ImageFetcher
|
|
from signal_bot.models import parse_envelope
|
|
from signal_bot.poem_command import (
|
|
COMMAND_NAME,
|
|
MAX_CHARS,
|
|
MAX_LINES,
|
|
POEMS_FILE,
|
|
load_slugs,
|
|
random_slug,
|
|
register_poem_command,
|
|
render,
|
|
)
|
|
from signal_bot.registry import CommandRefusedError, CommandRegistry
|
|
from signal_bot.wolnelektury import PUBLIC_DOMAIN, Poem
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
TEXT_URL = "https://lektury.example/txt/{slug}.txt"
|
|
ALIASES = {COMMAND_NAME: ["!wiersz", "!wiersze", "!poem"]}
|
|
POOL = ("bol-tabuli", "impas", "nikt-nic")
|
|
BODY = "Pierwsza linijka wiersza,\ndruga linijka wiersza."
|
|
|
|
|
|
def _fetcher(seen: list[httpx.URL] | None = None, *, body: str = BODY) -> ImageFetcher:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if seen is not None:
|
|
seen.append(request.url)
|
|
raw = f"Maria Konopnicka\n\nA co wam śpiewać\n\n\n{body}\n\n-----\n{PUBLIC_DOMAIN}\n"
|
|
return httpx.Response(200, text=raw)
|
|
|
|
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
|
|
|
|
|
def _ctx(transport: FakeTransport, store: Store, config: Config, text: str) -> CommandContext:
|
|
message = parse_envelope(envelope(text=text))
|
|
assert message is not None
|
|
return CommandContext(
|
|
message=message,
|
|
group_id=WATCHED_GROUP,
|
|
transport=transport,
|
|
store=store,
|
|
config=config,
|
|
command_name=COMMAND_NAME,
|
|
now=100.0,
|
|
day="2026-08-31",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("trigger", ["!wiersz", "!wiersze", "!poem"])
|
|
def test_every_alias_resolves(trigger: str) -> None:
|
|
registry = CommandRegistry()
|
|
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=POOL)
|
|
|
|
command = registry.resolve(trigger)
|
|
assert command is not None
|
|
assert command.name == COMMAND_NAME
|
|
|
|
|
|
def test_the_real_alias_file_carries_the_command() -> None:
|
|
registry = CommandRegistry()
|
|
register_poem_command(registry, _fetcher(), TEXT_URL, slugs=POOL)
|
|
# The Polish spelling is the primary one !pomoc prints.
|
|
assert registry.resolve("!wiersz") is not None
|
|
assert registry.resolve("!poem") is not None
|
|
|
|
|
|
async def test_it_posts_the_poem_its_author_and_where_it_came_from(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
registry = CommandRegistry()
|
|
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=("impas",))
|
|
command = registry.resolve("!wiersz")
|
|
assert command is not None
|
|
|
|
await command.handler(_ctx(transport, store, config, "!wiersz"))
|
|
|
|
expected = (
|
|
f"A co wam śpiewać\n\n{BODY}\n\n— Maria Konopnicka\n"
|
|
"https://wolnelektury.pl/katalog/lektura/impas/"
|
|
)
|
|
assert transport.texts == [expected]
|
|
# Text, not an attachment: this is the one content command that posts no
|
|
# picture, and sending an empty image would be a silent wrong turn.
|
|
assert len(transport.sent) == 1
|
|
assert transport.sent[0].image_base64 is None
|
|
# ...and it still quotes the asker, like every other reply.
|
|
assert transport.sent[0].quote is not None
|
|
|
|
|
|
async def test_the_full_author_name_is_posted_though_it_is_never_committed(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
"""The point of fetching rather than committing, as an assertion.
|
|
|
|
"Maria" is a Polish given name. Posting it is fine; committing several
|
|
hundred like it is what made check_personal_data.py refuse the first
|
|
version of this pack. See the module docstring.
|
|
"""
|
|
registry = CommandRegistry()
|
|
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=POOL)
|
|
command = registry.resolve("!wiersz")
|
|
assert command is not None
|
|
|
|
await command.handler(_ctx(transport, store, config, "!wiersz"))
|
|
|
|
assert "Maria Konopnicka" in transport.texts[0]
|
|
|
|
|
|
async def test_it_asks_for_the_slug_it_drew(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
seen: list[httpx.URL] = []
|
|
registry = CommandRegistry()
|
|
register_poem_command(registry, _fetcher(seen), TEXT_URL, aliases=ALIASES, slugs=("impas",))
|
|
command = registry.resolve("!wiersz")
|
|
assert command is not None
|
|
|
|
await command.handler(_ctx(transport, store, config, "!wiersz"))
|
|
|
|
assert len(seen) == 1
|
|
assert str(seen[0]) == "https://lektury.example/txt/impas.txt"
|
|
|
|
|
|
async def test_an_upstream_that_stops_serving_a_poem_is_not_posted(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
"""A half-parsed poem is not something to put in a group.
|
|
|
|
The dispatcher turns this into a log line and silence, which is what
|
|
every other command already does when its upstream misbehaves.
|
|
"""
|
|
registry = CommandRegistry()
|
|
fetcher = ImageFetcher(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, text="")))
|
|
)
|
|
register_poem_command(registry, fetcher, TEXT_URL, aliases=ALIASES, slugs=POOL)
|
|
command = registry.resolve("!wiersz")
|
|
assert command is not None
|
|
|
|
with pytest.raises(ValueError, match="no longer serves"):
|
|
await command.handler(_ctx(transport, store, config, "!wiersz"))
|
|
|
|
assert transport.sent == []
|
|
|
|
|
|
async def test_the_cooldown_decorator_applies(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
registry = CommandRegistry()
|
|
register_poem_command(
|
|
registry,
|
|
_fetcher(),
|
|
TEXT_URL,
|
|
decorate=cooldown(10, "WAIT {seconds}"),
|
|
aliases=ALIASES,
|
|
slugs=POOL,
|
|
)
|
|
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31")
|
|
command = registry.resolve("!wiersz")
|
|
assert command is not None
|
|
|
|
with pytest.raises(CommandRefusedError):
|
|
await command.handler(_ctx(transport, store, config, "!wiersz"))
|
|
|
|
assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"]
|
|
|
|
|
|
def test_the_draw_actually_varies() -> None:
|
|
"""0.0.3 posted one identical gecko forever; that is what this catches."""
|
|
assert len({random_slug(POOL) for _ in range(40)}) > 1
|
|
|
|
|
|
def test_render_puts_the_poem_before_the_attribution() -> None:
|
|
# The order is the point: a group reads the poem, then who wrote it.
|
|
text = render(Poem(title="Impas", author="Bolesław Leśmian", text=BODY), "impas")
|
|
assert text.index("Pierwsza") < text.index("— Bolesław") < text.index("wolnelektury.pl")
|
|
|
|
|
|
def test_the_shipped_pack_holds_slugs_and_no_poetry() -> None:
|
|
"""The privacy invariant this whole command is shaped around.
|
|
|
|
Committing the poems themselves is what check_personal_data.py refused:
|
|
three lines carried a real group member's name, and two still did after
|
|
a filter that had already discarded 31% of the corpus. A slug cannot
|
|
carry a name that way. This is the test that keeps the pack to slugs
|
|
when somebody regenerates it.
|
|
"""
|
|
raw = json.loads(POEMS_FILE.read_text(encoding="utf-8"))["poems"]
|
|
assert raw
|
|
assert all(isinstance(slug, str) and slug for slug in raw)
|
|
# A slug is a lowercase hyphenated handle, never prose: no capitals, no
|
|
# whitespace, no punctuation that a line of verse would carry.
|
|
assert all(slug == slug.lower() for slug in raw)
|
|
assert not any(set(slug) & set(" \n.,;:!?\"'") for slug in raw)
|
|
assert len(set(raw)) == len(raw)
|
|
assert raw == sorted(raw)
|
|
# What this does NOT claim: that no slug contains a word that is also
|
|
# somebody's name. "Zosia i jej mopsy" is a Konopnicka poem and its slug
|
|
# is zosia-i-jej-mopsy, because a capitalised *first* word is how every
|
|
# title starts and cannot be told from a name. That residue is acceptable
|
|
# where a pack of poems was not, and the difference is repair: the real
|
|
# check is scripts/check_personal_data.sh, pre-commit runs it on every
|
|
# commit, and a colliding slug is one line to delete out of 1180. The
|
|
# text version wedged the repository with "data nobody can edit out by
|
|
# hand"; this is a one-line edit and a regeneration.
|
|
|
|
|
|
def test_the_shipped_pack_is_large_enough_to_be_worth_drawing_from() -> None:
|
|
# A regeneration that silently truncated would still pass every other
|
|
# test here, and would quietly shrink the command's whole world.
|
|
assert len(load_slugs()) >= 300
|
|
|
|
|
|
def test_the_caps_are_the_promise_the_pack_makes() -> None:
|
|
# Whole poems only. The caps are enforced at harvest time, so they live
|
|
# next to the command rather than in the generator that applies them.
|
|
assert MAX_LINES == 24
|
|
assert MAX_CHARS == 1200
|
|
|
|
|
|
@pytest.mark.parametrize("broken", [[], ["", "impas"], [None], [3]])
|
|
def test_a_pool_that_is_not_slugs_is_refused(tmp_path: Path, broken: list[object]) -> None:
|
|
path = tmp_path / "poems.json"
|
|
path.write_text(json.dumps({"poems": broken}), encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="slugs"):
|
|
load_slugs(path)
|