fix: the evening report is one message, not one per thing it has to say
Some checks are pending
CI / check (push) Waiting to run

Tomorrow's run would have posted four times into the same group: the day
the bot owed from the catch-up, the week that closed inside it, today, and
August's summary. Four notifications in one evening is how a daily tally
becomes something people mute.

They are blocks in a single message now, separated by a rule because Signal
renders no markdown. That also makes the send atomic: the group gets all of
it or none of it, and nothing is marked either way, where before a day was
marked before its own weekly summary had even been attempted -- so a summary
that failed was simply lost.

The composition moved to reporting.py, both because bot.py hit the 250-line
cap and because it is worth having pure: "what would the group be told?" is
now answerable with no transport and no send.

The closing-time caveat reads the clock instead of REPORT_HOUR. The
scheduled run fires at the configured minute and still says exactly that; a
run started by hand at some other hour now tells the truth rather than
repeating a time nobody waited for.

The 0.1.6 entry gains a bullet rather than taking a new version: no group has
been told about 0.1.6 yet -- every one of them is still recorded at 0.1.5 --
so the release it describes has not happened from their side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 22:49:51 +02:00
parent 2230de4062
commit 6c6d6d92de
5 changed files with 233 additions and 123 deletions

View File

@ -102,6 +102,8 @@ papież nr 15 z 267, pontyfikat 199-217
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
- **jedna wiadomość, nie kilka**: dzień, tydzień, miesiąc i to, co bot
przegapił, są blokami w jednym poście, a nie osobnymi powiadomieniami
- dzień, którego bot przegapił (bo go nie było o 21:37), nadal zostanie
wysłany później — nic nie ginie

View File

