mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 21:14:04 +01:00
A Sunday closes a week, a month's last day closes the month, and 31 December closes the year. Keyed off the day being *reported* rather than off today, which is the only reading that stays right when the bot has been offline: catching up on a week-old Sunday now also posts that week's summary, and there is a test asserting the five messages a three-day outage produces. One scheduler, a date predicate. Three background tasks firing at the same wall-clock time into the same group is three independent ways for an evening to go wrong, for no gain over an if-statement. A failed period summary is deliberately best-effort and runs after the day has been marked, so it cannot make the bot re-post the daily one tomorrow. Also: the quote frame arrived while this was being written, and it carries a trap. `quote` has its own nested `attachments` array holding the *quoted* message's media -- so a text reply to somebody's photo has attachments in the payload that belong to somebody else. Reading them would credit the replier with a photo they did not post, in a group where people reply to photos constantly, inflating one number without ever looking wrong. The parser already read the top level only and is correct. It is now pinned by a test built from the exact captured frame, and written up in DOCS-signal-envelopes.md, because "it happens to be right" and "it cannot silently stop being right" are different things. This is the same wrong-nesting-level mistake that once made the bot ignore its own stickers. test_bot.py went past the 250-line cap on the way, so the report and catch-up tests move into files named for what they cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
223 lines
8.4 KiB
Python
223 lines
8.4 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.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, __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__)
|
|
|
|
Clock = Callable[[], dt.datetime]
|
|
|
|
COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND."
|
|
|
|
|
|
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 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 the last full day.
|
|
|
|
Yesterday, not "since the last post". The old scheme reset a counter
|
|
after publishing, so a group nobody spoke in was never reset and its
|
|
next report silently covered two days. A day is now a property of
|
|
when a message was sent, which cannot drift.
|
|
|
|
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.previous_day(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),
|
|
),
|
|
)
|
|
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
|
|
|
|
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(),
|
|
__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,
|
|
clock: Clock = now_local,
|
|
) -> 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,
|
|
)
|
|
register_stats_commands(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,
|
|
clock=clock,
|
|
)
|