signal-bot/tests/test_bot.py
Krzysztof kuhy Rudnicki 63f262bddd feat: survive an outage, and keep the counts that predate the event log
Two gaps kuhy pointed at, both about not losing messages the bot was not
awake for.

Missed *counts* already worked and now have a test saying so: an event
is keyed by Signal's own send timestamp, signal-cli keeps receiving in
json-rpc mode while the bot is down, and the replay on reconnect is
absorbed by the primary key. Messages sent during an outage come back
filed under the day they were actually sent.

Missed *reports* did not. A bot that was down at 21:37 never posted that
day and never went back for it. The last day reported is now written
down per group, so returning from an outage posts every day it owes,
oldest first, bounded at a week. A day nobody spoke on is marked settled
rather than reconsidered forever; a day whose send *failed* is
deliberately left unmarked so the next run retries it.

The pre-0.1.0 counter is carried over rather than discarded: 342
messages across four groups, 319 of them in the real group. What the
counter cannot say is when, so before assuming, four places were checked
for a real inbound timestamp -- journald (commands only), signal-cli's
account.db (19 tables, none inbound; message_send_log is the bot's own
outgoing resend buffer), Signal Desktop (unlinked, empty, and it does
not backfill on link), and signal-api/attachments (the one dated
artifact). The attachment mtimes put 12 files on 29 Aug against 79 on
30 Aug inside the counter's window, which is why the whole counter is
filed under 30 Aug rather than split.

That day is marked in group_meta, and a marked day claims no average
message length -- those messages have a count but no lengths, so
"średnio 0 znaków" would be a measurement nobody took. The mark is also
what will keep the day out of personal records: a merged 22-hour day
would otherwise hand somebody a record they never set.

render_day's options move into a DayView value object; the keyword list
had reached eight and this renderer is about to be shared by several
commands that each want a different subset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:23:53 +02:00

236 lines
8.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 dataclass
from typing import TYPE_CHECKING
import httpx
from signal_bot.bot import Bot, build_bot, now_local
from signal_bot.discovery import GroupDirectory
from signal_bot.liveness import Liveness
from signal_bot.stats.events import MessageEvent
from signal_bot.stats.nightly import LAST_REPORTED_KEY, mark_reported
from signal_bot.stats.periods import WARSAW
from signal_bot.transport_fake import FakeTransport
from signal_bot.trust import InviteTrust
from tests.conftest import (
SECOND_GROUP,
WATCHED_GROUP,
envelope,
group_entry,
listing_client,
mock_client,
)
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
# The report covers the previous full day, so the tests freeze "now" and
# write their events into the day before 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
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."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 1)
wrote(store, SECOND_GROUP, "u2", "Bob", 2)
await _bot(config, store, transport, directory).publish_report()
assert [m.recipient for m in transport.sent] == [WATCHED_GROUP, SECOND_GROUP]
assert "Alice" in transport.texts[0]
assert "Bob" in transport.texts[1]
assert "Alice" not in transport.texts[1]
async def test_the_report_covers_yesterday_not_today(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""Today's traffic belongs to tomorrow's report, not tonight's."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY)
wrote(store, WATCHED_GROUP, "u2", "Bob", 9, day="2026-09-02")
await _bot(config, store, transport, directory).publish_report()
assert "Alice" in transport.texts[0]
assert "Bob" not in transport.texts[0]
async def test_a_missed_day_is_posted_when_the_bot_returns(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""An outage over 21:37 used to lose that day's report entirely."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 2, day="2026-08-30")
wrote(store, WATCHED_GROUP, "u1", "Alice", 3, day="2026-08-31")
wrote(store, WATCHED_GROUP, "u1", "Alice", 4, day=YESTERDAY)
mark_reported(store, WATCHED_GROUP, "2026-08-29")
await _bot(config, store, transport, directory).publish_report()
assert len(transport.texts) == 3
assert "30 sierpnia" in transport.texts[0]
assert "31 sierpnia" in transport.texts[1]
assert "1 września" in transport.texts[2]
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == YESTERDAY
async def test_a_day_nobody_spoke_is_settled_not_retried_forever(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY)
mark_reported(store, WATCHED_GROUP, "2026-08-30")
await _bot(config, store, transport, directory).publish_report()
assert len(transport.texts) == 1
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == YESTERDAY
async def test_a_failed_send_leaves_its_day_unmarked(
config: Config, store: Store, directory: GroupDirectory
) -> None:
"""So the next run retries it instead of losing the day silently."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY)
mark_reported(store, WATCHED_GROUP, "2026-08-31")
transport = RefusingTransport(refuses=WATCHED_GROUP)
await _bot(config, store, transport, directory).publish_report()
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == "2026-08-31"
async def test_publishing_twice_posts_once(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""The marker is what stops a restart re-posting the same day."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 3)
bot = _bot(config, store, transport, directory)
await bot.publish_report()
await bot.publish_report()
assert len(transport.texts) == 1
async def test_nothing_is_deleted_after_publishing(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""The log is append-only; a second run renders the same day again."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 3)
bot = _bot(config, store, transport, directory)
await bot.publish_report()
first = transport.texts[0]
store.set_meta(WATCHED_GROUP, LAST_REPORTED_KEY, "2026-08-31")
await bot.publish_report()
assert transport.texts[1] == first
async def test_publish_report_falls_back_to_uuid_when_unnamed(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
wrote(store, WATCHED_GROUP, "u1", "u1", 1)
await _bot(config, store, transport, directory).publish_report()
assert "u1" in transport.texts[0]
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
@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 skip the next group."""
wrote(store, WATCHED_GROUP, "u1", "Alice", 1)
wrote(store, SECOND_GROUP, "u2", "Bob", 1)
transport = RefusingTransport(refuses=WATCHED_GROUP)
await _bot(config, store, transport, directory).publish_report()
assert [m.recipient for m in transport.sent] == [SECOND_GROUP]
assert "Bob" in transport.texts[0]
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."""
wrote(store, SECOND_GROUP, "u2", "Bob", 1)
await _bot(config, store, transport, directory).publish_report()
assert [m.recipient for m in transport.sent] == [SECOND_GROUP]
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()
wrote(store, WATCHED_GROUP, "u1", "Alice", 1)
wrote(store, SECOND_GROUP, "u2", "Bob", 1)
await _bot(config, store, transport, directory).publish_report()
assert [m.recipient for m in transport.sent] == [WATCHED_GROUP, SECOND_GROUP]