signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki 410dcda5d2
Some checks are pending
CI / check (push) Waiting to run
release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

142 lines
4.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The bot object itself: the command wiring and the two loops it owns.
Split out of app.py, which had grown to the 250-line cap with the ASGI
entrypoint, the object graph and the startup sequence all in one file. This
half is "what the bot does"; startup.py is "how it comes up".
"""
from __future__ import annotations
import datetime as dt
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.animals import register_animal_commands
from signal_bot.announce import announce_all
from signal_bot.changelog import load as load_changelog
from signal_bot.counting import format_tally
from signal_bot.decorators import cooldown
from signal_bot.dispatcher import Dispatcher
from signal_bot.http_client import ImageFetcher
from signal_bot.popes_command import register_pope_command
from signal_bot.portraits import PortraitCache
from signal_bot.registry import CommandRegistry
from signal_bot.versioning import POPE, __version__
if TYPE_CHECKING:
import httpx
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.liveness import Liveness
from signal_bot.store import Store
from signal_bot.transport_base import Transport
logger = logging.getLogger(__name__)
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND."
def now_local() -> dt.datetime:
"""Current local time, timezone-aware."""
return dt.datetime.now().astimezone()
@dataclass
class Bot:
"""The wired-up bot and its two long-running loops."""
config: Config
store: Store
transport: Transport
directory: GroupDirectory
dispatcher: Dispatcher
liveness: Liveness
async def publish_report(self) -> None:
"""Post each group's own tally into that group, and reset it.
Each group is isolated on purpose. One group refusing the report --
the bot removed from it, a transient send failure -- must not cost
every other group its report, and a tally is cleared only after its
report has actually gone out, so a failed send loses nothing. A
group where nobody spoke is skipped entirely.
"""
for group_id in self.directory.groups:
rows = [(t.display_name or t.uuid, t.count) for t in self.store.tallies(group_id)]
if not rows:
# Logged, not posted. The bot is in every group it was ever
# added to, so posting "nobody spoke" would mean a nightly
# ping into every quiet group, forever.
logger.info("no messages in %s today; not posting a report", group_id)
continue
try:
await self.transport.send_text(format_tally(rows), group_id)
except Exception:
logger.exception("daily report failed for group %s", group_id)
continue
self.store.reset_tallies(group_id)
async def announce_release(self) -> None:
"""Tell every watched group about a version it has not seen yet."""
await announce_all(
self.transport,
self.store,
list(self.directory.groups),
load_changelog(),
__version__,
POPE.name,
)
async def listen(self) -> None:
"""Consume frames forever, handing each to the dispatcher."""
async for frame in self.transport.receive():
# Every frame counts, including receipts and typing indicators:
# the question this answers is "can anything still reach us?",
# not "did anybody address the bot?".
self.liveness.note_envelope()
await self.dispatcher.handle(frame)
def build_bot(
config: Config,
client: httpx.AsyncClient,
store: Store,
transport: Transport,
directory: GroupDirectory,
liveness: Liveness,
) -> Bot:
"""Assemble the object graph from its injected dependencies."""
registry = CommandRegistry()
fetcher = ImageFetcher(client)
rate_limit = cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE)
register_animal_commands(
registry,
fetcher,
decorate=rate_limit,
endpoints=config.image_api_urls,
)
register_pope_command(
registry,
PortraitCache(fetcher, config.commons_api_url),
decorate=rate_limit,
)
dispatcher = Dispatcher(
config=config,
directory=directory,
registry=registry,
store=store,
transport=transport,
clock=now_local,
)
return Bot(
config=config,
store=store,
transport=transport,
directory=directory,
dispatcher=dispatcher,
liveness=liveness,
)