signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

247 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.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.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)
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_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,
)