mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
!dodaj is unwired: register_add_command is no longer called, its aliases
are gone from aliases.json, and the word is an unknown one like any
other -- answered with "Nie znam !dodaj", storing nothing. The ingest
path itself (pack_add, pack_ingest, pack_store, the stripper) stays,
tested at 100%, because the approval queue that replaces the direct
write will reuse every piece of it: !dodaj lands in pending/, and the
tagger view gets approve / reject.
The eight e2e scenarios that proved the direct write, the media
generation step and prove_the_add_photo_invariants leave with it;
64852ae has them for the queue to bring back in their new shape. One
scenario takes their place and pins the disabled state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
10 KiB
Python
244 lines
10 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.analytics_command import register_analytics_command
|
|
from signal_bot.animals import load_aliases, register_animal_commands
|
|
from signal_bot.announce import announce_all
|
|
from signal_bot.art_command import register_art_command
|
|
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.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.poem_command import register_poem_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.suggest_command import register_suggest_command
|
|
from signal_bot.traps_command import register_trap_command
|
|
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)
|
|
|
|
|
|
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_trap_command(registry, fetcher, config.commons_api_url, decorate=rate_limit)
|
|
register_art_command(registry, fetcher, config.commons_api_url, decorate=rate_limit)
|
|
register_poem_command(registry, fetcher, config.poem_text_url, decorate=rate_limit)
|
|
register_stats_commands(registry, decorate=rate_limit)
|
|
# Reads the *command* history rather than the message history, and
|
|
# reads the registry when it runs -- so the album's commands, which
|
|
# are registered below this line, are still in the near-miss set.
|
|
register_analytics_command(registry, decorate=rate_limit)
|
|
# Rate-limited like every other command, and unlike !add_photo, whose
|
|
# "NO CAP, NO LIMITS" is a deliberate exception rather than a default.
|
|
# The cost is real and accepted: somebody told "napisz !zaproponuj ..."
|
|
# who then files two ideas in a row is asked to wait for the second. The
|
|
# alternative is one person being able to fill a table nothing prunes.
|
|
register_suggest_command(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,
|
|
)
|