mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:17:02 +01:00
The tally was one integer per person, deleted after each post. Nothing dated survived it, so weeks, months, records, streaks and weekday averages were all unanswerable. This replaces it with an append-only per-message log holding metadata only -- never a message body -- and rebuilds the nightly report on top. What changes for the group: a photo posted without a caption now counts (it was worth zero), stickers count, the bot's own messages no longer do, people who said nothing appear with a 0, and the report covers the previous full calendar day in Europe/Warsaw instead of "since the last post" -- which used to merge two days whenever a group went quiet. Notable decisions, each with the trap it avoids: - Events and people are PERMANENT_TABLES: neither forget_group nor a future SCHEMA_VERSION bump may drop them. Both paths used to cost a day's counter and would now cost the entire history, and forget_group fires on a listing that merely omits members. - is_own comes from which envelope branch carried the payload, not from comparing identities. The account is spelled as a uuid on some frames and a +E.164 number on others, so any comparison misses one of them. - The roster is stashed in refresh(), not _adopt(), which returns early once the watched set stops changing -- i.e. always, in the steady state. - The roster matches on uuid OR number because /v1/groups spells kuhy as +48123456789 while his messages carry an ACI uuid. Joining on uuid alone would have listed him twice every night, once with 0. - Members whose display name we have never seen are counted, not named: their only spellings are a uuid or a phone number. - (group, uuid, sent_at_ms) is the primary key, so a websocket redelivery after a reconnect cannot inflate the counts. - Days come from a stored Warsaw date string, never a timestamp range: 2026-03-29 is 23 hours long and 2026-10-25 is 25. - Polish number agreement is a real rule with a 12-14 exception, so "44 znaki" and "45 znaków" are both spelled correctly. config.py and startup.py were two lines under the 250-line cap and both had to grow, so config_env and startup_retry come out of them first, as pure moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
235 lines
7.8 KiB
Python
235 lines
7.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Coming up while signal-cli is not there yet.
|
|
|
|
The failure being nailed down: on 2026-08-30 the machine rebooted, the bot's
|
|
first HTTP call raised because the container was still starting, the unit
|
|
burned its five restarts in 25 seconds and stayed dead for eight hours.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import signal
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from signal_bot import startup_retry as retry_module
|
|
from signal_bot.bot import build_bot
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.liveness import Liveness
|
|
from signal_bot.membership import MembershipError
|
|
from signal_bot.startup import NotReadyError, Runtime, create_runtime
|
|
from signal_bot.startup_retry import MAX_RETRY_SECONDS, Starter, terminate
|
|
from signal_bot.transport_fake import FakeTransport
|
|
from signal_bot.transport_rest import TransportError
|
|
from signal_bot.trust import InviteTrust
|
|
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, mock_client, signal_client
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.store import Store
|
|
|
|
DEAD = httpx.ConnectError("Server disconnected without sending a response.")
|
|
|
|
|
|
def _offline(request: httpx.Request) -> httpx.Response:
|
|
raise DEAD
|
|
|
|
|
|
def _fake_runtime(config: Config, store: Store) -> Runtime:
|
|
"""A runtime whose loops touch nothing outside the process."""
|
|
client = mock_client(lambda r: httpx.Response(200))
|
|
directory = GroupDirectory(config, client, store, InviteTrust(()))
|
|
liveness = Liveness(config, client)
|
|
bot = build_bot(config, client, store, FakeTransport(), directory, liveness)
|
|
return Runtime(bot=bot)
|
|
|
|
|
|
class Sleeps:
|
|
"""An async sleep that records instead of waiting."""
|
|
|
|
def __init__(self) -> None:
|
|
self.delays: list[float] = []
|
|
|
|
async def __call__(self, seconds: float) -> None:
|
|
self.delays.append(seconds)
|
|
|
|
|
|
async def test_create_runtime_verifies_the_container_mode(config: Config, store: Store) -> None:
|
|
runtime = await create_runtime(config, signal_client(WATCHED_GROUP, SECOND_GROUP), store)
|
|
assert isinstance(runtime, Runtime)
|
|
await runtime.stop()
|
|
|
|
|
|
async def test_create_runtime_refuses_a_polling_container(config: Config, store: Store) -> None:
|
|
with pytest.raises(TransportError):
|
|
await create_runtime(config, signal_client(mode="normal"), store)
|
|
|
|
|
|
async def test_create_runtime_refuses_a_pinned_group_it_cannot_join(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""A pending invite looks healthy from outside; a pinned startup must not."""
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
with pytest.raises(MembershipError, match="not a joined member"):
|
|
await create_runtime(pinned, signal_client(WATCHED_GROUP, member=False), store)
|
|
|
|
|
|
async def test_create_runtime_accepts_an_account_in_no_groups_at_all(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""An unpinned bot with no groups is not broken; it is waiting to be added."""
|
|
runtime = await create_runtime(config, signal_client(), store)
|
|
assert runtime.bot.directory.groups == ()
|
|
await runtime.stop()
|
|
|
|
|
|
async def test_an_unreadable_listing_is_not_a_missing_group(config: Config, store: Store) -> None:
|
|
"""The trap this ordering exists for.
|
|
|
|
With the container down, the watched set stays empty. Judging GROUP_IDS
|
|
on that empty set would raise MembershipError -- a verdict nothing can
|
|
retry -- for a bot that is in its group and simply cannot ask yet.
|
|
"""
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
|
|
def half_up(request: httpx.Request) -> httpx.Response:
|
|
if "/v1/groups" in request.url.path:
|
|
raise DEAD
|
|
return httpx.Response(200, json={"mode": "json-rpc"})
|
|
|
|
with pytest.raises(NotReadyError, match="could not be read"):
|
|
await create_runtime(pinned, mock_client(half_up), store)
|
|
|
|
|
|
async def test_runtime_start_and_stop_cancels_tasks(config: Config, store: Store) -> None:
|
|
runtime = _fake_runtime(config, store)
|
|
runtime.start()
|
|
# listen, the daily report, the group refresh loop, the deafness gate,
|
|
# and the one-shot release announcement.
|
|
assert len(runtime.tasks) == 5
|
|
await asyncio.sleep(0)
|
|
await runtime.stop()
|
|
assert all(task.done() for task in runtime.tasks)
|
|
|
|
|
|
async def test_starter_connects_on_the_first_attempt(config: Config, store: Store) -> None:
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(lambda r: httpx.Response(200)),
|
|
store,
|
|
sleeper=sleeps,
|
|
factory=lambda c, _client, s: _ready(c, s),
|
|
)
|
|
await starter.run()
|
|
assert starter.runtime is not None
|
|
assert sleeps.delays == []
|
|
await starter.runtime.stop()
|
|
|
|
|
|
async def _ready(config: Config, store: Store) -> Runtime:
|
|
return _fake_runtime(config, store)
|
|
|
|
|
|
async def test_starter_survives_a_container_that_is_not_up_yet(
|
|
config: Config, store: Store
|
|
) -> None:
|
|
"""The whole point: a slow container costs attempts, never the process."""
|
|
attempts = {"n": 0}
|
|
|
|
async def flaky(cfg: Config, _client: httpx.AsyncClient, opened: Store) -> Runtime:
|
|
attempts["n"] += 1
|
|
if attempts["n"] < 3:
|
|
raise DEAD
|
|
return _fake_runtime(cfg, opened)
|
|
|
|
stopped: list[bool] = []
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(_offline),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: stopped.append(True),
|
|
factory=flaky,
|
|
)
|
|
# Unhealthy and honest about why, before it ever succeeds.
|
|
assert starter.runtime is None
|
|
assert "not attempted yet" in starter.reason
|
|
|
|
await starter.run()
|
|
|
|
assert attempts["n"] == 3
|
|
assert starter.runtime is not None
|
|
# Recovered with nobody touching it, and without ending the process.
|
|
assert stopped == []
|
|
assert sleeps.delays == [5.0, 10.0]
|
|
await starter.runtime.stop()
|
|
|
|
|
|
async def test_starter_backoff_stops_growing(config: Config, store: Store) -> None:
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
mock_client(_offline),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: None,
|
|
factory=_always_offline,
|
|
)
|
|
await starter.run(iterations=8)
|
|
assert starter.runtime is None
|
|
assert max(sleeps.delays) == MAX_RETRY_SECONDS
|
|
assert f"attempt {len(sleeps.delays)}" in starter.reason
|
|
assert "ConnectError" in starter.reason
|
|
|
|
|
|
async def _always_offline(config: Config, client: httpx.AsyncClient, store: Store) -> Runtime:
|
|
raise DEAD
|
|
|
|
|
|
async def test_a_container_in_the_wrong_mode_is_fatal(config: Config, store: Store) -> None:
|
|
"""Not an outage: no number of retries turns 'normal' into 'json-rpc'."""
|
|
stopped: list[bool] = []
|
|
sleeps = Sleeps()
|
|
starter = Starter(
|
|
config,
|
|
signal_client(mode="normal"),
|
|
store,
|
|
sleeper=sleeps,
|
|
stopper=lambda: stopped.append(True),
|
|
)
|
|
await starter.run()
|
|
assert starter.runtime is None
|
|
assert stopped == [True]
|
|
# It gave up rather than backing off forever.
|
|
assert sleeps.delays == []
|
|
assert "TransportError" in starter.reason
|
|
|
|
|
|
async def test_a_pinned_group_the_bot_is_not_in_is_fatal(config: Config, store: Store) -> None:
|
|
stopped: list[bool] = []
|
|
pinned = replace(config, group_ids=(WATCHED_GROUP,))
|
|
starter = Starter(
|
|
pinned,
|
|
signal_client(WATCHED_GROUP, member=False),
|
|
store,
|
|
sleeper=Sleeps(),
|
|
stopper=lambda: stopped.append(True),
|
|
)
|
|
await starter.run()
|
|
assert stopped == [True]
|
|
assert "MembershipError" in starter.reason
|
|
|
|
|
|
def test_terminate_asks_uvicorn_to_shut_down(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A background task cannot exit the process; SIGTERM is how it asks."""
|
|
sent: list[int] = []
|
|
monkeypatch.setattr(retry_module.signal, "raise_signal", sent.append)
|
|
terminate()
|
|
assert sent == [signal.SIGTERM]
|