mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 12:57:32 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
227 lines
8.8 KiB
Python
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,
|
|
)
|