mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
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
134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
# 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
|