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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 19:56:25 +02:00
parent bfc2bcb3b8
commit 8df2646377
8 changed files with 489 additions and 159 deletions

View File

@ -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

View File

@ -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(

View File

@ -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.

View File

@ -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]

148
tests/test_bot_catchup.py Normal file
View File

@ -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

156
tests/test_bot_report.py Normal file
View File

@ -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]

View File

@ -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"

View File

@ -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