signal-bot/signal_bot/stats/nightly.py
Krzysztof kuhy Rudnicki 50bd390b00 release: 0.1.6 «Zephyrinus» — 21:37 reports today, not yesterday
The scheduler fired exactly on time tonight and posted nothing, because
publish_report covered the previous full day and the bot's first recorded
day was today: "no messages in group.WTI5dVptbG... on 2026-08-29". Correct by
code, a day late to everybody reading the group.

So the report covers the day it runs on. The count closes at the report
time and render_day says so, because a total that quietly omits three hours
is worse than one that admits to it. Everything that made a late day
recoverable stays: the per-group marker still prevents a double post, and
days_to_report still yields every day an outage swallowed.

closing_periods keeps taking a day rather than reading a clock, so a week
now closes on the Sunday evening it ends -- off a day that is itself still
running -- and a catch-up on a week-old Sunday still gets it right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:09:13 +02:00

101 lines
3.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Which days still owe the group a report, and posting them.
The event log already survives an outage: a message carries Signal's own send
timestamp, signal-cli keeps receiving while the bot is down, and the replay on
reconnect is absorbed by the primary key. So the *counts* come back on their
own, filed under the day they were actually sent.
The *report* did not. A bot that was down at 21:37 simply never posted that
day, and nothing ever went back for it -- the old counter merged the day into
the next one instead, which is the bug this release exists to remove. So the
last day reported is written down per group, and every day since then gets
posted when the bot comes back.
Bounded, because coming back from a fortnight offline should not open with a
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 Period, month_of, shift, week_of, year_of
if TYPE_CHECKING:
from signal_bot.store import Store
logger = logging.getLogger(__name__)
LAST_REPORTED_KEY: Final = "last_reported_day"
# At most a week of catch-up in one go. Long enough to cover a weekend
# outage nobody noticed, short enough that a month offline is not a wall.
CATCHUP_LIMIT: Final = 7
def days_to_report(store: Store, group_id: str, latest: str) -> list[str]:
"""Every day up to ``latest`` that has not been reported yet, oldest first.
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 today.
Returns:
The days to post, oldest first. A group with no marker gets only
``latest``: the marker is absent on a brand-new group and on the first
run after this release, and neither should trigger a backlog.
"""
last = store.meta(group_id, LAST_REPORTED_KEY)
if last is None:
return [latest]
if last >= latest:
return []
days = []
day = shift(last, 1)
while day <= latest:
days.append(day)
day = shift(day, 1)
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*, 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
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.
Called for a day that was posted *and* for a day nobody spoke on -- an
empty day is settled, not pending, and leaving it unmarked would make the
bot reconsider it every night forever.
"""
store.set_meta(group_id, LAST_REPORTED_KEY, day)