# 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 load_aliases, 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.media_strip import ExiftoolStripper from signal_bot.pack_add import register_add_command from signal_bot.pack_command import register_pack_commands from signal_bot.pack_holder import PackHolder from signal_bot.pack_load import load as load_pack from signal_bot.pack_random import register_random_command from signal_bot.popes_command import register_pope_command from signal_bot.portraits import PortraitCache from signal_bot.registry import CommandRegistry from signal_bot.reporting import compose from signal_bot.stats import periods from signal_bot.stats.commands import register_stats_commands from signal_bot.stats.nightly import days_to_report, mark_reported from signal_bot.versioning import POPE if TYPE_CHECKING: from pathlib import Path import httpx from signal_bot.config import Config from signal_bot.decorators import Decorator 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 **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() 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.""" 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 _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None: """Wire the album's commands, all of them reading one mutable holder. !random takes the same cooldown as every other photo command; !add_photo takes no decorator at all, per "NO CAP, NO LIMITS" -- no cooldown, no size cap, no daily limit. """ aliases = load_aliases() holder = PackHolder(load_pack(directory)) register_pack_commands(registry, holder, decorate=rate_limit) register_random_command(registry, holder, tuple(aliases["random"]), decorate=rate_limit) register_add_command(registry, holder, ExiftoolStripper(), tuple(aliases["add_photo"])) 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) # Absent unless this installation has an album, and scoped to that # album's own groups when it does -- so on every other machine, and in # every other group, these commands simply do not exist. # "album", not "directory": build_bot's own `directory` is the group # directory, and shadowing it here silently passed the wrong object. for album in config.photo_pack_dirs: _register_album(registry, album, 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, )