mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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>
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Command wiring, the daily report, and the listen loop."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot.bot import Bot, build_bot, now_local
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.liveness import Liveness
|
|
from signal_bot.stats.events import MessageEvent
|
|
from signal_bot.stats.periods import WARSAW
|
|
from tests import pack_fixture
|
|
from tests.conftest import (
|
|
WATCHED_GROUP,
|
|
envelope,
|
|
mock_client,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
|
|
# The report covers the day it runs on, so the tests freeze "now" and write
|
|
# their events into it.
|
|
NOW = dt.datetime(2026, 9, 2, 21, 37, tzinfo=WARSAW)
|
|
YESTERDAY = "2026-09-01"
|
|
|
|
|
|
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
|
|
client = mock_client(lambda r: httpx.Response(200))
|
|
return build_bot(
|
|
config, client, store, transport, directory, Liveness(config, client), clock=lambda: NOW
|
|
)
|
|
|
|
|
|
def wrote(
|
|
store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = YESTERDAY
|
|
) -> None:
|
|
"""Put ``times`` messages from one person into one group on one day."""
|
|
for i in range(times):
|
|
store.events.record(
|
|
MessageEvent(
|
|
group_id=group_id,
|
|
uuid=uuid,
|
|
display_name=name,
|
|
sent_at_ms=i + 1,
|
|
day=day,
|
|
local_hour=12,
|
|
char_len=5,
|
|
)
|
|
)
|
|
store.events.note_person(group_id, uuid, name, "", day)
|
|
|
|
|
|
def test_now_local_is_timezone_aware() -> None:
|
|
assert now_local().tzinfo is not None
|
|
|
|
|
|
def test_build_bot_registers_the_animal_commands(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
bot = _bot(config, store, transport, directory)
|
|
assert bot.dispatcher.registry.resolve("!cat") is not None
|
|
assert bot.dispatcher.registry.resolve("!pies") is not None
|
|
|
|
|
|
def test_build_bot_registers_the_culture_commands(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
"""!sztuka and !wiersz reach the live registry, off the shipped packs.
|
|
|
|
Both read a committed data file at registration time rather than at the
|
|
moment somebody types the trigger, so a missing or malformed pack is a
|
|
startup failure. This is the test that would catch one.
|
|
"""
|
|
bot = _bot(config, store, transport, directory)
|
|
assert bot.dispatcher.registry.resolve("!sztuka") is not None
|
|
assert bot.dispatcher.registry.resolve("!wiersz") is not None
|
|
|
|
|
|
async def test_listen_dispatches_every_frame(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
transport.queue([envelope(text="hello", group_id=WATCHED_GROUP)])
|
|
await _bot(config, store, transport, directory).listen()
|
|
row = store.events.connection.execute(
|
|
"SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (WATCHED_GROUP,)
|
|
).fetchone()
|
|
assert row["n"] == 1
|
|
|
|
|
|
def test_an_album_is_wired_up_only_where_there_is_one(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""Absent by default; present, and scoped, when the config names a pack."""
|
|
client = mock_client(lambda r: httpx.Response(200))
|
|
liveness = Liveness(config, client)
|
|
|
|
plain = build_bot(config, client, store, transport, directory, liveness)
|
|
assert plain.dispatcher.registry.resolve("!alfa") is None
|
|
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
with_album = build_bot(
|
|
replace(config, photo_pack_dirs=(root,)),
|
|
client,
|
|
store,
|
|
transport,
|
|
directory,
|
|
liveness,
|
|
)
|
|
command = with_album.dispatcher.registry.resolve("!alfa")
|
|
assert command is not None
|
|
assert command.name == "pack:p1"
|
|
assert command.groups == frozenset({WATCHED_GROUP})
|
|
|
|
|
|
def test_reload_commands_picks_up_a_person_added_to_the_album(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""A person tagged after startup is a command after a reload, not a restart."""
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
bot = _bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)
|
|
before = bot.command_count
|
|
assert bot.dispatcher.registry.resolve("!delta") is None
|
|
|
|
people = [*pack_fixture.PEOPLE, {"key": "p4", "triggers": ["!delta"]}]
|
|
pack_fixture.build(root, people=people, tags={**pack_fixture.TAGS, "delta.png": ["p4"]})
|
|
|
|
assert bot.reload_commands() is True
|
|
assert bot.dispatcher.registry.resolve("!delta") is not None
|
|
assert bot.command_count == before + 1
|
|
|
|
|
|
def test_reload_commands_keeps_serving_when_the_album_breaks(
|
|
tmp_path: Path,
|
|
config: Config,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
directory: GroupDirectory,
|
|
) -> None:
|
|
"""A half-edited manifest must not take the working commands down with it."""
|
|
root = pack_fixture.build(tmp_path / "pack")
|
|
bot = _bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)
|
|
(root / "people.json").write_text("{not json", encoding="utf-8")
|
|
|
|
assert bot.reload_commands() is False
|
|
assert bot.dispatcher.registry.resolve("!alfa") is not None
|
|
assert bot.dispatcher.holder.last_error
|
|
# And the real loader is what said no -- the same error a restart would die on.
|
|
with pytest.raises(ConfigError):
|
|
_bot(replace(config, photo_pack_dirs=(root,)), store, transport, directory)
|