signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki 4af32f8bd5 Handle frames side by side, and budget the bot's own share of a reply
Bot.listen awaited every frame to completion, so one Commons fetch or one
attachment upload held up every text reply behind it (DOCS-latency.md).
Each frame now runs in its own task, never more than LIMIT at once; the
reader waits for a slot before spawning, so a burst applies back-pressure
at the websocket instead of fanning out upstream. One person's commands
stay serialised (user_lock.py): two quick !kot would otherwise both pass
the cooldown gate before either recorded a use.

Every served command reports its timings split into the bot's own time
and everybody else's (served.py); /_selftest carries that block and
tool/latency.py holds own_ms to 100 ms at p95, with the command's
cooldown switched off for the run and put back afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:35:34 +02:00

237 lines
9.2 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 asyncio
import datetime as dt
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from signal_bot.announce import announce_all
from signal_bot.changelog import load as load_changelog
from signal_bot.dispatcher import Dispatcher
from signal_bot.http_client import ImageFetcher
from signal_bot.inflight import Inflight
from signal_bot.mute import is_muted
from signal_bot.portraits import PortraitCache
from signal_bot.registry_holder import RegistryHolder
from signal_bot.reporting import compose
from signal_bot.stats import periods
from signal_bot.stats.nightly import days_to_report, mark_reported
from signal_bot.versioning import POPE
from signal_bot.wiring import COOLDOWN_MESSAGE, build_registry
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.served import Served
from signal_bot.store import Store
from signal_bot.transport_base import Transport
logger = logging.getLogger(__name__)
Clock = Callable[[], dt.datetime]
# Re-exported: the message lives with the wiring now, and this is where
# every test and the docs have always imported it from.
__all__ = ["COOLDOWN_MESSAGE", "Bot", "Clock", "build_bot", "now_local"]
def now_local() -> dt.datetime:
"""Current Warsaw time -- the group's day, not the host's.
One definition of "day" for the whole bot. The daily scheduler fires
against this, ``CommandContext.day`` keys the daily limits off it, and
every event is filed under it, so a host in another timezone (or a
container on UTC) cannot make those three disagree.
"""
return periods.now()
@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
# The same cache !papiez draws from, so a release announcement and the
# command never download the same portrait twice.
portraits: PortraitCache
# The same clock the dispatcher files events against. One clock, so
# "which day is it" cannot differ between writing an event and reporting
# on it -- which is precisely the drift the old reset-after-post had.
clock: Clock = now_local
# The frames being handled right now, bounded: see inflight.
inflight: Inflight = field(default_factory=Inflight)
async def publish_report(self) -> None:
"""Post each group **one** message: today, plus anything still owed.
Today, because 21:37 is when the group expects to read its own
numbers; a post at 21:37 about yesterday is a day late to everyone
looking at it. The count therefore closes at the report time and the
rest of the evening lands in tomorrow's day -- render_day says so,
because a total that quietly omits three hours is worse than one that
admits to it.
One message, always. A run can have several things to say -- a day
the bot was down for, the day it is on now, and the week or month
that ended inside them -- and saying each in its own post turns one
evening into four notifications. They are blocks in one message
instead, which is also what makes the send atomic: either the group
got the lot, or nothing is marked and the next run says it all again.
Nothing is deleted afterwards. 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.
"""
latest = periods.today(self.clock())
for group_id in self.directory.groups:
await self._report_group(group_id, latest)
async def _report_group(self, group_id: str, latest: str) -> None:
"""Say everything one group is owed, in one message or not at all."""
days = days_to_report(self.store, group_id, latest)
if not days:
return
now = self.clock()
# Deferred, not dropped: nothing is marked, so the next run says
# these days along with its own. A mute is ten minutes at most, and
# the report is the one message a mute must be able to hold back.
if is_muted(self.store, group_id, now.timestamp()):
logger.info("report for %s held back: the group is muted", group_id)
return
text = compose(
self.store,
self.directory,
group_id,
days,
today=periods.today(now),
# Off the clock rather than off REPORT_HOUR: the scheduled run
# fires at the configured minute and so says exactly that, while
# a run started by hand at some other hour tells the truth
# instead of repeating a time nobody waited for.
closed_at=now.strftime("%H:%M"),
)
if text:
try:
await self.transport.send_text(text, group_id)
except Exception:
# Nothing is marked, so the next run repeats the whole thing
# rather than losing the days that were inside this message.
logger.exception("report failed for group %s", group_id)
return
# Marked even when there was nothing to say: a day nobody spoke on is
# settled, not pending, and leaving it unmarked would make the bot
# reconsider it every night forever.
for day in days:
mark_reported(self.store, group_id, day)
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(),
POPE,
self.portraits,
)
async def listen(self) -> None:
"""Consume frames forever, handing each to the dispatcher.
Each frame runs in a task of its own, so a slow reply -- a picture
fetched from Commons, an attachment upload -- never holds up the
text reply behind it. The reader only pauses when every slot is
busy. When the stream ends the frames in flight are finished; when
the bot is stopped they are stopped with it.
"""
try:
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.inflight.start(
self.dispatcher.handle(frame, received_at=time.perf_counter())
)
await self.inflight.drain()
except asyncio.CancelledError:
self.inflight.cancel()
raise
async def serve(self, frame: str) -> Served | None:
"""Handle one frame here and now, through the same slots ``listen`` uses.
For the self-test: it wants the timings back, which a spawned task
cannot give it, and it must queue behind real frames exactly as a
real frame would, or its numbers are not the bot's.
"""
async with self.inflight.slot():
return await self.dispatcher.handle(frame, received_at=time.perf_counter())
def reload_commands(self) -> bool:
"""Rebuild the command table from the data on disk, keeping the old one on failure."""
return self.dispatcher.holder.reload()
@property
def command_count(self) -> int:
"""How many commands the live table holds -- what /_reload reports back."""
return len(self.dispatcher.registry.commands)
def build_bot(
config: Config,
client: httpx.AsyncClient,
store: Store,
transport: Transport,
directory: GroupDirectory,
liveness: Liveness,
clock: Clock = now_local,
) -> Bot:
"""Assemble the object graph from its injected dependencies.
The registry is built through a holder so that the data behind it --
aliases, albums -- can be re-read without a restart: see registry_holder.
The fetcher and the portrait cache outlive any one table and are shared
with every rebuild.
"""
fetcher = ImageFetcher(client)
portraits = PortraitCache(fetcher, config.commons_api_url)
holder = RegistryHolder(lambda: build_registry(config, fetcher, portraits))
dispatcher = Dispatcher(
config=config,
directory=directory,
holder=holder,
store=store,
transport=transport,
clock=clock,
)
return Bot(
config=config,
store=store,
transport=transport,
directory=directory,
dispatcher=dispatcher,
liveness=liveness,
portraits=portraits,
clock=clock,
)