mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
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
236 lines
8.8 KiB
Python
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]
|