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.
234 lines
8.0 KiB
Python
234 lines
8.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The !sztuka command: one work of art, skewed Polish, captioned."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.art_command import (
|
|
ART_FILE,
|
|
COMMAND_NAME,
|
|
PER_CENT,
|
|
POLISH_SHARE,
|
|
load_art,
|
|
random_art,
|
|
register_art_command,
|
|
)
|
|
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.registry import CommandRefusedError, CommandRegistry
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Sequence
|
|
from pathlib import Path
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
IMAGE = b"art-bytes"
|
|
API = "https://commons.example/w/api.php"
|
|
ALIASES = {COMMAND_NAME: ["!sztuka", "!sztukę", "!art"]}
|
|
POLISH = (1001, 1002, 1003)
|
|
WORLD = (2001, 2002, 2003)
|
|
POOLS = (POLISH, WORLD)
|
|
|
|
|
|
def _fetcher(seen: list[httpx.URL] | None = None) -> ImageFetcher:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if "commons" in str(request.url):
|
|
if seen is not None:
|
|
seen.append(request.url)
|
|
page = {
|
|
"title": "File:Bitwa pod Grunwaldem.jpg",
|
|
"imageinfo": [
|
|
{
|
|
"thumburl": "https://cdn/art.jpg",
|
|
"descriptionurl": "https://commons.wikimedia.org/wiki/File:Grunwald.jpg",
|
|
"extmetadata": {
|
|
"ImageDescription": {"value": "Bitwa pod <b>Grunwaldem</b>"},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
return httpx.Response(200, json={"query": {"pages": [page]}})
|
|
return httpx.Response(200, content=IMAGE)
|
|
|
|
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", ["!sztuka", "!sztukę", "!art"])
|
|
def test_every_alias_resolves(trigger: str) -> None:
|
|
registry = CommandRegistry()
|
|
register_art_command(registry, _fetcher(), API, aliases=ALIASES, pools=POOLS)
|
|
|
|
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_art_command(registry, _fetcher(), API, pools=POOLS)
|
|
# The Polish spelling is the primary one !pomoc prints.
|
|
assert registry.resolve("!sztuka") is not None
|
|
assert registry.resolve("!art") is not None
|
|
|
|
|
|
async def test_it_posts_an_image_captioned_with_what_the_work_is(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
registry = CommandRegistry()
|
|
register_art_command(registry, _fetcher(), API, aliases=ALIASES, pools=POOLS)
|
|
command = registry.resolve("!sztuka")
|
|
assert command is not None
|
|
|
|
await command.handler(_ctx(transport, store, config, "!sztuka"))
|
|
|
|
sent = transport.sent[0]
|
|
assert sent.recipient == WATCHED_GROUP
|
|
assert sent.image_base64 is not None
|
|
assert base64.b64decode(sent.image_base64) == IMAGE
|
|
assert len(transport.sent) == 1
|
|
assert sent.caption == (
|
|
"Bitwa pod Grunwaldem\nhttps://commons.wikimedia.org/wiki/File:Grunwald.jpg"
|
|
)
|
|
|
|
|
|
async def test_it_names_the_file_by_page_id_and_never_by_title(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
# A Commons painting filename is very often literally somebody's name, so
|
|
# this is the assertion that keeps the query -- and the pool -- on ids.
|
|
seen: list[httpx.URL] = []
|
|
registry = CommandRegistry()
|
|
register_art_command(registry, _fetcher(seen), API, aliases=ALIASES, pools=POOLS)
|
|
command = registry.resolve("!sztuka")
|
|
assert command is not None
|
|
|
|
await command.handler(_ctx(transport, store, config, "!sztuka"))
|
|
|
|
assert len(seen) == 1
|
|
assert seen[0].params["pageids"] in {str(page) for page in POLISH + WORLD}
|
|
assert "titles" not in seen[0].params
|
|
|
|
|
|
async def test_the_cooldown_decorator_applies(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
registry = CommandRegistry()
|
|
register_art_command(
|
|
registry,
|
|
_fetcher(),
|
|
API,
|
|
decorate=cooldown(10, "WAIT {seconds}"),
|
|
aliases=ALIASES,
|
|
pools=POOLS,
|
|
)
|
|
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31")
|
|
command = registry.resolve("!sztuka")
|
|
assert command is not None
|
|
|
|
with pytest.raises(CommandRefusedError):
|
|
await command.handler(_ctx(transport, store, config, "!sztuka"))
|
|
|
|
assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"]
|
|
|
|
|
|
def _stub(first: int, second: int = 0) -> Callable[[int], int]:
|
|
"""A randbelow whose first answer picks the pool and second picks the work."""
|
|
answers = iter((first, second))
|
|
|
|
def randbelow(_: int) -> int:
|
|
return next(answers)
|
|
|
|
return randbelow
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("roll", "expected"),
|
|
[(0, POLISH), (POLISH_SHARE - 1, POLISH), (POLISH_SHARE, WORLD), (PER_CENT - 1, WORLD)],
|
|
)
|
|
def test_the_share_decides_which_pool_is_drawn_from(roll: int, expected: Sequence[int]) -> None:
|
|
"""The 60/40 branch, named rather than sampled.
|
|
|
|
secrets.randbelow cannot be seeded, so asserting the split by taking real
|
|
draws would either flake or need thousands of iterations. The chooser is
|
|
injectable precisely so this test can say which side it means.
|
|
"""
|
|
assert random_art(POLISH, WORLD, _stub(roll)) == expected[0]
|
|
|
|
|
|
def test_the_draw_actually_varies() -> None:
|
|
"""0.0.3 posted one identical gecko forever; that is what this catches."""
|
|
drawn = {random_art(POLISH, WORLD) for _ in range(40)}
|
|
assert len(drawn) > 1
|
|
|
|
|
|
def test_both_shipped_pools_are_page_ids_and_nothing_else() -> None:
|
|
"""The personal-data invariant, as a unit test.
|
|
|
|
A filename here would carry artists' and sitters' names into a public
|
|
repository, and one of them colliding with a label somebody minted from
|
|
the chat wedges check_personal_data.py permanently. Integers cannot.
|
|
"""
|
|
document = json.loads(ART_FILE.read_text(encoding="utf-8"))
|
|
for name in ("polish", "world"):
|
|
raw = document[name]
|
|
assert raw
|
|
assert all(isinstance(page, int) for page in raw)
|
|
assert len(set(raw)) == len(raw)
|
|
assert raw == sorted(raw)
|
|
|
|
|
|
def test_the_two_shipped_pools_are_disjoint() -> None:
|
|
# A work in both pools would be drawn at both sets of odds, which makes
|
|
# POLISH_SHARE a number that does not mean what it says.
|
|
polish, world = load_art()
|
|
assert not set(polish) & set(world)
|
|
|
|
|
|
def test_the_shipped_pools_are_large_enough_to_be_worth_drawing_from() -> None:
|
|
# A regeneration that silently truncated one pool would still pass every
|
|
# other test here, and would quietly make the skew a draw from nothing.
|
|
polish, world = load_art()
|
|
assert len(polish) >= 300
|
|
assert len(world) >= 300
|
|
|
|
|
|
@pytest.mark.parametrize("broken", [[], ["1001.jpg"], [None]])
|
|
@pytest.mark.parametrize("pool", ["polish", "world"])
|
|
def test_a_pool_that_is_not_page_ids_is_refused(
|
|
tmp_path: Path, pool: str, broken: list[object]
|
|
) -> None:
|
|
document: dict[str, list[object]] = {"polish": [1001], "world": [2001]}
|
|
document[pool] = broken
|
|
path = tmp_path / "art.json"
|
|
path.write_text(json.dumps(document), encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="page ids"):
|
|
load_art(path)
|