diff --git a/DOCS-changelog.md b/DOCS-changelog.md index 0b28a4b..aea93c7 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -71,6 +71,15 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about `/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can reach, and it was rightly called out. +## 0.1.6 — Zephyrinus + +- statystyki o 21:37 są od teraz o **dzisiejszym** dniu, a nie o wczorajszym — + wieczorny post pokazuje wreszcie ten dzień, który właśnie mijał +- licznik zamyka się o 21:37, więc to, co napiszecie później, wpada do jutra; + post sam to mówi, żeby liczba nie udawała całej doby +- dzień, którego bot przegapił (bo go nie było o 21:37), nadal zostanie + wysłany później — nic nie ginie + ## 0.1.5 — Victor I - ogłoszenie nowej wersji przychodzi teraz ze zdjęciem papieża, po którym ta diff --git a/pyproject.toml b/pyproject.toml index 9bc87ad..5c7d7fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "signal-bot" -version = "0.1.5" +version = "0.1.6" description = "A Signal group bot: image commands and daily message tallies." readme = "README.md" license = "MIT" diff --git a/signal_bot/bot.py b/signal_bot/bot.py index ddb6ef1..2a238f0 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -82,18 +82,24 @@ class Bot: clock: Clock = now_local async def publish_report(self) -> None: - """Post each group's report for the last full day. + """Post each group's report for today, plus any day still owed. - Yesterday, not "since the last post". The old scheme reset a counter - after publishing, so a group nobody spoke in was never reset and its - next report silently covered two days. A day is now a property of - when a message was sent, which cannot drift. + Today, because 21:37 is when the group expects to read its own + numbers; a post at 21:37 about yesterday is a day late to everyone + looking at it. The count therefore closes at the report time and the + rest of the evening lands in tomorrow's day -- render_day says so, + because a total that quietly omits three hours is worse than one that + admits to it. + + Everything that makes a late day recoverable stays: the day is marked + per group, so it cannot post twice, and days_to_report still yields + every day the bot was down for, oldest first. Nothing is deleted afterwards. Each group is isolated on purpose: one group refusing the report -- the bot removed from it, a transient send failure -- must not cost every other group its report. """ - latest = periods.previous_day(self.clock()) + latest = periods.today(self.clock()) for group_id in self.directory.groups: for day in days_to_report(self.store, group_id, latest): if not await self._report_one(group_id, day): @@ -125,6 +131,10 @@ class Bot: since=self.store.events.first_day(group_id), approximate=backfilled == day, highlights=tuple(pick.text for pick in picks), + # Only for a day that is still running. A day caught up after + # an outage is complete, and telling the group its count + # stopped at 21:37 would be a lie about a finished day. + closed_at=self._closing_time(day), ), ) try: @@ -134,6 +144,12 @@ class Bot: return False return True + def _closing_time(self, day: str) -> str | None: + """The "HH:MM" a still-open day was counted up to, or None.""" + if day != periods.today(self.clock()): + return None + return f"{self.config.report_hour:02d}:{self.config.report_minute:02d}" + async def _report_closing(self, group_id: str, day: str) -> None: """Post the week, month or year that ended with this day, if any. diff --git a/signal_bot/stats/nightly.py b/signal_bot/stats/nightly.py index 2147962..4c88469 100644 --- a/signal_bot/stats/nightly.py +++ b/signal_bot/stats/nightly.py @@ -41,7 +41,7 @@ def days_to_report(store: Store, group_id: str, latest: str) -> list[str]: Args: store: The open database, which holds the per-group marker. group_id: The group being reported on. - latest: The most recent day eligible for a report, normally yesterday. + latest: The most recent day eligible for a report, normally today. Returns: The days to post, oldest first. A group with no marker gets only @@ -69,10 +69,11 @@ 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. + Keyed off the day being *reported*, never off the calendar. The nightly + post covers the day it runs on, so a week is complete exactly when that + day is a Sunday -- and the same reading stays correct when the bot has + been offline and is catching up on a week-old day, which is why the + predicate takes the day rather than reading a clock. 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 diff --git a/signal_bot/stats/render_day.py b/signal_bot/stats/render_day.py index 27becaa..8cecc3e 100644 --- a/signal_bot/stats/render_day.py +++ b/signal_bot/stats/render_day.py @@ -87,6 +87,10 @@ class DayView: heading: str | None = None # The day's messages have counts but no lengths -- see stats.backfill. approximate: bool = False + # "21:37" when the day is still running and the count stopped there, so + # the block never presents a partial day as a whole one. None for a + # finished day, which needs no caveat. + closed_at: str | None = None def render_day( @@ -110,6 +114,8 @@ def render_day( if line ] picks = list(view.highlights) + if view.closed_at: + tail.append(f"(licznik zamknięty o {view.closed_at}; reszta wieczoru wpada do jutra)") if view.since: tail.append(f"(liczymy od {pl_day_month(view.since)})") diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index 07302dd..4244498 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -30,7 +30,7 @@ PATCHES_PER_MINOR: Final = 8 # Which release this build is. Bump by one per release; everything else, # including the version string in pyproject.toml, follows from it. -RELEASE_ORDINAL: Final = 14 +RELEASE_ORDINAL: Final = 15 _POPES_FILE: Final = Path(__file__).with_name("popes.json") diff --git a/tests/test_bot_catchup.py b/tests/test_bot_catchup.py index bd0b1f6..f92929d 100644 --- a/tests/test_bot_catchup.py +++ b/tests/test_bot_catchup.py @@ -8,13 +8,13 @@ from typing import TYPE_CHECKING import httpx -from signal_bot.bot import build_bot +from signal_bot.bot import Bot, 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 +from tests.test_bot_report import TODAY, YESTERDAY, RefusingTransport, _bot, wrote if TYPE_CHECKING: from signal_bot.config import Config @@ -22,6 +22,21 @@ if TYPE_CHECKING: from signal_bot.store import Store +def _at( + config: Config, + store: Store, + transport: FakeTransport, + directory: GroupDirectory, + day: str, +) -> Bot: + """A bot whose clock reads 21:37 on ``day``, which is the day it reports.""" + at = dt.datetime.fromisoformat(day).replace(hour=21, minute=37, tzinfo=WARSAW) + client = mock_client(lambda r: httpx.Response(200)) + return build_bot( + config, client, store, transport, directory, Liveness(config, client), clock=lambda: at + ) + + async def test_a_missed_day_is_posted_when_the_bot_returns( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: @@ -42,7 +57,7 @@ async def test_a_missed_day_is_posted_when_the_bot_returns( 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 + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == TODAY async def test_a_day_nobody_spoke_is_settled_not_retried_forever( @@ -54,7 +69,7 @@ async def test_a_day_nobody_spoke_is_settled_not_retried_forever( await _bot(config, store, transport, directory).publish_report() assert len(transport.texts) == 1 - assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == YESTERDAY + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == TODAY async def test_a_failed_send_leaves_its_day_unmarked( @@ -84,30 +99,39 @@ async def test_publishing_twice_posts_once( 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.""" + """One scheduler, a date predicate -- not three tasks racing at 21:37. + + The week now closes on the Sunday evening it ends, off a day that is + itself still running. That is the point of the predicate taking a day + instead of reading a clock: partial or not, Sunday is the last day of + the week and the summary belongs to it. + """ 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() + await _at(config, store, transport, directory, sunday).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_a_saturday_closes_nothing( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """The day before the week ends is still just a day.""" + saturday = "2026-08-29" + wrote(store, WATCHED_GROUP, "u1", "Alice", 4, day=saturday) + mark_reported(store, WATCHED_GROUP, "2026-08-28") + + await _at(config, store, transport, directory, saturday).publish_report() + + assert len(transport.texts) == 1 + assert "Sobota, 29 sierpnia" in transport.texts[0] + + async def test_an_ordinary_day_posts_only_the_day( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: @@ -134,15 +158,5 @@ async def test_a_failing_weekly_summary_does_not_unmark_the_day( 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() + await _at(config, store, transport, directory, sunday).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 index 7e55c66..abcee94 100644 --- a/tests/test_bot_report.py +++ b/tests/test_bot_report.py @@ -29,9 +29,11 @@ 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. +# The report covers the day it runs on, so the tests freeze "now" and write +# their events into that same day. YESTERDAY is what the catch-up tests use +# for a day the bot still owes. NOW = dt.datetime(2026, 9, 2, 21, 37, tzinfo=WARSAW) +TODAY = "2026-09-02" YESTERDAY = "2026-09-01" @@ -43,7 +45,7 @@ def _bot(config: Config, store: Store, transport: FakeTransport, directory: Grou def wrote( - store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = YESTERDAY + store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = TODAY ) -> None: """Put ``times`` messages from one person into one group on one day.""" for i in range(times): @@ -74,17 +76,36 @@ async def test_publish_report_posts_each_group_its_own_tally( assert "Alice" not in transport.texts[1] -async def test_the_report_covers_yesterday_not_today( +async def test_the_report_covers_today_not_yesterday( 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") + """21:37 posts the day the group is living through, not the one before.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=TODAY) + wrote(store, WATCHED_GROUP, "u2", "Bob", 9, day=YESTERDAY) 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_still_open_day_says_when_its_count_stopped( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """A partial day must not be presented as a whole one.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1) + await _bot(config, store, transport, directory).publish_report() + assert "licznik zamknięty o 21:37" in transport.texts[0] + + +async def test_a_caught_up_day_carries_no_closing_caveat( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """That day is over; its count is complete and needs no excuse.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY) + store.set_meta(WATCHED_GROUP, LAST_REPORTED_KEY, "2026-08-31") + await _bot(config, store, transport, directory).publish_report() + assert "licznik zamknięty" not in transport.texts[0] + + async def test_nothing_is_deleted_after_publishing( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: @@ -93,7 +114,7 @@ async def test_nothing_is_deleted_after_publishing( 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") + store.set_meta(WATCHED_GROUP, LAST_REPORTED_KEY, YESTERDAY) await bot.publish_report() assert transport.texts[1] == first diff --git a/tests/test_stats_nightly.py b/tests/test_stats_nightly.py index f4c71ed..69e8e1d 100644 --- a/tests/test_stats_nightly.py +++ b/tests/test_stats_nightly.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: GROUP = "group-a==" -def test_a_group_with_no_marker_gets_only_yesterday(store: Store) -> None: +def test_a_group_with_no_marker_gets_only_the_latest_day(store: Store) -> None: """A new group, and the first run after this release, must not backlog.""" assert days_to_report(store, GROUP, "2026-09-01") == ["2026-09-01"] @@ -57,7 +57,7 @@ def test_catch_up_is_bounded(store: Store) -> None: assert days[0] == "2026-08-26" -def test_a_marker_ahead_of_yesterday_owes_nothing(store: Store) -> None: +def test_a_marker_ahead_of_the_latest_day_owes_nothing(store: Store) -> None: """Defends the clock going backwards, e.g. an NTP correction.""" mark_reported(store, GROUP, "2026-09-05") assert days_to_report(store, GROUP, "2026-09-01") == []