signal-bot/signal_bot/stats/commands.py
Krzysztof kuhy Rudnicki 47de47eb61
Some checks are pending
CI / check (push) Waiting to run
release: 0.1.4 «Eleuteriusz» — the list of commands lives in the chat now
Thirteen commands, and the only list of them was a README table nobody in a
Signal group can read — which had itself gone stale two releases earlier,
still advertising four commands after nine more had shipped. 0.1.1 gave each
command an output that says what the command is; that only helps once you
already know to type it.

!pomoc (also !pomocy, !help, !komendy, !commands) prints all fourteen,
grouped, one Polish line each.

The interesting part is that it cannot go stale. CommandRegistry.register now
takes the description as a REQUIRED argument, so a command that does not
explain itself does not compile; and render_help prints any command that
help_command.GROUPS forgot to file, under no heading, rather than dropping
it. Two independent ways to fail closed, because the README proved that a
hand-maintained second list is a list that drifts.

Command also gained `primary`, the first raw alias. The trigger set could not
supply it: those are folded skeletons in an unordered frozenset, so all that
survives of "!papież" there is "!papiez", and the styled Unicode aliases come
out as zalgo. aliases.json already lists the canonical Polish spelling first.

The full trigger table moves to DOCS-commands.md. It did not fit in README —
that file was already sitting exactly on the shared 250-line cap, and the
gate caught the overflow on the first commit attempt.

Exercised in testbot at the unbumped version before this bump: the live
dispatcher ran it and posted the list, journal clean. pytest 708 green at
100% branch coverage, e2e green including a new scenario that asserts !pomoc
mentions a command registered by a different module.

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

