mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 23:03:50 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
224 lines
8.2 KiB
Python
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
|
|
)
|