signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki 46db2e7753 feat: the message event log, and the nightly report built on it
The tally was one integer per person, deleted after each post. Nothing
dated survived it, so weeks, months, records, streaks and weekday
averages were all unanswerable. This replaces it with an append-only
per-message log holding metadata only -- never a message body -- and
rebuilds the nightly report on top.

What changes for the group: a photo posted without a caption now counts
(it was worth zero), stickers count, the bot's own messages no longer
do, people who said nothing appear with a 0, and the report covers the
previous full calendar day in Europe/Warsaw instead of "since the last
post" -- which used to merge two days whenever a group went quiet.

Notable decisions, each with the trap it avoids:

- Events and people are PERMANENT_TABLES: neither forget_group nor a
  future SCHEMA_VERSION bump may drop them. Both paths used to cost a
  day's counter and would now cost the entire history, and forget_group
  fires on a listing that merely omits members.
- is_own comes from which envelope branch carried the payload, not from
  comparing identities. The account is spelled as a uuid on some frames
  and a +E.164 number on others, so any comparison misses one of them.
- The roster is stashed in refresh(), not _adopt(), which returns early
  once the watched set stops changing -- i.e. always, in the steady
  state.
- The roster matches on uuid OR number because /v1/groups spells kuhy as
  +48516501330 while his messages carry an ACI uuid. Joining on uuid
  alone would have listed him twice every night, once with 0.
- Members whose display name we have never seen are counted, not named:
  their only spellings are a uuid or a phone number.
- (group, uuid, sent_at_ms) is the primary key, so a websocket
  redelivery after a reconnect cannot inflate the counts.
- Days come from a stored Warsaw date string, never a timestamp range:
  2026-03-29 is 23 hours long and 2026-10-25 is 25.
- Polish number agreement is a real rule with a 12-14 exception, so
  "44 znaki" and "45 znaków" are both spelled correctly.

config.py and startup.py were two lines under the 250-line cap and both
had to grow, so config_env and startup_retry come out of them first, as
pure moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:14:43 +02:00

169 lines
5.9 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 periods
from signal_bot.stats.queries_day import day_rows, day_totals, with_silent
from signal_bot.stats.render_day import render_day
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.
"""
day = periods.previous_day(self.clock())
for group_id in self.directory.groups:
rows = day_rows(self.store.events, group_id, day)
if not rows:
# Logged, not posted. 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)
continue
named = self.store.events.roster(group_id, self.directory.member_identities(group_id))
rows = with_silent(rows, [(p.uuid, p.display_name) for p in named])
text = render_day(
day,
rows,
day_totals(rows),
since=self.store.events.first_day(group_id),
)
try:
await self.transport.send_text(text, group_id)
except Exception:
logger.exception("daily report failed for group %s", 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,
)
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,
)