signal-bot/fake_signal/cli.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

184 lines
7.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Drive a running fake server through a scenario, and adjudicate the result.
Exits non-zero on any mismatch, so scripts/e2e.sh is a real gate rather than
a log to read.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
import time
from pathlib import Path
from typing import Any
import httpx
from fake_signal.scenario import Scenario, check, load_scenario
# Measured, not guessed. A surplus send is *always* detected whatever this is,
# because collect() re-reads after the expected count arrives -- injecting a
# deliberate double-send proved that at every value tried, six runs, all red.
# What this buys is **attribution**: whether the surplus is blamed on the
# scenario that caused it or on the innocent one that follows. Measured with a
# surplus delayed by hand:
#
# surplus at +0.05s / +0.25s blamed correctly at 0.35 and at 0.15
# surplus at +0.50s blamed correctly at 0.35, wrongly at 0.15
#
# 0.15 halves the replay (35s -> 19s, against a 40s budget) and only
# misattributes a surplus arriving half a second late, which took an explicit
# asyncio.sleep to construct -- a real double-send comes from the same handler
# microseconds apart. Raise it with --settle if a slow machine starts blaming
# the wrong scenario; that symptom is what this number is for.
SETTLE_SECONDS = 0.15
# How long a scenario may wait for the sends it expects before giving up.
#
# A single fixed settle cannot cover both a reply that is a dictionary lookup
# and one that fetches an image and shells out to exiftool, and the failure
# that produced was not "slow" but "wrong": the late send landed after the
# NEXT scenario's reset and was counted against that one instead. So two
# different scenarios failed per run, never the same two twice -- one with
# "captured 0" and one with "captured 2". Waiting for the count is a
# guarantee; a bigger number is a guess that fails again on a slower machine.
PATIENCE_SECONDS = 15.0
POLL_SECONDS = 0.05
def _emit(line: str) -> None:
"""Write one report line to stdout."""
sys.stdout.write(f"{line}\n")
def resolve_groups(args: argparse.Namespace) -> dict[str, str]:
"""Map the scenario's symbolic group names onto real ids.
Names are supplied as repeated ``--group NAME=ID`` pairs rather than one
flag per group, because the bot now watches an arbitrary number of them
and a scenario has to be able to say "this reply belongs in the *other*
watched group".
Args:
args: Parsed command line.
Returns:
Scenario alias to real group id.
Raises:
SystemExit: A --group argument is not NAME=ID.
"""
groups: dict[str, str] = {}
for pair in args.group:
name, separator, value = pair.partition("=")
if not separator or not name or not value:
msg = f"--group expects NAME=ID, got {pair!r}"
raise SystemExit(msg)
groups[name] = value
return groups
async def captured(client: httpx.AsyncClient) -> list[dict[str, Any]]:
"""Every send the fake server has recorded since the last reset."""
response = await client.get("/_control/sent")
response.raise_for_status()
sends: list[dict[str, Any]] = response.json()
return sends
async def collect(
client: httpx.AsyncClient, want: int, *, settle: float, patience: float
) -> list[dict[str, Any]]:
"""Wait for the sends a scenario expects, then for a beat of quiet.
The quiet beat is not politeness: returning the moment the expected count
arrives would make "expected 1, captured 2" unobservable, because the
surplus send is exactly the one that has not landed yet. A scenario that
expects nothing skips the poll entirely and waits out the settle, which
is the only way to assert that nothing happens.
Args:
client: A client pointed at the fake server's control plane.
want: How many sends the scenario expects.
settle: How long "quiet" is.
patience: How long to wait for ``want`` sends before giving up.
Returns:
Every send recorded, which may be more or fewer than expected -- the
comparison is somebody else's job.
"""
deadline = time.monotonic() + patience
sends = await captured(client)
while len(sends) < want and time.monotonic() < deadline:
await asyncio.sleep(POLL_SECONDS)
sends = await captured(client)
await asyncio.sleep(settle)
return await captured(client)
async def run_scenario(
base_url: str,
scenario: Scenario,
groups: dict[str, str],
*,
settle: float,
patience: float = PATIENCE_SECONDS,
) -> list[str]:
"""Inject every frame, wait for the bot to react, then compare."""
async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
await client.post("/_control/reset")
if scenario.arrive:
# Additive: the groups already on the account stay, exactly as
# they would when somebody adds the bot to one more.
await client.post(
"/_control/groups/add",
json=[arrival.to_entry(groups) for arrival in scenario.arrive],
)
for injection in scenario.inject:
await client.post("/_control/inject", json={"raw": injection.to_frame(groups)})
await asyncio.sleep(settle)
sent = await collect(client, len(scenario.expect), settle=settle, patience=patience)
return check(scenario, sent, groups)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"""Parse the injector's command line."""
parser = argparse.ArgumentParser(description="Replay a scenario against the bot.")
parser.add_argument("scenarios", nargs="+", type=Path)
parser.add_argument("--base-url", default="http://127.0.0.1:8099")
parser.add_argument("--group", action="append", default=[], metavar="NAME=ID")
parser.add_argument("--settle", type=float, default=SETTLE_SECONDS)
# Overridable so the tests can drive the give-up path without waiting it
# out, and so a slow machine can be given longer without editing source.
parser.add_argument("--patience", type=float, default=PATIENCE_SECONDS)
return parser.parse_args(argv)
async def _main_async(args: argparse.Namespace) -> int:
"""Run every scenario, reporting failures as they happen."""
groups = resolve_groups(args)
failed = 0
for path in args.scenarios:
scenario = load_scenario(path)
failures = await run_scenario(
args.base_url, scenario, groups, settle=args.settle, patience=args.patience
)
if failures:
failed += 1
_emit(f"FAIL {scenario.name}")
for line in failures:
_emit(f" {line}")
else:
_emit(f"ok {scenario.name}")
return 1 if failed else 0
def main(argv: list[str] | None = None) -> int:
"""Entry point. Returns a process exit code."""
return asyncio.run(_main_async(parse_args(argv)))
if __name__ == "__main__":
sys.exit(main())