@ -26,16 +26,10 @@ from signal_bot.pack_load import load as load_pack
from signal_bot.popes_command import register_pope_command
from signal_bot.portraits import PortraitCache
from signal_bot.registry import CommandRegistry
from signal_bot.stats import curiosities, periods
from signal_bot.stats.backfill import backfilled_day
from signal_bot.reporting import compose
from signal_bot.stats import periods
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 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.stats.nightly import days_to_report, mark_reported
from signal_bot.versioning import POPE
if TYPE_CHECKING:
@ -84,7 +78,7 @@ class Bot:
clock: Clock = now_local
async def publish_report(self) -> None:
"""Post each group's report for today, plus any day still owed.
"""Post each group **one** message: today, plus anything still owed.
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
@ -93,9 +87,12 @@ class Bot:
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.
One message, always. A run can have several things to say -- a day
the bot was down for, the day it is on now, and the week or month
that ended inside them -- and saying each in its own post turns one
evening into four notifications. They are blocks in one message
instead, which is also what makes the send atomic: either the group
got the lot, or nothing is marked and the next run says it all again.
Nothing is deleted afterwards. Each group is isolated on purpose: one
group refusing the report -- the bot removed from it, a transient send
@ -103,81 +100,39 @@ class Bot:
"""
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):
# Left unmarked on purpose, so the next run retries it
# rather than skipping the day forever.
break
await self._report_group(group_id, latest)
async def _report_group(self, group_id: str, latest: str) -> None:
"""Say everything one group is owed, in one message or not at all."""
days = days_to_report(self.store, group_id, latest)
if not days:
return
now = self.clock()
text = compose(
self.store,
self.directory,
group_id,
days,
today=periods.today(now),
# Off the clock rather than off REPORT_HOUR: the scheduled run
# fires at the configured minute and so says exactly that, while
# a run started by hand at some other hour tells the truth
# instead of repeating a time nobody waited for.
closed_at=now.strftime("%H:%M"),
)
if text:
try:
await self.transport.send_text(text, group_id)
except Exception:
# Nothing is marked, so the next run repeats the whole thing
# rather than losing the days that were inside this message.
logger.exception("report failed for group %s", group_id)
return
# Marked even when there was nothing to say: a day nobody spoke on is
# settled, not pending, and leaving it unmarked would make the bot
# reconsider it every night forever.
for day in days:
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."""
rows = day_rows(self.store.events, group_id, day)
if not rows:
# Settled, not pending. The bot is in every group it was ever
# added to, so posting "nobody spoke" would mean a nightly ping
# into every quiet group, forever.
logger.info("no messages in %s on %s; not posting a report", group_id, day)
return True
named = self.store.events.roster(group_id, self.directory.member_identities(group_id))
with_zeros = with_silent(rows, [(p.uuid, p.display_name) for p in named])
backfilled = backfilled_day(self.store, group_id)
facts = gather(self.store.events, group_id, day, with_zeros, exclude=backfilled)
picks = select(curiosities.for_day(facts), SLOTS)
text = render_day(
day,
with_zeros,
day_totals(with_zeros),
DayView(
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:
await self.transport.send_text(text, group_id)
except Exception:
logger.exception("daily report failed for group %s", group_id)
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.
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."""

133
signal_bot/reporting.py Normal file
View File

@ -0,0 +1,133 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Composing the evening report: what it says, but never the sending of it.
Split out of bot.py at the 250-line cap, and the seam is worth having on its
own terms -- everything here is pure, so "what would the group be told?" is a
question that can be answered without a transport, a group, or a send.
One run can have several things to say: a day the bot was down for, the day
it is on now, and the week or month that ended inside them. They are blocks
in one message rather than a post each, because four notifications in one
evening is how a daily tally becomes something people mute.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Final
from signal_bot.stats import curiosities, periods
from signal_bot.stats.backfill import backfilled_day
from signal_bot.stats.facts import gather
from signal_bot.stats.highlights import SLOTS, select
from signal_bot.stats.nightly import closing_periods
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
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
logger = logging.getLogger(__name__)
# What separates the day, the week and any caught-up day inside one report.
# A blank line and a rule, because Signal renders no markdown and two blocks
# run straight into each other without one.
BLOCK_SEPARATOR: Final = "\n\n———\n\n"
def compose(
store: Store,
directory: GroupDirectory,
group_id: str,
days: Sequence[str],
today: str,
closed_at: str | None,
) -> str:
"""The whole message for one group, or "" when there is nothing to say.
Args:
store: The open database.
directory: Used for the member roster, so people who said nothing are
still named.
group_id: The group being reported on.
days: Days to cover, oldest first.
today: The day that is still running, from the caller's clock. Passed
rather than read here: one injected clock decides what "today"
means for the whole bot, and a second reading of the real one
could disagree with it across midnight.
closed_at: "HH:MM" that still-running day was counted up to.
"""
blocks: list[str] = []
for day in days:
block = day_block(store, directory, group_id, day, closed_at if day == today else None)
if block:
blocks.append(block)
blocks.extend(closing_blocks(store, group_id, day))
return BLOCK_SEPARATOR.join(blocks)
def day_block(
store: Store,
directory: GroupDirectory,
group_id: str,
day: str,
closed_at: str | None,
) -> str | None:
"""One day as the group reads it, or None if nobody spoke that day."""
rows = day_rows(store.events, group_id, day)
if not rows:
# The bot is in every group it was ever added to, so "nobody spoke"
# would be a nightly ping into every quiet group, forever.
logger.info("no messages in %s on %s; not reporting on it", group_id, day)
return None
named = store.events.roster(group_id, directory.member_identities(group_id))
with_zeros = with_silent(rows, [(person.uuid, person.display_name) for person in named])
backfilled = backfilled_day(store, group_id)
facts = gather(store.events, group_id, day, with_zeros, exclude=backfilled)
picks = select(curiosities.for_day(facts), SLOTS)
return render_day(
day,
with_zeros,
day_totals(with_zeros),
DayView(
since=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: telling the group a
# finished day's count "stopped at 21:37" would be a lie.
closed_at=closed_at,
),
)
def closing_blocks(store: Store, group_id: str, day: str) -> list[str]:
"""The week, month or year that ended with this day, if any.
Rendered rather than posted: they join the day in one message, so a
Sunday evening is one notification and not two.
"""
blocks: list[str] = []
for period in closing_periods(day):
rows = period_rows(store.events, group_id, period)
if not rows:
continue
before = periods.previous(period)
trend = Trend(
now=total_for(store.events, group_id, period.start, period.end),
before=total_for(store.events, group_id, before.start, before.end),
)
blocks.append(
render_period(
period,
rows,
trend,
since=store.events.first_day(group_id),
podium=period.kind != "week",
)
)
return blocks

View File

@ -12,7 +12,6 @@ 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 TODAY, YESTERDAY, RefusingTransport, _bot, wrote
@ -20,6 +19,7 @@ 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
def _at(
@ -48,15 +48,20 @@ async def test_a_missed_day_is_posted_when_the_bot_returns(
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]
# One message, five blocks. Catching up on the days also catches up the
# periods that closed inside them -- the 30th was a Sunday and the 31st
# ended August -- and posting each separately would turn one evening
# into five notifications.
assert len(transport.texts) == 1
report = transport.texts[0]
# Whole headers, not bare dates: a curiosity line inside one day's block
# can name another day ("rekord życiowy (poprzedni 1, 31 sierpnia)"), so
# a substring search finds the wrong occurrence and the order looks wrong.
sunday = report.index("Niedziela, 30 sierpnia")
week = report.index("tydzień 35")
monday = report.index("Poniedziałek, 31 sierpnia")
assert sunday < week < monday < report.index("sierpień 2026")
assert "Wtorek, 1 września" in report
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == TODAY
@ -113,9 +118,9 @@ async def test_a_sunday_also_posts_the_week(
await _at(config, store, transport, directory, sunday).publish_report()
assert len(transport.texts) == 2
assert len(transport.texts) == 1
assert "Niedziela, 30 sierpnia" in transport.texts[0]
assert "tydzień 35" in transport.texts[1]
assert "tydzień 35" in transport.texts[0]
async def test_a_saturday_closes_nothing(
@ -140,23 +145,21 @@ async def test_an_ordinary_day_posts_only_the_day(
assert len(transport.texts) == 1
async def test_a_failing_weekly_summary_does_not_unmark_the_day(
async def test_a_refused_report_marks_nothing_at_all(
config: Config, store: Store, directory: GroupDirectory
) -> None:
"""The day was posted; retrying it tomorrow would repeat it."""
"""One message means one outcome: the group got all of it, or none.
The day used to be marked before its weekly summary was even attempted,
so a summary that failed was simply lost. With both inside one send there
is no half-state to reason about -- the next run says the whole thing.
"""
sunday = "2026-08-30"
wrote(store, WATCHED_GROUP, "u1", "Alice", 6, day=sunday)
mark_reported(store, WATCHED_GROUP, "2026-08-29")
transport = RefusingTransport(refuses=WATCHED_GROUP)
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()
await _at(config, store, transport, directory, sunday).publish_report()
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == sunday
assert transport.texts == []
assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == "2026-08-29"

View File

@ -31,6 +31,9 @@
.hint { color: var(--dim); font-size: 14px; }
.err { color: #f7768e; }
label.group { display: block; padding: 4px 0; }
pre.sample { background: #101214; border: 1px solid #33363d; border-radius: 8px;
padding: 12px; overflow-x: auto; }
.dim { color: #6b7280; }
</style>
</head>
<body>
@ -38,12 +41,15 @@
<div id="setup" hidden>
<h1>Kto jest w tej paczce?</h1>
<p class="hint">
Jedna osoba na linię: nazwy komend po przecinku, bez wykrzyknika.<br>
<code>franek, kranek</code><br>
Kombinacje po znaku <code>=</code>: <code>faron = michal + marek</code>
(zdjęcie z obojgiem) albo <code>ktokolwiek = michal | marek</code>
(losowo jedno z nich).
<b>Jedna osoba na linię</b> — Enter po każdej. Bez wykrzyknika.
Przecinek to <b>inna nazwa tej samej osoby</b>, nie następna osoba.
</p>
<pre class="hint sample">marek
michał
franek, kranek <span class="dim">← jedna osoba, dwie nazwy</span>
iga
faron = michał + marek <span class="dim">← zdjęcie, na którym są oboje</span>
ktokolwiek = michał | marek <span class="dim">← losowo jedno z nich</span></pre>
<textarea id="names" spellcheck="false"></textarea>
<h1>W której grupie działają?</h1>
<div id="groups" class="hint">szukam grup…</div>
@ -54,7 +60,7 @@
<div id="tagger" hidden>
<div class="bar">
<h1 id="progress"></h1>
<span class="hint">1–9 = osoba, → dalej, ← wstecz</span>
<span class="hint">1–9 (0 = dziesiąta) = osoba, → dalej, ← wstecz</span>
</div>
<div class="stage" id="stage"></div>
<div class="people" id="people"></div>
@ -81,11 +87,15 @@ function parseSetup(text) {
const byName = new Map();
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
if (raw.includes("=")) { continue; }
const triggers = raw.split(",").map((t) => "!" + t.trim().replace(/^!/, "")).filter((t) => t.length > 1);
const triggers = raw.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
if (!triggers.length) { continue; }
const k = `p${people.length + 1}`;
people.push({key: k, triggers});
byName.set(triggers[0].slice(1), k);
// Every alias, not just the first: a combination may name a person by
// whichever spelling its author remembers.
for (const trigger of triggers) { byName.set(trigger.slice(1), k); }
}
let n = 1;
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
@ -93,10 +103,15 @@ function parseSetup(text) {
const [left, right] = raw.split("=");
const every = right.includes("+");
const parts = right.split(every ? "+" : "|").map((p) => p.trim().replace(/^!/, ""));
const keys = parts.map((p) => byName.get(p)).filter(Boolean);
if (keys.length !== parts.length) { throw new Error(`nie znam kogoś z: ${right.trim()}`); }
const triggers = left.split(",").map((t) => "!" + t.trim().replace(/^!/, ""));
combos.push(every ? {key: `c${n}`, triggers, all_of: keys} : {key: `c${n}`, triggers, any_of: keys});
const unknown = parts.filter((p) => !byName.has(p));
if (unknown.length) { throw new Error(`nie znam: ${unknown.join(", ")}`); }
const keys = parts.map((p) => byName.get(p));
const triggers = left.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
combos.push(every
? {key: `c${n}`, triggers, all_of: keys}
: {key: `c${n}`, triggers, any_of: keys});
n += 1;
}
return {people, combos};
@ -137,7 +152,7 @@ function draw() {
: `<img src="/media/${at}" alt="">`;
document.getElementById("people").innerHTML = entries().map((p, i) => `
<div class="person ${item.keys.includes(p.key) ? "on" : ""}" data-key="${p.key}">
${i + 1}. ${p.triggers[0].slice(1)}
${i === 9 ? 0 : i + 1}. ${p.triggers[0].slice(1)}
</div>`).join("");
for (const node of document.querySelectorAll(".person")) {
node.onclick = () => toggle(node.dataset.key);
@ -162,8 +177,10 @@ document.addEventListener("keydown", (event) => {
if (document.getElementById("tagger").hidden) { return; }
if (event.key === "ArrowRight" || event.key === "Enter") { move(1); }
else if (event.key === "ArrowLeft") { move(-1); }
else if (/^[1-9]$/.test(event.key)) {
const person = entries()[Number(event.key) - 1];
else if (/^[0-9]$/.test(event.key)) {
// 0 is the tenth, so a pack of ten is still entirely keyboard-driven.
const index = event.key === "0" ? 9 : Number(event.key) - 1;
const person = entries()[index];
if (person) { toggle(person.key); }
}
});