mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:54:04 +01:00
Per-person photo commands, where the mechanism is public and the album is not. Nothing identifying is in this repository: who the people are, what fires their photos and which file shows whom all live in one directory outside it, and the tests, the docs and the offline e2e run use three invented people instead. Several names in one message intersect -- "!alfa beta" is a photo with both -- and where an entry offers a choice the bot picks among the readings that actually have a photo, so a question the album could answer is not refused. A combination nobody photographed gets one line rather than silence, which from the group's side is indistinguishable from a broken bot. Scoping is enforced in the dispatcher, not in the handler: a command the group is not allowed resolves to nothing, so it is not answered, not counted as a command, and never named in a log line outside its own groups. A handler that quietly returned would leak all three. Commands are named by pseudonym (pack:p1), so the journal and command_uses say somebody asked for a photo and nothing about whose. Video works: the attachment goes out as data:video/mp4;filename=...;base64, which is the container's own spelling, read off its /swagger/doc.json. Told it is a photo, Signal renders a clip as an unplayable file. tool/tagger.py serves one page on loopback to fill the album in -- names are typed into the page, never as arguments, and it prints how many photos each command would have when it stops, because a command with zero is dead on arrival. Also renames the real people who were still sitting in the stats test fixtures. They remain in the history; the point is that nothing new is added to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
251 lines
10 KiB
Python
251 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.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.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_load import load as load_pack
|
|
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
|
|
|
|
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} {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's report for today, plus any day 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.
|
|
|
|
Everything that makes a late day recoverable stays: the day is marked
|
|
per group, so it cannot post twice, and days_to_report still yields
|
|
every day the bot was down for, oldest first.
|
|
|
|
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:
|
|
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),
|
|
# Only for a day that is still running. A day caught up after
|
|
# an outage is complete, and telling the group its count
|
|
# stopped at 21:37 would be a lie about a finished day.
|
|
closed_at=self._closing_time(day),
|
|
),
|
|
)
|
|
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
|
|
|
|
def _closing_time(self, day: str) -> str | None:
|
|
"""The "HH:MM" a still-open day was counted up to, or None."""
|
|
if day != periods.today(self.clock()):
|
|
return None
|
|
return f"{self.config.report_hour:02d}:{self.config.report_minute:02d}"
|
|
|
|
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(),
|
|
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 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.
|
|
if config.photo_pack_dir is not None:
|
|
register_pack_commands(registry, load_pack(config.photo_pack_dir), decorate=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,
|
|
)
|