mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 21: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>
235 lines
8.2 KiB
Python
235 lines
8.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""What !analityka says, and the two things it must never say."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.analytics import render
|
|
from signal_bot.analytics.demand import Demand, NearMiss
|
|
from signal_bot.analytics.hours import Peak
|
|
from signal_bot.analytics.render import Summary
|
|
from signal_bot.analytics.scope import Window
|
|
from signal_bot.analytics.usage import CommandUse, PersonUse
|
|
from signal_bot.analytics_command import register_analytics_command, triggers_for
|
|
from signal_bot.dispatcher import Dispatcher
|
|
from signal_bot.registry import CommandRegistry
|
|
from signal_bot.registry_holder import RegistryHolder
|
|
from signal_bot.stats.events import MessageEvent
|
|
from tests.analytics_seed import ALA, BASIA, Shape, ran
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.context import CommandContext
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_fake import FakeTransport
|
|
|
|
DAY = "2026-08-29"
|
|
NOW = dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC)
|
|
|
|
|
|
def a_registry() -> CommandRegistry:
|
|
"""A registry with the command under test on it."""
|
|
registry = CommandRegistry()
|
|
register_analytics_command(registry, aliases={"command_stats": ["!analityka"]})
|
|
return registry
|
|
|
|
|
|
def seed(store: Store, *, group_id: str = WATCHED_GROUP) -> None:
|
|
"""A small history with a name attached to one of the two people."""
|
|
for minute in range(3):
|
|
ran(
|
|
store,
|
|
day=DAY,
|
|
hour=19,
|
|
minute=minute,
|
|
who=ALA,
|
|
command="pope",
|
|
shape=Shape(group_id=group_id),
|
|
)
|
|
ran(store, day=DAY, hour=9, who=BASIA, command="cat", shape=Shape(group_id=group_id))
|
|
store.events.record(
|
|
MessageEvent(
|
|
group_id=group_id,
|
|
uuid=ALA,
|
|
display_name="ala",
|
|
sent_at_ms=1,
|
|
day=DAY,
|
|
local_hour=19,
|
|
is_command=True,
|
|
)
|
|
)
|
|
store.events.note_person(group_id, ALA, "ala", "", DAY)
|
|
|
|
|
|
async def reply_of(dispatcher: Dispatcher, transport: FakeTransport) -> str:
|
|
"""Run !analityka and return what went into the group."""
|
|
await dispatcher.handle(envelope(text="!analityka"))
|
|
return transport.sent[-1].text
|
|
|
|
|
|
def test_an_empty_history_says_so_in_one_line() -> None:
|
|
"""A fresh install must not answer with a blank block."""
|
|
summary = render.Summary(
|
|
measured=Window(),
|
|
uses=(),
|
|
people=(),
|
|
names={},
|
|
peak=Peak(),
|
|
direction="unknown",
|
|
demand=Demand(),
|
|
asker=ALA,
|
|
commands=0,
|
|
messages=0,
|
|
)
|
|
assert render.render(summary) == render.NOTHING_YET
|
|
|
|
|
|
def test_the_reply_is_capped_at_twelve_lines() -> None:
|
|
"""The cap, on a summary that fills every optional line."""
|
|
summary = Summary(
|
|
measured=Window(first_day=DAY, last_day="2026-09-30", days=30, events=99),
|
|
uses=(
|
|
CommandUse("pope", 40, 3, 30, 8, 2, DAY, DAY),
|
|
CommandUse("cat", 10, 2, 10, 0, 0, DAY, DAY),
|
|
CommandUse("dog", 1, 1, 1, 0, 0, DAY, DAY),
|
|
),
|
|
people=(PersonUse(ALA, 30, 3, "pope"), PersonUse(BASIA, 5, 1, "cat")),
|
|
names={ALA: "ala", BASIA: "basia"},
|
|
peak=Peak(busiest=19, busiest_uses=20, quietest=5, quietest_uses=1),
|
|
direction="rising",
|
|
demand=Demand(near_misses=(NearMiss("!pulapka", 17, 4),)),
|
|
asker=ALA,
|
|
commands=99,
|
|
messages=300,
|
|
)
|
|
lines = render.render(summary).split("\n")
|
|
assert len(lines) <= render.MAX_LINES
|
|
assert lines[0] == render.HEADING
|
|
# Every line earned its place; none is blank padding.
|
|
assert all(line.strip() for line in lines)
|
|
|
|
|
|
def test_the_window_is_stated_before_anything_is_claimed(store: Store) -> None:
|
|
"""The bot is days old, and a reader has to be able to see that."""
|
|
line = render.window_line(Window(first_day=DAY, last_day=DAY, days=1, events=4))
|
|
assert line.startswith("Okno: 1 dzień")
|
|
assert "4 razy" in line
|
|
|
|
|
|
def test_a_span_of_days_names_both_ends() -> None:
|
|
line = render.window_line(
|
|
Window(first_day="2026-08-29", last_day="2026-08-31", days=3, events=10)
|
|
)
|
|
assert "3 dni" in line
|
|
assert "29 sierpnia" in line
|
|
assert "31 sierpnia" in line
|
|
|
|
|
|
def test_no_line_carries_padding() -> None:
|
|
"""Signal renders a proportional font, so ljust lines up on nobody's phone."""
|
|
line = render.window_line(Window(first_day=DAY, last_day=DAY, days=1, events=1))
|
|
assert line == line.strip()
|
|
assert " " not in line
|
|
|
|
|
|
def test_a_direction_is_only_worded_when_there_is_one() -> None:
|
|
assert render.trend_line("unknown") == ""
|
|
assert render.trend_line("rising") == "użycie rośnie"
|
|
assert render.trend_line("falling") == "użycie spada"
|
|
assert render.trend_line("flat") == "użycie się trzyma"
|
|
|
|
|
|
def test_nobody_is_named_without_a_display_name() -> None:
|
|
"""The roster knows the rest as a uuid or a phone number, and both leak."""
|
|
people = (PersonUse(ALA, 5, 2, "pope"), PersonUse(BASIA, 3, 1, "cat"))
|
|
assert render.people_line(people, {}) == ""
|
|
named = render.people_line(people, {ALA: "ala"})
|
|
assert named == "Kto najwięcej: ala 5"
|
|
assert BASIA not in named
|
|
|
|
|
|
def test_the_asker_gets_their_own_favourite_and_nobody_elses() -> None:
|
|
"""Self-scope: a per-person breakdown of everybody stays off the chat."""
|
|
people = (PersonUse(ALA, 5, 2, "pope"), PersonUse(BASIA, 3, 1, "cat"))
|
|
assert render.mine_line(people, ALA) == "Twoja ulubiona: !pope (5 razy)"
|
|
assert render.mine_line(people, "uuid-nobody") == ""
|
|
|
|
|
|
def test_a_healthy_bot_says_nothing_about_health() -> None:
|
|
"""A line with nothing to say is dropped, never printed empty."""
|
|
healthy = (CommandUse("cat", 3, 1, 3, 0, 0, DAY, DAY),)
|
|
assert render.health_line(healthy) == ""
|
|
|
|
|
|
def test_the_worst_command_is_named_with_what_is_wrong() -> None:
|
|
"""Refusals count as much as errors: both read as "it does not work"."""
|
|
uses = (
|
|
CommandUse("cat", 10, 2, 6, 3, 1, DAY, DAY),
|
|
CommandUse("dog", 4, 1, 4, 0, 0, DAY, DAY),
|
|
)
|
|
line = render.health_line(uses)
|
|
assert line.startswith("Do poprawki: !cat")
|
|
assert "błędów: 1" in line
|
|
assert "odmów: 3" in line
|
|
|
|
|
|
def test_only_what_is_actually_wrong_is_listed() -> None:
|
|
"""A command that only crashes says nothing about refusals, and vice versa."""
|
|
crashing = (CommandUse("cat", 5, 1, 3, 0, 2, DAY, DAY),)
|
|
assert render.health_line(crashing) == "Do poprawki: !cat (błędów: 2)"
|
|
|
|
refusing = (CommandUse("cat", 5, 1, 3, 2, 0, DAY, DAY),)
|
|
assert render.health_line(refusing) == "Do poprawki: !cat (odmów: 2)"
|
|
|
|
|
|
def test_share_of_chat_is_omitted_when_there_is_no_chat() -> None:
|
|
assert render.share_line(0, 0) == ""
|
|
assert render.share_line(1, 4) == "Komendy to 25% wiadomości w tej grupie"
|
|
|
|
|
|
def test_triggers_are_scoped_to_the_group_that_asked() -> None:
|
|
"""The album's triggers are real people's names.
|
|
|
|
Reporting a near miss against one outside its group would announce that
|
|
the album exists, which is the leak the dispatcher already refuses at
|
|
resolution time.
|
|
"""
|
|
|
|
async def handler(_ctx: CommandContext) -> None:
|
|
pass
|
|
|
|
registry = CommandRegistry()
|
|
registry.register("cat", ["!kot"], handler, "kot")
|
|
registry.register("pack:p1", ["!ala"], handler, "ala", groups=frozenset({"album=="}))
|
|
|
|
here = triggers_for(registry.commands, "somewhere-else==")
|
|
there = triggers_for(registry.commands, "album==")
|
|
assert "!ala" not in here
|
|
assert "!ala" in there
|
|
|
|
|
|
async def test_the_command_answers_in_the_group_that_asked(
|
|
config: object,
|
|
directory: object,
|
|
store: Store,
|
|
transport: FakeTransport,
|
|
) -> None:
|
|
"""The whole path: a real frame, the real dispatcher, a real reply."""
|
|
seed(store)
|
|
dispatcher = Dispatcher(
|
|
config=config,
|
|
directory=directory,
|
|
holder=RegistryHolder(a_registry),
|
|
store=store,
|
|
transport=transport,
|
|
clock=lambda: NOW,
|
|
)
|
|
text = await reply_of(dispatcher, transport)
|
|
|
|
assert text.startswith(render.HEADING)
|
|
assert "!pope" in text
|
|
assert "ala" in text
|
|
assert len(text.split("\n")) <= render.MAX_LINES
|