signal-bot/signal_bot/bot.py
Krzysztof kuhy Rudnicki aefffb2fd9
Some checks are pending
CI / check (push) Waiting to run
feat: add a photo to the album from the chat, tagged, stripped and verified
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own
groups and it lands in the pack tagged with both, retrievable through !alfa
and the new !losowe immediately -- no restart, and no tagger session on the PC.
A label matching nobody becomes a custom tag, and !losowe is the only door to
one, which is what keeps a live swap a *data* swap: it can never register a
trigger or widen a command's groups.

No typed label travels. Labels become pseudonym keys in a new registry
arg_resolver, at the dispatcher, before the line that logs the command name --
so no handler, journal entry, command_uses row or reply body ever holds a word
somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1".

Stripping is exiftool with a *positive* check on the output, never an exit
code. ffmpeg was tried first and fails silently on photos: given a JPEG with a
real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0.
The surviving-tag allowlist is per tag *name* rather than per group, because
measurement showed a group-level "must be empty" rule rejects every format
except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime
tags that are the container itself. Codec configuration (CompressorName,
VendorID, the HEVC parameter set) is on the list by decision: CompressorName
lives in the avc1 sample description and exiftool cannot write it at all, so
the alternative was refusing every video and every iPhone HEIC. Location and
identity tags are still refused, and the e2e run re-reads the stored files to
prove a QuickTime geotag did not survive.

.webm and .svg stay on the format allowlist and are stored *unstripped*:
exiftool 13.55 can write neither, and its SVG reader does not even surface
<metadata>. That is a deliberate, documented hole, not an oversight.

Two things this had to defuse along the way:
- personal_data_sources now reads custom.json, so "!add_photo kot <photo>"
  would have outlawed the word "kot" and refused every future commit, fixable
  only by hand-editing an album the committer cannot see. Minting now refuses
  a word that is already a command, and the gate subtracts the repo's own
  alias vocabulary from custom labels only -- curated people.json names are
  never excused.
- signal_bot/external.py is now the single subprocess site in the repository,
  so ruff's S603 needs exactly one scoped per-file-ignore instead of one per
  module that shells out. A test asserts nothing else imports subprocess.

A failed add changes nothing: media and the three manifests are written, the
album is reloaded, and only a load that succeeds is published; a load that
fails rolls all four back and keeps serving the pack that was already working,
without raising -- Restart=always would turn a ConfigError into a loop.

977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh
gains 8 scenarios driven against the real exiftool on media generated at run
time, and asserts the invariants a reply cannot: that a refused upload is not
stored, that a message with no attachment leaves custom.json byte-identical,
and that signal-cli's own copy of every ingested attachment is gone.

Closes TODO-add-photo.md.

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

227 lines
8.8 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 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.
if config.photo_pack_dir is not None:
_register_album(registry, config.photo_pack_dir, 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,
)