signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki 50bd390b00 release: 0.1.6 «Zephyrinus» — 21:37 reports today, not yesterday
The scheduler fired exactly on time tonight and posted nothing, because
publish_report covered the previous full day and the bot's first recorded
day was today: "no messages in group.WTI5dVptbG... on 2026-08-29". Correct by
code, a day late to everybody reading the group.

So the report covers the day it runs on. The count closes at the report
time and render_day says so, because a total that quietly omits three hours
is worse than one that admits to it. Everything that made a late day
recoverable stays: the per-group marker still prevents a double post, and
days_to_report still yields every day an outage swallowed.

closing_periods keeps taking a day rather than reading a clock, so a week
now closes on the Sunday evening it ends -- off a day that is itself still
running -- and a catch-up on a week-old Sunday still gets it right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:09:13 +02:00

244 lines
9.6 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 collections.abc import Callable
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.decorators import cooldown
from signal_bot.dispatcher import Dispatcher
from signal_bot.help_command import register_help_command
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.stats import curiosities, periods
from signal_bot.stats.backfill import backfilled_day
from signal_bot.stats.commands import register_stats_commands
from signal_bot.stats.facts import gather
from signal_bot.stats.highlights import SLOTS, select
from signal_bot.stats.nightly import closing_periods, days_to_report, mark_reported
from signal_bot.stats.queries_day import day_rows, day_totals, with_silent
from signal_bot.stats.queries_period import Trend, period_rows, total_for
from signal_bot.stats.render_day import DayView, render_day
from signal_bot.stats.render_period import render_period
from signal_bot.versioning import POPE
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__)
Clock = Callable[[], dt.datetime]
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} {unit}."
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
async def publish_report(self) -> None:
"""Post each group's report for today, plus any day 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.
Everything that makes a late day recoverable stays: the day is marked
per group, so it cannot post twice, and days_to_report still yields
every day the bot was down for, oldest first.
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:
for day in days_to_report(self.store, group_id, latest):
if not await self._report_one(group_id, day):
# Left unmarked on purpose, so the next run retries it
# rather than skipping the day forever.
break
mark_reported(self.store, group_id, day)
await self._report_closing(group_id, day)
async def _report_one(self, group_id: str, day: str) -> bool:
"""Post one group's report for one day. False means try again later."""
rows = day_rows(self.store.events, group_id, day)
if not rows:
# Settled, not pending. 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 on %s; not posting a report", group_id, day)
return True
named = self.store.events.roster(group_id, self.directory.member_identities(group_id))
with_zeros = with_silent(rows, [(p.uuid, p.display_name) for p in named])
backfilled = backfilled_day(self.store, group_id)
facts = gather(self.store.events, group_id, day, with_zeros, exclude=backfilled)
picks = select(curiosities.for_day(facts), SLOTS)
text = render_day(
day,
with_zeros,
day_totals(with_zeros),
DayView(
since=self.store.events.first_day(group_id),
approximate=backfilled == day,
highlights=tuple(pick.text for pick in picks),
# Only for a day that is still running. A day caught up after
# an outage is complete, and telling the group its count
# stopped at 21:37 would be a lie about a finished day.
closed_at=self._closing_time(day),
),
)
try:
await self.transport.send_text(text, group_id)
except Exception:
logger.exception("daily report failed for group %s", group_id)
return False
return True
def _closing_time(self, day: str) -> str | None:
"""The "HH:MM" a still-open day was counted up to, or None."""
if day != periods.today(self.clock()):
return None
return f"{self.config.report_hour:02d}:{self.config.report_minute:02d}"
async def _report_closing(self, group_id: str, day: str) -> None:
"""Post the week, month or year that ended with this day, if any.
Best effort and deliberately after the day has been marked: a failed
weekly summary must not make the bot re-post the daily one tomorrow.
"""
for period in closing_periods(day):
before = periods.previous(period)
trend = Trend(
now=total_for(self.store.events, group_id, period.start, period.end),
before=total_for(self.store.events, group_id, before.start, before.end),
)
rows = period_rows(self.store.events, group_id, period)
if not rows:
continue
text = render_period(
period,
rows,
trend,
since=self.store.events.first_day(group_id),
podium=period.kind != "week",
)
try:
await self.transport.send_text(text, group_id)
except Exception:
logger.exception("%s summary failed for group %s", period.kind, 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(),
POPE,
self.portraits,
)
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,
clock: Clock = now_local,
) -> Bot:
"""Assemble the object graph from its injected dependencies."""
registry = CommandRegistry()
fetcher = ImageFetcher(client)
portraits = PortraitCache(fetcher, config.commons_api_url)
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, portraits, decorate=rate_limit)
register_stats_commands(registry, decorate=rate_limit)
# Last, so !pomoc trails the list it prints; it reads the registry
# when it runs, so the order is presentation only.
register_help_command(registry, decorate=rate_limit)
dispatcher = Dispatcher(
config=config,
directory=directory,
registry=registry,
store=store,
transport=transport,
clock=clock,
)
return Bot(
config=config,
store=store,
transport=transport,
directory=directory,
dispatcher=dispatcher,
liveness=liveness,
portraits=portraits,
clock=clock,
)