signal-bot/signal_bot/liveness.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

172 lines
6.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Whether the bot can still hear, and the gate that stops it saying yes anyway.
On 2026-08-29 signal-cli's connection to Signal died and reconnected in a
loop for three hours. Every surface said the bot was fine: the container's
own healthcheck answered 204, ``/v1/groups`` answered 200 out of local
state, ``/healthz`` answered 200, and the bot's WebSocket to the container
stayed *open* -- the container keeps client sockets alive even when its own
upstream is dead, so the transport's reconnect logic never fired. The only
symptom was silence, and silence is also what a quiet evening looks like.
So this module answers "is the silence real?" with a question nobody can
answer from a cache: ``/v1/devices``, which needs a live round trip to
Signal. It is asked only after the silence has gone on long enough to be
worth asking about, so a busy group costs nothing and an idle one costs one
request per silence window.
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Final
import httpx
if TYPE_CHECKING:
from signal_bot.config import Config
logger = logging.getLogger(__name__)
# Measured against the real failure: the broken endpoint answered in 3.9s
# once and hung past 20s another time. 15s is above the observed failure
# latency and below config.liveness_tick_seconds, so probes cannot pile up.
PROBE_TIMEOUT_SECONDS: Final = 15.0
# signal-cli's own reconnect loop backs off in 100..3200ms, so the failure is
# stable within a second. Three probes 30s apart is not "waiting it out", it
# is refusing to trip on one bad round trip.
FAILURES_BEFORE_DEAF: Final = 3
Ticker = Callable[[], float]
Sleeper = Callable[[float], Awaitable[None]]
class Liveness:
"""Tracks proof that messages can still arrive, and reports when they cannot."""
def __init__(
self,
config: Config,
client: httpx.AsyncClient,
*,
ticker: Ticker | None = None,
sleeper: Sleeper | None = None,
failures_before_deaf: int = FAILURES_BEFORE_DEAF,
) -> None:
"""Wire the gate to its config and its injectable I/O.
Args:
config: Resolved runtime configuration.
client: HTTP client used for the probe.
ticker: Monotonic clock. Injected by the tests.
sleeper: Async sleep used by run(). Injected by the tests.
failures_before_deaf: Consecutive failed probes before the bot
stops calling itself healthy.
"""
self._config = config
self._client = client
self._now = ticker if ticker is not None else time.monotonic
self._sleep = sleeper if sleeper is not None else asyncio.sleep
self._limit = failures_before_deaf
# Healthy until proven otherwise: the installer waits on /healthz
# with a 60s budget, and a cold "unknown means 503" would fail the
# install and burn the unit's restart limit for no reason.
self._last_alive = self._now()
self._failures = 0
self._detail = ""
@property
def healthy(self) -> bool:
"""True while the bot has no evidence that it has gone deaf."""
return self._failures < self._limit
@property
def silent_for(self) -> float:
"""Seconds since the last envelope or successful probe."""
return self._now() - self._last_alive
@property
def reason(self) -> str:
"""Why the bot believes it is deaf, in one line for /healthz."""
return (
f"nothing received for {self.silent_for:.0f}s and {self._failures} "
f"consecutive failed probes of {self._config.devices_url}: {self._detail}"
)
def note_envelope(self) -> None:
"""Record proof of life: any frame at all, or a successful probe."""
self._last_alive = self._now()
if self._failures:
logger.warning("receive path recovered after %d failed probe(s)", self._failures)
self._failures = 0
self._detail = ""
async def probe(self) -> bool:
"""Ask signal-cli something it cannot answer from local state.
Returns:
True when the account's connection to Signal is alive.
"""
try:
response = await self._client.get(
self._config.devices_url, timeout=PROBE_TIMEOUT_SECONDS
)
except httpx.HTTPError as exc:
self._detail = f"{type(exc).__name__}: {exc}"
return False
if response.status_code != httpx.codes.OK:
self._detail = f"HTTP {response.status_code}: {response.text.strip()[:200]}"
return False
return True
async def run(self, iterations: int | None = None) -> None:
"""Check for deafness forever.
Args:
iterations: Stop after this many ticks. None means forever.
"""
completed = 0
while iterations is None or completed < iterations:
await self._sleep(self._config.liveness_tick_seconds)
await self.check()
completed += 1
async def check(self) -> None:
"""Probe once, but only when the silence has lasted long enough.
Public because it is the whole gate in one call: the tests, and any
future hand-run diagnostic, must be able to ask the same question
the loop asks without reaching into private state.
"""
silent = self.silent_for
if silent < self._config.silence_seconds:
return
if await self.probe():
# A live probe is proof of life exactly like an envelope, and
# resetting the clock here is what bounds the cost: an idle
# account probes once per silence window, not once per tick.
self.note_envelope()
return
self._failures += 1
if self._failures <= self._limit:
logger.warning(
"signal-cli cannot reach Signal (failure %d of %d) after %.0fs of silence: %s",
self._failures,
self._limit,
silent,
self._detail,
)
return
# Past the limit the verdict is already in, and "failure 8 of 3"
# reads like a bug in the counter rather than a bot that is still
# deaf. Keep saying it, but say it truthfully.
logger.warning(
"still deaf: %d failed probe(s) and %.0fs of silence: %s",
self._failures,
silent,
self._detail,
)