mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
The registry used to be built once, inline in build_bot, so every change to the data behind it -- a person tagged in the web UI, an alias, soon a command switched off from the management page -- meant restarting the unit. That is cheap and lossless, but it is a deploy step somebody has to remember, and a 21:37 report can land in the middle of it. Now the registration sequence lives in wiring.build_registry, and RegistryHolder runs it again on POST /_reload: the new table is built whole and swapped in with one assignment, never patched in place, so a duplicate trigger is found before anything the dispatcher reads has changed. Bad data (ConfigError, DuplicateTriggerError) keeps the previous table and answers 409 with the reason; a programming error still surfaces as a 500. scripts/reload.sh wraps it, with --code for the restart a Python change needs, and an optional command to type in testbot so "reloaded" means "answered". No Python hot-reload on purpose. The loader change is what makes deploying this safe: two rows in the live tags.json name files that are no longer in media/, and load_pack refused them, so the next restart would have looped on ConfigError. A stale tag is drift in a file a page edits mid-session, not a broken album -- it is now skipped with a warning naming it. A tag that reaches outside the album is still refused. pack_load.py split at the 250-line cap; pack_tags.py is the half whose checks are not all fatal. Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is tagged into the synthetic pack, scripts/reload.sh runs, and the same process posts delta's photo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
78 lines
3.1 KiB
Python
78 lines
3.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""The one mutable box the command table lives in, so data changes need no restart.
|
|
|
|
``PackHolder`` made a *photo* visible the moment it was stored, and said out
|
|
loud what it would never do: add a command. A new person in the tagger, a
|
|
new alias, a command switched off from the management page -- every one of
|
|
those changes the registry itself, and until now every one of them meant
|
|
restarting the unit. Restarting is cheap and lossless (signal-cli replays
|
|
what arrived meanwhile), but it is a deploy step somebody has to remember,
|
|
and a 21:37 report can land in the middle of it.
|
|
|
|
So the registry is rebuilt, whole, from the same builder that made it at
|
|
startup, and swapped in one assignment. Never patched in place: a table
|
|
that is edited command by command can be observed half-edited, and a
|
|
duplicate trigger found on the third command of five leaves the first two
|
|
already live. Building a fresh table first means ``DuplicateTriggerError``
|
|
and ``ConfigError`` are raised before anything the dispatcher reads has
|
|
changed.
|
|
|
|
``reload`` never raises on bad *data*. The builder reads files people edit
|
|
by hand and pages write mid-session, and the only safe answer to a file
|
|
that does not load is the table that was already answering. A programming
|
|
error is not data and is not swallowed: it reaches the caller, which for
|
|
``POST /_reload`` means a 500 and a traceback in the journal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.registry import DuplicateTriggerError
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from signal_bot.registry import CommandRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A `type` alias is evaluated lazily, so the TYPE_CHECKING-only names are fine.
|
|
type Builder = Callable[[], CommandRegistry]
|
|
|
|
|
|
class RegistryHolder:
|
|
"""A mutable reference to the command table the dispatcher reads through."""
|
|
|
|
def __init__(self, build: Builder) -> None:
|
|
"""Build the first table now, loudly.
|
|
|
|
Startup is the one time a bad table must *not* be survived: there is
|
|
no previous one to fall back on, and ``Restart=always`` looping on a
|
|
ConfigError is a clearer signal than a bot that answers nothing.
|
|
"""
|
|
self._build = build
|
|
self.registry = build()
|
|
self.last_error = ""
|
|
|
|
def reload(self) -> bool:
|
|
"""Rebuild the table from whatever is on disk now and publish it.
|
|
|
|
Returns:
|
|
True when the new table is live, False when the old one is still
|
|
being served because the data no longer builds -- and then
|
|
``last_error`` says why, for whoever asked.
|
|
"""
|
|
try:
|
|
fresh = self._build()
|
|
except (ConfigError, DuplicateTriggerError) as error:
|
|
self.last_error = str(error)
|
|
logger.exception("command registry no longer builds; keeping the previous one")
|
|
return False
|
|
self.registry = fresh
|
|
self.last_error = ""
|
|
logger.info("command registry rebuilt: %d commands", len(fresh.commands))
|
|
return True
|