224 lines
8.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The `!` commands that read the statistics.
None of them takes an argument. ``registry.resolve`` matches the whole folded
message body against a trigger, so `!ja mithra` resolves to nothing; teaching
it to split on whitespace would silently make `!kot pls` fire where today it
does nothing, which is a behaviour change with its own blast radius. Each
question gets its own trigger instead.
`!wczoraj` renders through exactly the same path as the nightly post. That is
not a convenience: the bot cannot trigger its own 21:37 report on demand, so
without a command that produces byte-identical text there would be no way to
check the headline output of this release short of waiting for the evening.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.animals import load_aliases
from signal_bot.stats import curiosities, periods, records, streaks
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.queries_day import day_rows, day_totals, with_silent
from signal_bot.stats.queries_period import (
Trend,
every_day,
period_rows,
total_for,
weekday_buckets,
)
from signal_bot.stats.render_day import DayView, render_day
from signal_bot.stats.render_extra import (
ME_TITLE,
favourite_hour,
render_crown,
render_me,
render_records,
render_weekdays,
)
from signal_bot.stats.render_period import render_period
if TYPE_CHECKING:
from signal_bot.decorators import Decorator
from signal_bot.registry import CommandContext, CommandRegistry, Handler
from signal_bot.stats.periods import Period
def _day_block(ctx: CommandContext, day: str, heading: str | None = None) -> str:
"""Render one day exactly as the nightly report does."""
store = ctx.store
rows = with_silent(
day_rows(store.events, ctx.group_id, day),
[(p.uuid, p.display_name) for p in store.events.people(ctx.group_id)],
)
if not any(not row.is_silent for row in rows):
return "Nikt jeszcze nic nie napisał tego dnia."
backfilled = backfilled_day(store, ctx.group_id)
facts = gather(store.events, ctx.group_id, day, rows, exclude=backfilled)
picks = select(curiosities.for_day(facts), SLOTS)
return render_day(
day,
rows,
day_totals(rows),
DayView(
since=store.events.first_day(ctx.group_id),
approximate=backfilled == day,
highlights=tuple(pick.text for pick in picks),
heading=heading,
),
)
def _period_block(ctx: CommandContext, period: Period, *, podium: bool = False) -> str:
"""Render one period against the one before it."""
store = ctx.store
before = periods.previous(period)
trend = Trend(
now=total_for(store.events, ctx.group_id, period.start, period.end),
before=total_for(store.events, ctx.group_id, before.start, before.end),
)
return render_period(
period,
period_rows(store.events, ctx.group_id, period),
trend,
since=store.events.first_day(ctx.group_id),
podium=podium,
)
async def today(ctx: CommandContext) -> None:
"""`!staty` -- the day so far."""
await ctx.reply(_day_block(ctx, periods.today(), heading="Dziś, na razie:"))
async def yesterday(ctx: CommandContext) -> None:
"""`!wczoraj` -- byte-identical to what the nightly post said."""
await ctx.reply(_day_block(ctx, periods.previous_day()))
async def week(ctx: CommandContext) -> None:
"""`!tydzień` -- this week against last week."""
await ctx.reply(_period_block(ctx, periods.week_of(periods.today())))
async def month(ctx: CommandContext) -> None:
"""`!miesiąc` -- this month, with a podium."""
await ctx.reply(_period_block(ctx, periods.month_of(periods.today()), podium=True))
async def year(ctx: CommandContext) -> None:
"""`!rok` -- this year against last year."""
await ctx.reply(_period_block(ctx, periods.year_of(periods.today())))
async def all_records(ctx: CommandContext) -> None:
"""`!rekordy` -- every all-time record the group has set."""
store = ctx.store
exclude = backfilled_day(store, ctx.group_id)
history = every_day(store.events, ctx.group_id, exclude=exclude)
personal = []
for person in store.events.people(ctx.group_id):
best = records.personal_best(store.events, ctx.group_id, person.uuid, exclude=exclude)
if best is not None:
personal.append((person.display_name, best))
personal.sort(key=lambda pair: (-pair[1].value, pair[0]))
await ctx.reply(
render_records(
history,
records.most_media_day(store.events, ctx.group_id, exclude=exclude),
records.longest_silence(history, periods.today()) if history else None,
personal,
store.events.first_day(ctx.group_id),
)
)
async def crown(ctx: CommandContext) -> None:
"""`!korona` -- who holds it, who held it longest, who has held it most."""
store = ctx.store
winners = streaks.daily_winners(
store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id)
)
await ctx.reply(
render_crown(
winners,
store.events.people(ctx.group_id),
store.events.first_day(ctx.group_id),
)
)
async def weekdays(ctx: CommandContext) -> None:
"""`!dni` -- the Monday-to-Sunday picture."""
store = ctx.store
history = every_day(store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id))
await ctx.reply(
render_weekdays(
weekday_buckets(history),
periods.weekday_of(periods.today()),
store.events.first_day(ctx.group_id),
)
)
async def me(ctx: CommandContext) -> None:
"""`!ja` -- the asker's own numbers."""
store = ctx.store
uuid = ctx.message.source_uuid
exclude = backfilled_day(store, ctx.group_id)
everyone = period_rows(
store.events,
ctx.group_id,
periods.Period("all", "0000-01-01", "9999-12-31", "wszystko"),
)
mine = next((row for row in everyone if row.uuid == uuid), None)
if mine is None:
await ctx.reply(f"{ME_TITLE}\n\nNie mam jeszcze od ciebie ani jednej wiadomości.")
return
total = sum(row.messages for row in everyone)
winners = streaks.daily_winners(store.events, ctx.group_id, exclude=exclude)
running = [s for s in streaks.current_streaks(winners) if s.uuid == uuid]
await ctx.reply(
render_me(
mine.display_name,
mine.messages,
records.personal_best(store.events, ctx.group_id, uuid, exclude=exclude),
round(mine.messages / total * 100) if total else 0,
favourite_hour(store.events, ctx.group_id, uuid),
running[0].length if running else 0,
)
)
# Handler plus the one line !pomoc prints for it, in the group's language.
# Paired here rather than in the help module so that adding a command and
# explaining it are the same edit.
HANDLERS: dict[str, tuple[Handler, str]] = {
"stats_today": (today, "ile kto dziś napisał, na razie"),
"stats_yesterday": (yesterday, "wczorajszy raport, ten sam, który wpada o 21:37"),
"stats_week": (week, "ten tydzień na tle poprzedniego"),
"stats_month": (month, "ten miesiąc, z podium"),
"stats_year": (year, "ten rok na tle poprzedniego"),
"stats_records": (all_records, "rekordy grupy: najgłośniejszy dzień, najdłuższa cisza"),
"stats_crown": (crown, "kto danego dnia pisze najwięcej i kto trzymał koronę najdłużej"),
"stats_weekdays": (weekdays, "od poniedziałku do niedzieli, który dzień jest najgadatliwszy"),
"stats_me": (me, "twoje własne liczby w tej grupie"),
}
def register_stats_commands(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
) -> None:
"""Register every statistics command under its triggers from aliases.json."""
table = load_aliases() if aliases is None else aliases
for name, (handler, description) in HANDLERS.items():
registry.register(
name, table[name], decorate(handler) if decorate else handler, description
)