mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 22:03:50 +01:00
Some checks are pending
CI / check (push) Waiting to run
Release n is pope n, so the version is a fact about the ordinal rather than a judgement about the size of a change. The constraint that fixes the scheme is Jan Pawel II at 2.13.7: eight patches per minor with majors opening at ordinals 33 and 153 gives (264 - 153) = 13 * 8 + 7 exactly, and both earlier majors happen to divide evenly by eight too, so no bracket needs a fudge factor. The anchors are not arbitrary -- 33 is Sylwester I, pope through the Edict of Milan and Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism. popes.json is scraped by tool/gen_popes.py rather than recalled, because antipopes, Benedykt IX's three separate reigns and the Stefan II question all shift the count and being wrong by one moves Jan Pawel II off 2.13.7 silently. The generator refuses to write a list that is not 267 contiguous entries with Jan Pawel II at 264. A second, independent parse of the same page agreed on every ordinal, which is the only reason to believe either. On startup each watched group is told about the releases it has not seen. A group with no record has seen none of them, so the first announcement starts at 0.0.0 "Swiety Piotr Apostol" rather than skipping to whatever is current -- watching the ladder start at Peter is the point of naming releases after popes. A group added years from now would otherwise be handed the whole backlog, so the message is capped at ten releases and says how many it left out. The version is recorded before the message is sent: with Restart=always and RestartSec=5, a crash between the two would re-announce on every restart, and a missed announcement is far cheaper than a looping one. DOCS-changelog.md is the single source -- the bot parses it, so there is no second machine-readable copy to drift. It is DOCS-, not CHANGELOG.md, because the md-naming gate allows four namespaces and that is not one of them. Deliberately NOT bumping SCHEMA_VERSION for the new group_meta table: _apply_schema drops every table on a bump, so bumping to add a table would have thrown away the live tallies to gain nothing. Verified against the real database -- group_meta appeared and all four counts survived the restart. The schema moves to its own module so store.py stays under the 250-line cap, and /healthz now names the build, because there was previously no way to ask a running bot which version it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
230 lines
8.7 KiB
Python
230 lines
8.7 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Wiring, the background loops, and ASGI startup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass, replace
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from signal_bot import app as app_module
|
|
from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.liveness import Liveness
|
|
from signal_bot.membership import MembershipError
|
|
from signal_bot.transport_fake import FakeTransport
|
|
from signal_bot.transport_rest import TransportError
|
|
from signal_bot.trust import InviteTrust
|
|
from signal_bot.versioning import __version__
|
|
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope, group_entry, listing_client
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.registry import Handler
|
|
from signal_bot.store import Store
|
|
|
|
BASE_ENV = {
|
|
"PHONE_NUMBER": "+15550000000",
|
|
"GROUP_IDS": "",
|
|
"DATABASE_PATH": ":memory:",
|
|
}
|
|
|
|
|
|
# Captured before any monkeypatching, so a patched httpx.AsyncClient cannot
|
|
# recurse into this helper.
|
|
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
|
|
|
|
|
def _client(handler: Handler) -> httpx.AsyncClient:
|
|
return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler))
|
|
|
|
|
|
def _signal(*group_ids: str, mode: str = "json-rpc", member: bool = True) -> Handler:
|
|
"""Answer both startup probes: the mode check and the group listing."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if "/v1/groups" in request.url.path:
|
|
return httpx.Response(200, json=[group_entry(gid, member=member) for gid in group_ids])
|
|
return httpx.Response(200, json={"mode": mode})
|
|
|
|
return handler
|
|
|
|
|
|
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
|
|
client = _client(lambda r: httpx.Response(200))
|
|
return build_bot(config, client, store, transport, directory, Liveness(config, client))
|
|
|
|
|
|
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
|
|
|
|
|
|
async def test_publish_report_posts_each_group_its_own_tally(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
"""Every group gets its own counts, in its own chat, and only its own."""
|
|
store.increment(WATCHED_GROUP, "u1", "Alice")
|
|
store.increment(SECOND_GROUP, "u2", "Bob")
|
|
store.increment(SECOND_GROUP, "u2", "Bob")
|
|
await _bot(config, store, transport, directory).publish_report()
|
|
assert [(m.recipient, m.text) for m in transport.sent] == [
|
|
(WATCHED_GROUP, "Alice 1"),
|
|
(SECOND_GROUP, "Bob 2"),
|
|
]
|
|
assert store.tallies(WATCHED_GROUP) == []
|
|
assert store.tallies(SECOND_GROUP) == []
|
|
|
|
|
|
async def test_publish_report_falls_back_to_uuid_when_unnamed(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
store.increment(WATCHED_GROUP, "u1", "")
|
|
await _bot(config, store, transport, directory).publish_report()
|
|
assert transport.texts[0] == "u1 1"
|
|
|
|
|
|
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()
|
|
assert store.tallies(WATCHED_GROUP)[0].count == 1
|
|
|
|
|
|
async def test_create_runtime_verifies_the_container_mode(config: Config) -> None:
|
|
runtime = await create_runtime(config, _client(_signal(WATCHED_GROUP, SECOND_GROUP)))
|
|
assert isinstance(runtime, Runtime)
|
|
await runtime.stop()
|
|
|
|
|
|
async def test_create_runtime_refuses_a_polling_container(config: Config) -> None:
|
|
with pytest.raises(TransportError):
|
|
await create_runtime(
|
|
config, _client(lambda r: httpx.Response(200, json={"mode": "normal"}))
|
|
)
|
|
|
|
|
|
async def test_runtime_start_and_stop_cancels_tasks(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
client = _client(lambda r: httpx.Response(200))
|
|
runtime = Runtime(bot=_bot(config, store, transport, directory), client=client, store=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)
|
|
|
|
|
|
def test_healthz_serves_and_startup_completes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The old app never finished startup, so no route was ever reachable."""
|
|
for key, value in BASE_ENV.items():
|
|
monkeypatch.setenv(key, value)
|
|
|
|
def fake_client(**kwargs: object) -> httpx.AsyncClient:
|
|
return _client(_signal(WATCHED_GROUP))
|
|
|
|
monkeypatch.setattr(app_module.httpx, "AsyncClient", fake_client)
|
|
with TestClient(app_module.app) as client:
|
|
body = client.get("/healthz").json()
|
|
assert body["status"] == "ok"
|
|
assert body["version"] == __version__
|
|
|
|
|
|
async def test_lifespan_is_an_async_context_manager(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
for key, value in BASE_ENV.items():
|
|
monkeypatch.setenv(key, value)
|
|
monkeypatch.setattr(
|
|
app_module.httpx,
|
|
"AsyncClient",
|
|
lambda **kwargs: _client(_signal(WATCHED_GROUP)),
|
|
)
|
|
async with lifespan(app_module.app):
|
|
assert app_module.app.state.runtime is not None
|
|
|
|
|
|
async def test_create_runtime_refuses_a_pinned_group_it_cannot_join(
|
|
config: Config,
|
|
) -> 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, _client(_signal(WATCHED_GROUP, member=False)))
|
|
|
|
|
|
async def test_create_runtime_accepts_an_account_in_no_groups_at_all(
|
|
config: Config,
|
|
) -> None:
|
|
"""An unpinned bot with no groups is not broken; it is waiting to be added."""
|
|
runtime = await create_runtime(config, _client(_signal()))
|
|
assert runtime.bot.directory.groups == ()
|
|
await runtime.stop()
|
|
|
|
|
|
@dataclass
|
|
class RefusingTransport(FakeTransport):
|
|
"""A transport that rejects one group, the way a removal from it would."""
|
|
|
|
refuses: str = ""
|
|
|
|
async def send_text(self, text: str, recipient: str) -> None:
|
|
"""Record the send, unless this is the group that refuses them."""
|
|
if recipient == self.refuses:
|
|
msg = "removed from the group"
|
|
raise RuntimeError(msg)
|
|
await super().send_text(text, recipient)
|
|
|
|
|
|
async def test_one_group_failing_its_report_does_not_cost_the_others(
|
|
config: Config, store: Store, directory: GroupDirectory
|
|
) -> None:
|
|
"""A failed send must not clear that group's day, nor skip the next group."""
|
|
store.increment(WATCHED_GROUP, "u1", "Alice")
|
|
store.increment(SECOND_GROUP, "u2", "Bob")
|
|
transport = RefusingTransport(refuses=WATCHED_GROUP)
|
|
|
|
await _bot(config, store, transport, directory).publish_report()
|
|
|
|
assert [(m.recipient, m.text) for m in transport.sent] == [(SECOND_GROUP, "Bob 1")]
|
|
# The group that failed keeps its counts; the one that succeeded is reset.
|
|
assert store.tallies(WATCHED_GROUP)[0].count == 1
|
|
assert store.tallies(SECOND_GROUP) == []
|
|
|
|
|
|
async def test_a_quiet_group_is_logged_rather_than_posted_into(
|
|
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
|
|
) -> None:
|
|
"""The bot sits in every group it was added to; a nightly ping is noise."""
|
|
store.increment(SECOND_GROUP, "u2", "Bob")
|
|
await _bot(config, store, transport, directory).publish_report()
|
|
assert [(m.recipient, m.text) for m in transport.sent] == [(SECOND_GROUP, "Bob 1")]
|
|
|
|
|
|
async def test_the_report_follows_the_live_group_set(
|
|
config: Config, store: Store, transport: FakeTransport
|
|
) -> None:
|
|
"""Groups come from the directory, not from .env, so a new one is included."""
|
|
client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)])
|
|
directory = GroupDirectory(config, client, store, InviteTrust(()))
|
|
await directory.refresh()
|
|
store.increment(WATCHED_GROUP, "u1", "Alice")
|
|
store.increment(SECOND_GROUP, "u2", "Bob")
|
|
await _bot(config, store, transport, directory).publish_report()
|
|
assert [m.recipient for m in transport.sent] == [WATCHED_GROUP, SECOND_GROUP]
|