From 8df2646377f72182a356bcece33beb5e3e2f060b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 30 Aug 2026 19:56:25 +0200 Subject: [PATCH] feat: weekly, monthly and year-in-review posts, keyed off the day reported A Sunday closes a week, a month's last day closes the month, and 31 December closes the year. Keyed off the day being *reported* rather than off today, which is the only reading that stays right when the bot has been offline: catching up on a week-old Sunday now also posts that week's summary, and there is a test asserting the five messages a three-day outage produces. One scheduler, a date predicate. Three background tasks firing at the same wall-clock time into the same group is three independent ways for an evening to go wrong, for no gain over an if-statement. A failed period summary is deliberately best-effort and runs after the day has been marked, so it cannot make the bot re-post the daily one tomorrow. Also: the quote frame arrived while this was being written, and it carries a trap. `quote` has its own nested `attachments` array holding the *quoted* message's media -- so a text reply to somebody's photo has attachments in the payload that belong to somebody else. Reading them would credit the replier with a photo they did not post, in a group where people reply to photos constantly, inflating one number without ever looking wrong. The parser already read the top level only and is correct. It is now pinned by a test built from the exact captured frame, and written up in DOCS-signal-envelopes.md, because "it happens to be right" and "it cannot silently stop being right" are different things. This is the same wrong-nesting-level mistake that once made the bot ignore its own stickers. test_bot.py went past the 250-line cap on the way, so the report and catch-up tests move into files named for what they cover. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom --- DOCS-signal-envelopes.md | 35 ++++++++ signal_bot/bot.py | 32 +++++++- signal_bot/stats/nightly.py | 30 ++++++- tests/test_bot.py | 159 +----------------------------------- tests/test_bot_catchup.py | 148 +++++++++++++++++++++++++++++++++ tests/test_bot_report.py | 156 +++++++++++++++++++++++++++++++++++ tests/test_models.py | 62 ++++++++++++++ tests/test_stats_nightly.py | 26 ++++++ 8 files changed, 489 insertions(+), 159 deletions(-) create mode 100644 tests/test_bot_catchup.py create mode 100644 tests/test_bot_report.py diff --git a/DOCS-signal-envelopes.md b/DOCS-signal-envelopes.md index c3f8a4d..41c1a41 100644 --- a/DOCS-signal-envelopes.md +++ b/DOCS-signal-envelopes.md @@ -74,6 +74,41 @@ has not been observed yet. `char_len` currently measures `message` only, so if it turns out to be the latter, captioned photos are recording a length of 0. Leave `LOG_LEVEL=DEBUG` on and watch for `caption: str` to settle it. +## A reply carries `quote` -- with its own attachments + +Also captured live, on 2026-08-30. This is a *text* reply to somebody else's +photo: + +``` +dataMessage: { + expiresInSeconds: int, isExpirationUpdate: bool, viewOnce: bool, + groupInfo: {groupId: str, groupName: str, revision: int, type: 'DELIVER'}, + message: str, + quote: { + attachments: [{contentType: 'image/jpeg', filename: str, + thumbnail: {caption: null, contentType: 'image/jpeg', + filename: str, height: int, id: str, size: int, + uploadTimestamp: int, width: int}} x1], + author: str, authorNumber: str, authorUuid: str, id: int, text: str}, + timestamp: int} +``` + +**`quote` has its own `attachments` array**, holding the *quoted* message's +media. There is no top-level `attachments` here at all, because the replier +sent only text. + +So anything looking for attachments must read them off the top level of the +resolved payload and nowhere else. Searching the payload for an `attachments` +key would credit the replier with a photo somebody else posted -- and in a +group where people reply to photos constantly, that inflates one number +without ever looking wrong. It is the same wrong-nesting-level mistake that +once made the bot ignore its own stickers. + +`quote.text` is the quoted message's body, and is not read for the same +reason: it is somebody else's message, already counted when they sent it. +`tests/test_models.py::test_a_quoted_photo_is_not_the_replier_s_photo` pins +this against the exact frame above. + ## Somebody changed the membership: `groupInfo.type` is `UPDATE` The only frame that names *who* added the bot to a group, which is what diff --git a/signal_bot/bot.py b/signal_bot/bot.py index 54e732c..d0fc3dd 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -28,9 +28,11 @@ from signal_bot.stats.backfill import backfilled_day from signal_bot.stats.commands import register_stats_commands from signal_bot.stats.facts import gather from signal_bot.stats.highlights import SLOTS, select -from signal_bot.stats.nightly import days_to_report, mark_reported +from signal_bot.stats.nightly import closing_periods, days_to_report, mark_reported from signal_bot.stats.queries_day import day_rows, day_totals, with_silent +from signal_bot.stats.queries_period import Trend, period_rows, total_for from signal_bot.stats.render_day import DayView, render_day +from signal_bot.stats.render_period import render_period from signal_bot.versioning import POPE, __version__ if TYPE_CHECKING: @@ -95,6 +97,7 @@ class Bot: # rather than skipping the day forever. break mark_reported(self.store, group_id, day) + await self._report_closing(group_id, day) async def _report_one(self, group_id: str, day: str) -> bool: """Post one group's report for one day. False means try again later.""" @@ -127,6 +130,33 @@ class Bot: return False return True + async def _report_closing(self, group_id: str, day: str) -> None: + """Post the week, month or year that ended with this day, if any. + + Best effort and deliberately after the day has been marked: a failed + weekly summary must not make the bot re-post the daily one tomorrow. + """ + for period in closing_periods(day): + before = periods.previous(period) + trend = Trend( + now=total_for(self.store.events, group_id, period.start, period.end), + before=total_for(self.store.events, group_id, before.start, before.end), + ) + rows = period_rows(self.store.events, group_id, period) + if not rows: + continue + text = render_period( + period, + rows, + trend, + since=self.store.events.first_day(group_id), + podium=period.kind != "week", + ) + try: + await self.transport.send_text(text, group_id) + except Exception: + logger.exception("%s summary failed for group %s", period.kind, group_id) + async def announce_release(self) -> None: """Tell every watched group about a version it has not seen yet.""" await announce_all( diff --git a/signal_bot/stats/nightly.py b/signal_bot/stats/nightly.py index 6006ffb..2147962 100644 --- a/signal_bot/stats/nightly.py +++ b/signal_bot/stats/nightly.py @@ -18,10 +18,11 @@ fortnight of back-posts. from __future__ import annotations +import datetime as dt import logging from typing import TYPE_CHECKING, Final -from signal_bot.stats.periods import shift +from signal_bot.stats.periods import Period, month_of, shift, week_of, year_of if TYPE_CHECKING: from signal_bot.store import Store @@ -61,6 +62,33 @@ def days_to_report(store: Store, group_id: str, latest: str) -> list[str]: return days[-CATCHUP_LIMIT:] +SUNDAY: Final = 6 +LAST_DAY_OF_DECEMBER: Final = "12-31" + + +def closing_periods(day: str) -> list[Period]: + """The longer periods that ended with ``day``, in the order to post them. + + Keyed off the day being *reported*, not off today. The nightly post covers + yesterday, so a week is complete exactly when yesterday was a Sunday -- + which is also the only reading that stays correct when the bot has been + offline and is catching up on a week-old day. + + No separate scheduler for these. Three background tasks firing at the same + wall-clock time into the same group is three independent ways for the + evening to go wrong, for no gain over an if-statement. + """ + date = dt.date.fromisoformat(day) + closing = [] + if date.weekday() == SUNDAY: + closing.append(week_of(day)) + if shift(day, 1)[8:] == "01": + closing.append(month_of(day)) + if day.endswith(LAST_DAY_OF_DECEMBER): + closing.append(year_of(day)) + return closing + + def mark_reported(store: Store, group_id: str, day: str) -> None: """Record that a day no longer owes this group a report. diff --git a/tests/test_bot.py b/tests/test_bot.py index 60d6858..10d2d26 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -4,31 +4,25 @@ 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.discovery import GroupDirectory from signal_bot.store import Store + from signal_bot.transport_fake import FakeTransport # The report covers the previous full day, so the tests freeze "now" and @@ -75,105 +69,6 @@ def test_build_bot_registers_the_animal_commands( 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: @@ -183,53 +78,3 @@ async def test_listen_dispatches_every_frame( "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] diff --git a/tests/test_bot_catchup.py b/tests/test_bot_catchup.py new file mode 100644 index 0000000..bd0b1f6 --- /dev/null +++ b/tests/test_bot_catchup.py @@ -0,0 +1,148 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Coming back from an outage: the days and the periods the bot still owes.""" + +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING + +import httpx + +from signal_bot.bot import build_bot +from signal_bot.liveness import Liveness +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 tests.conftest import WATCHED_GROUP, mock_client +from tests.test_bot_report import YESTERDAY, RefusingTransport, _bot, wrote + +if TYPE_CHECKING: + from signal_bot.config import Config + from signal_bot.discovery import GroupDirectory + from signal_bot.store import Store + + +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() + + # Five, not three: catching up on the days also catches up the periods + # that closed inside them. The 30th was a Sunday and the 31st ended + # August, so each is followed by its own summary. + assert len(transport.texts) == 5 + assert "30 sierpnia" in transport.texts[0] + assert "tydzień 35" in transport.texts[1] + assert "31 sierpnia" in transport.texts[2] + assert "sierpień 2026" in transport.texts[3] + assert "1 września" in transport.texts[4] + 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_a_sunday_also_posts_the_week( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """One scheduler, a date predicate -- not three tasks racing at 21:37.""" + sunday = "2026-08-30" + wrote(store, WATCHED_GROUP, "u1", "Alice", 4, day="2026-08-28") + wrote(store, WATCHED_GROUP, "u1", "Alice", 6, day=sunday) + mark_reported(store, WATCHED_GROUP, "2026-08-29") + at_monday = dt.datetime(2026, 8, 31, 21, 37, tzinfo=WARSAW) + + client = mock_client(lambda r: httpx.Response(200)) + bot = build_bot( + config, + client, + store, + transport, + directory, + Liveness(config, client), + clock=lambda: at_monday, + ) + await bot.publish_report() + + assert len(transport.texts) == 2 + assert "Niedziela, 30 sierpnia" in transport.texts[0] + assert "tydzień 35" in transport.texts[1] + + +async def test_an_ordinary_day_posts_only_the_day( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + wrote(store, WATCHED_GROUP, "u1", "Alice", 4) + await _bot(config, store, transport, directory).publish_report() + assert len(transport.texts) == 1 + + +async def test_a_failing_weekly_summary_does_not_unmark_the_day( + config: Config, store: Store, directory: GroupDirectory +) -> None: + """The day was posted; retrying it tomorrow would repeat it.""" + sunday = "2026-08-30" + wrote(store, WATCHED_GROUP, "u1", "Alice", 6, day=sunday) + mark_reported(store, WATCHED_GROUP, "2026-08-29") + + class RefuseSecond(FakeTransport): + """Lets the day through and rejects everything after it.""" + + async def send_text(self, text: str, recipient: str) -> None: + if self.sent: + msg = "no" + raise RuntimeError(msg) + await super().send_text(text, recipient) + + transport = RefuseSecond() + client = mock_client(lambda r: httpx.Response(200)) + bot = build_bot( + config, + client, + store, + transport, + directory, + Liveness(config, client), + clock=lambda: dt.datetime(2026, 8, 31, 21, 37, tzinfo=WARSAW), + ) + await bot.publish_report() + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == sunday diff --git a/tests/test_bot_report.py b/tests/test_bot_report.py new file mode 100644 index 0000000..7e55c66 --- /dev/null +++ b/tests/test_bot_report.py @@ -0,0 +1,156 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The nightly report: which day, which groups, and catching up after an outage.""" + +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 +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 +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, + 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) + + +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_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] + + +@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] diff --git a/tests/test_models.py b/tests/test_models.py index 2cef3fc..ced9233 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -99,3 +99,65 @@ def test_malformed_group_info_reads_as_direct_message(group_info: object) -> Non message = parse_envelope(raw) assert message is not None assert message.is_direct_message + + +# The exact shape of a live frame captured on 2026-08-30: a text reply to +# somebody else's photo. The photo is nested inside `quote`, not at the top +# level, and counting it would credit the replier with a photo they did not +# post -- the same wrong-nesting-level mistake that once made the bot ignore +# its own stickers. +REPLY_TO_A_PHOTO = json.dumps( + { + "envelope": { + "sourceUuid": "u1", + "sourceName": "Ala", + "sourceNumber": None, + "timestamp": 1756576440000, + "dataMessage": { + "timestamp": 1756576440000, + "message": "ha", + "viewOnce": False, + "expiresInSeconds": 0, + "isExpirationUpdate": False, + "groupInfo": { + "groupId": WATCHED_GROUP, + "groupName": "x", + "revision": 1, + "type": "DELIVER", + }, + "quote": { + "id": 1756576000000, + "author": "x", + "authorNumber": "+15555550123", + "authorUuid": "u2", + "text": "", + "attachments": [ + { + "contentType": "image/jpeg", + "filename": "photo.jpg", + "thumbnail": { + "contentType": "image/jpeg", + "caption": None, + "id": "t", + "size": 1, + "width": 1, + "height": 1, + "filename": "thumb.jpg", + "uploadTimestamp": 1756576000000, + }, + } + ], + }, + }, + } + } +) + + +def test_a_quoted_photo_is_not_the_replier_s_photo() -> None: + """`quote` carries its own attachments array. Only the top level counts.""" + message = parse_envelope(REPLY_TO_A_PHOTO) + assert message is not None + assert message.attachment_types == () + assert message.is_reply is True + assert message.text == "ha" diff --git a/tests/test_stats_nightly.py b/tests/test_stats_nightly.py index 6fc37b6..f4c71ed 100644 --- a/tests/test_stats_nightly.py +++ b/tests/test_stats_nightly.py @@ -5,9 +5,12 @@ from __future__ import annotations from typing import TYPE_CHECKING +import pytest + from signal_bot.stats.nightly import ( CATCHUP_LIMIT, LAST_REPORTED_KEY, + closing_periods, days_to_report, mark_reported, ) @@ -73,3 +76,26 @@ def test_a_month_boundary_is_crossed_by_the_date(store: Store) -> None: "2026-09-01", "2026-09-02", ] + + +@pytest.mark.parametrize( + ("day", "expected"), + [ + # A Sunday closes a week. + ("2026-09-06", ["tydzień 36"]), + # The last day of a month closes it. + ("2026-09-30", ["wrzesień 2026"]), + # 31 December closes both the month and the year. + ("2026-12-31", ["grudzień 2026", "2026"]), + # A Sunday mid-month closes the week only. + ("2026-08-30", ["tydzień 35"]), + # An ordinary Tuesday closes nothing. + ("2026-09-15", []), + # 28 February in a leap year is not a month end. + ("2028-02-28", []), + ("2028-02-29", ["luty 2028"]), + ], +) +def test_closing_periods(day: str, expected: list[str]) -> None: + """Keyed off the day being reported, so a catch-up still gets it right.""" + assert [period.label for period in closing_periods(day)] == expected