mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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.
249 lines
9.1 KiB
Python
249 lines
9.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""!pomoc, and the two gates that keep it from going stale."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.animals import register_animal_commands
|
|
from signal_bot.art_command import register_art_command
|
|
from signal_bot.config import DEFAULT_COMMONS_API_URL, DEFAULT_POEM_TEXT_URL
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.help_command import (
|
|
ALBUM_HEADING,
|
|
COMMAND_NAME,
|
|
GROUPS,
|
|
HEADING,
|
|
PACK_HEADING,
|
|
SPELLINGS,
|
|
register_help_command,
|
|
render_help,
|
|
)
|
|
from signal_bot.http_client import ImageFetcher
|
|
from signal_bot.models import parse_envelope
|
|
from signal_bot.pack_add import register_add_command
|
|
from signal_bot.pack_command import register_pack_commands
|
|
from signal_bot.pack_holder import PackHolder
|
|
from signal_bot.pack_load import load as load_pack
|
|
from signal_bot.pack_random import register_random_command
|
|
from signal_bot.poem_command import register_poem_command
|
|
from signal_bot.popes_command import register_pope_command
|
|
from signal_bot.portraits import PortraitCache
|
|
from signal_bot.registry import CommandRegistry
|
|
from signal_bot.stats.commands import register_stats_commands
|
|
from signal_bot.suggest_command import register_suggest_command
|
|
from signal_bot.traps_command import register_trap_command
|
|
from tests import pack_fixture
|
|
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.media_sniff import MediaKind
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
|
|
def _offline_fetcher() -> ImageFetcher:
|
|
"""A fetcher wired to a transport that refuses to be called.
|
|
|
|
!pomoc never fetches anything, and the other commands are registered here
|
|
only so that the help text has something to list.
|
|
"""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(request.url)
|
|
|
|
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
|
|
|
|
|
def full_registry() -> CommandRegistry:
|
|
"""Every command the live bot registers, wired the way build_bot wires it."""
|
|
registry = CommandRegistry()
|
|
fetcher = _offline_fetcher()
|
|
register_animal_commands(registry, fetcher)
|
|
register_pope_command(registry, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
|
|
register_trap_command(registry, fetcher, DEFAULT_COMMONS_API_URL, traps=(1,))
|
|
register_art_command(registry, fetcher, DEFAULT_COMMONS_API_URL, pools=((1,), (2,)))
|
|
register_poem_command(registry, fetcher, DEFAULT_POEM_TEXT_URL, slugs=("impas",))
|
|
register_stats_commands(registry)
|
|
register_suggest_command(registry)
|
|
register_help_command(registry)
|
|
return registry
|
|
|
|
|
|
async def _noop(ctx: CommandContext) -> None:
|
|
"""A handler that does nothing."""
|
|
|
|
|
|
def test_every_registered_command_carries_a_description() -> None:
|
|
"""The whole point of making description a required argument."""
|
|
for command in full_registry().commands:
|
|
assert command.description.strip(), command.name
|
|
|
|
|
|
def test_help_lists_every_command_exactly_once() -> None:
|
|
"""A command missing from !pomoc, or listed twice, fails here.
|
|
|
|
Asserted on the whole line rather than the bare trigger, so prose that
|
|
happens to mention "!stats" can neither satisfy nor break it.
|
|
"""
|
|
registry = full_registry()
|
|
text = render_help(registry.commands, WATCHED_GROUP)
|
|
for command in registry.commands:
|
|
assert text.count(f"\n{command.primary} — {command.description}") == 1, command.name
|
|
|
|
|
|
def test_help_prints_the_polish_spelling_not_the_folded_one() -> None:
|
|
"""`triggers` holds skeletons; only `primary` still carries the diacritics."""
|
|
text = render_help(full_registry().commands, WATCHED_GROUP)
|
|
assert "!papież" in text
|
|
assert "!papiez" not in text
|
|
assert "!tydzień" in text
|
|
|
|
|
|
def test_headings_appear_in_order() -> None:
|
|
"""Obrazki, Papież, Statystyki, then the ungrouped block, then the note."""
|
|
text = render_help(full_registry().commands, WATCHED_GROUP)
|
|
positions = [text.index(f"\n{title}\n") for title, _ in GROUPS]
|
|
assert positions == sorted(positions)
|
|
assert text.startswith(HEADING)
|
|
assert text.endswith(SPELLINGS)
|
|
|
|
|
|
def test_a_command_outside_the_groups_is_still_listed() -> None:
|
|
"""The safety net: forgetting to add a command to GROUPS cannot hide it."""
|
|
registry = full_registry()
|
|
registry.register("ping", ["!ping"], _noop, "odpowiada pong")
|
|
assert "\n!ping — odpowiada pong" in render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
def test_a_group_naming_an_unknown_command_fails_loudly() -> None:
|
|
"""A typo in GROUPS raises rather than silently dropping a line."""
|
|
registry = CommandRegistry()
|
|
register_help_command(registry)
|
|
with pytest.raises(KeyError):
|
|
render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
async def test_the_handler_replies_with_the_rendered_list(
|
|
transport: FakeTransport, store: Store, config: Config
|
|
) -> None:
|
|
"""!pomoc answers in the group that asked, quoting the asker."""
|
|
registry = full_registry()
|
|
command = registry.resolve("!pomoc")
|
|
assert command is not None
|
|
assert command.name == COMMAND_NAME
|
|
message = parse_envelope(envelope(text="!pomoc"))
|
|
assert message is not None
|
|
await command.handler(
|
|
CommandContext(
|
|
message=message,
|
|
group_id=WATCHED_GROUP,
|
|
transport=transport,
|
|
store=store,
|
|
config=config,
|
|
command_name=COMMAND_NAME,
|
|
now=100.0,
|
|
day="2026-08-30",
|
|
)
|
|
)
|
|
assert transport.sent[0].recipient == WATCHED_GROUP
|
|
assert transport.sent[0].text == render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
|
|
@pytest.mark.parametrize("trigger", ["!pomoc", "!pomocy", "!help", "!komendy", "!commands"])
|
|
def test_every_alias_resolves(trigger: str) -> None:
|
|
"""All five spellings reach the same command."""
|
|
command = full_registry().resolve(trigger)
|
|
assert command is not None
|
|
assert command.name == COMMAND_NAME
|
|
|
|
|
|
def test_an_album_gets_its_own_heading_and_a_hint(tmp_path: Path) -> None:
|
|
"""And the hint is built from real triggers, because they are the example."""
|
|
registry = full_registry()
|
|
register_pack_commands(registry, PackHolder(load_pack(pack_fixture.build(tmp_path / "pack"))))
|
|
|
|
text = render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
assert f"\n{PACK_HEADING}\n" in text
|
|
assert "!alfa — losowe zdjęcie tej osoby" in text
|
|
assert "Kilka naraz (!alfa !beta)" in text
|
|
|
|
|
|
def test_the_album_is_invisible_in_every_other_group(tmp_path: Path) -> None:
|
|
"""The list is what would announce a private command family to strangers."""
|
|
registry = full_registry()
|
|
register_pack_commands(registry, PackHolder(load_pack(pack_fixture.build(tmp_path / "pack"))))
|
|
|
|
text = render_help(registry.commands, SECOND_GROUP)
|
|
|
|
assert PACK_HEADING not in text
|
|
assert "alfa" not in text
|
|
assert "!kot" in text
|
|
|
|
|
|
def test_a_pack_of_one_prints_no_hint(tmp_path: Path) -> None:
|
|
""" "Combine them" needs two things to combine."""
|
|
root = pack_fixture.build(
|
|
tmp_path / "pack",
|
|
people=[{"key": "p1", "triggers": ["!alfa"]}],
|
|
combos=[],
|
|
tags={},
|
|
)
|
|
registry = full_registry()
|
|
register_pack_commands(registry, PackHolder(load_pack(root)))
|
|
|
|
text = render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
assert PACK_HEADING in text
|
|
assert "Kilka naraz" not in text
|
|
|
|
|
|
class _NoStripper:
|
|
"""Registration needs a stripper; rendering help never calls one."""
|
|
|
|
def strip(self, payload: bytes, kind: MediaKind) -> bytes | None:
|
|
return payload
|
|
|
|
|
|
def test_the_album_commands_get_their_own_block(tmp_path: Path) -> None:
|
|
"""Their own heading, so the "kilka naraz" hint keeps describing people.
|
|
|
|
And neither description may name anybody: !pomoc prints them, and the
|
|
trigger is the only place a name is allowed to appear.
|
|
"""
|
|
holder = PackHolder(load_pack(pack_fixture.build(tmp_path / "pack")))
|
|
registry = full_registry()
|
|
register_pack_commands(registry, holder)
|
|
register_random_command(registry, holder, ("!losowe",))
|
|
register_add_command(registry, holder, _NoStripper(), ("!dodaj",))
|
|
|
|
text = render_help(registry.commands, WATCHED_GROUP)
|
|
|
|
assert f"\n{ALBUM_HEADING}\n" in text
|
|
assert "!dodaj — dodaje załączone zdjęcie do albumu z podanymi etykietami" in text
|
|
assert "!losowe — losowe zdjęcie z albumu, opcjonalnie po etykiecie" in text
|
|
# The people's block is still its own, with its own hint.
|
|
assert f"\n{PACK_HEADING}\n" in text
|
|
assert "Kilka naraz (!alfa !beta)" in text
|
|
|
|
|
|
def test_the_album_commands_are_invisible_in_every_other_group(tmp_path: Path) -> None:
|
|
holder = PackHolder(load_pack(pack_fixture.build(tmp_path / "pack")))
|
|
registry = full_registry()
|
|
register_pack_commands(registry, holder)
|
|
register_random_command(registry, holder, ("!losowe",))
|
|
register_add_command(registry, holder, _NoStripper(), ("!dodaj",))
|
|
|
|
text = render_help(registry.commands, SECOND_GROUP)
|
|
|
|
assert ALBUM_HEADING not in text
|
|
assert "!dodaj" not in text
|
|
assert "!losowe" not in text
|