signal-bot/tests/test_bot.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

128 lines
4.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Command wiring, the daily report, and the listen loop."""
from __future__ import annotations
import datetime as dt
from dataclasses import replace
from typing import TYPE_CHECKING
import httpx
from signal_bot.bot import Bot, build_bot, now_local
from signal_bot.liveness import Liveness
from signal_bot.stats.events import MessageEvent
from signal_bot.stats.periods import WARSAW
from tests import pack_fixture
from tests.conftest import (
WATCHED_GROUP,
envelope,
mock_client,
)
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
# The report covers the day it runs on, so the tests freeze "now" and write
# their events into it.
NOW = dt.datetime(2026, 9, 2, 21, 37, tzinfo=WARSAW)
YESTERDAY = "2026-09-01"
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
client = mock_client(lambda r: httpx.Response(200))
return build_bot(
config, client, store, transport, directory, Liveness(config, client), clock=lambda: NOW
)
def wrote(
store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = YESTERDAY
) -> None:
"""Put ``times`` messages from one person into one group on one day."""
for i in range(times):
store.events.record(
MessageEvent(
group_id=group_id,
uuid=uuid,
display_name=name,
sent_at_ms=i + 1,
day=day,
local_hour=12,
char_len=5,
)
)
store.events.note_person(group_id, uuid, name, "", day)
def test_now_local_is_timezone_aware() -> None:
assert now_local().tzinfo is not None
def test_build_bot_registers_the_animal_commands(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
assert bot.dispatcher.registry.resolve("!cat") is not None
assert bot.dispatcher.registry.resolve("!pies") is not None
def test_build_bot_registers_the_culture_commands(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""!sztuka and !wiersz reach the live registry, off the shipped packs.
Both read a committed data file at registration time rather than at the
moment somebody types the trigger, so a missing or malformed pack is a
startup failure. This is the test that would catch one.
"""
bot = _bot(config, store, transport, directory)
assert bot.dispatcher.registry.resolve("!sztuka") is not None
assert bot.dispatcher.registry.resolve("!wiersz") is not None
async def test_listen_dispatches_every_frame(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
transport.queue([envelope(text="hello", group_id=WATCHED_GROUP)])
await _bot(config, store, transport, directory).listen()
row = store.events.connection.execute(
"SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (WATCHED_GROUP,)
).fetchone()
assert row["n"] == 1
def test_an_album_is_wired_up_only_where_there_is_one(
tmp_path: Path,
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
) -> None:
"""Absent by default; present, and scoped, when the config names a pack."""
client = mock_client(lambda r: httpx.Response(200))
liveness = Liveness(config, client)
plain = build_bot(config, client, store, transport, directory, liveness)
assert plain.dispatcher.registry.resolve("!alfa") is None
root = pack_fixture.build(tmp_path / "pack")
with_album = build_bot(
replace(config, photo_pack_dirs=(root,)),
client,
store,
transport,
directory,
liveness,
)
command = with_album.dispatcher.registry.resolve("!alfa")
assert command is not None
assert command.name == "pack:p1"
assert command.groups == frozenset({WATCHED_GROUP})