signal-bot/signal_bot/help_command.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

157 lines
6.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !pomoc command: every command the bot has, and what each one does.
Nothing in the chat used to say the commands existed. The README's table was
the only list, it was stale within two releases, and nobody in a Signal group
reads a README anyway -- so the list is built from the registry itself. A
command cannot be registered without a description (``CommandRegistry.register``
requires one), and any command this module's ``GROUPS`` does not mention is
printed anyway, under no heading. Between those two, a new command cannot
quietly fail to appear here.
The trigger printed is ``Command.primary``, the first *raw* alias, because
``Command.triggers`` holds folded skeletons in an unordered set: "!papież"
survives there only as "!papiez", and the styled Unicode aliases would come
out as the zalgo they are.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
from signal_bot.pack_command import NAME_PREFIX
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.registry import Command, CommandRegistry
COMMAND_NAME: Final = "help"
DESCRIPTION: Final = "ta lista"
HEADING: Final = "Komendy"
# Which commands sit under which heading, in the order they are printed.
# Anything missing from here still gets printed, in a block of its own -- see
# the module docstring.
GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
("Obrazki", ("cat", "dog", "gecko", "trap", "art")),
("Papież", ("pope",)),
("Wiersze", ("poem",)),
("Propozycje", ("suggest",)),
(
"Statystyki",
(
"stats_today",
"stats_yesterday",
"stats_week",
"stats_month",
"stats_year",
"stats_records",
"stats_crown",
"stats_weekdays",
"stats_me",
"command_stats",
),
),
)
# Only the canonical spelling is listed above, but half the group types the
# English one. Without this line "!cat" looks broken to somebody holding a
# list that says "!kot".
SPELLINGS: Final = "Każda komenda ma też inne pisownie, np. !cat, !stats, !week."
# The private album, when a group has one. Its heading is here and its
# commands are not: they come from a file this repository never sees, so
# GROUPS could not name them even if it wanted to -- and naming an optional
# command there would make !pomoc fail in every group that has no pack.
PACK_HEADING: Final = "Zdjęcia"
PACK_HINT: Final = "Kilka naraz ({first} {second}) = zdjęcie, na którym są wszyscy."
# The two commands that act on the album rather than posting one person from
# it. Their own block, so the "kilka naraz" hint above keeps describing the
# people entries it was written for. Spelled from NAME_PREFIX rather than
# imported, so !pomoc does not pull the ingest path in just to print a line.
ALBUM_HEADING: Final = "Album"
ALBUM_NAMES: Final = (f"{NAME_PREFIX}add", f"{NAME_PREFIX}random")
def _line(command: Command) -> str:
"""One command as !pomoc prints it."""
return f"{command.primary} — {command.description}"
def _pack_block(commands: Sequence[Command]) -> str:
"""The album's own block, or "" for a group that has no album.
Built from whatever is registered rather than from a table, because the
entries are private data: this file cannot know how many there are or
what they are called, and must not need to.
"""
entries = [c for c in commands if c.name.startswith(NAME_PREFIX) and c.name not in ALBUM_NAMES]
if not entries:
return ""
lines = [PACK_HEADING, *(_line(command) for command in entries)]
if len(entries) > 1:
lines.append(PACK_HINT.format(first=entries[0].primary, second=entries[1].primary))
return "\n".join(lines)
def _album_block(commands: Sequence[Command]) -> str:
"""The !add_photo / !random block, or "" where there is no album."""
entries = [c for c in commands if c.name in ALBUM_NAMES]
if not entries:
return ""
return "\n".join([ALBUM_HEADING, *(_line(command) for command in entries)])
def render_help(commands: Sequence[Command], group_id: str) -> str:
"""Render the command list for one group, grouped, in registration order.
Per group, because a command can be scoped to one: the private album
exists in its own groups and nowhere else, and a help text that listed it
everywhere would announce it to every group the bot is in.
"""
visible = [command for command in commands if command.allowed_in(group_id)]
by_name = {command.name: command for command in visible}
# Indexed, not .get(): a name in GROUPS that no command answers to is a
# typo in this file, and it should fail loudly the first time the command
# is used rather than silently drop a line.
grouped = {name for _, names in GROUPS for name in names}
blocks = [HEADING]
for title, names in GROUPS:
blocks.append("\n".join([title, *(_line(by_name[name]) for name in names)]))
blocks.extend(block for block in (_pack_block(visible), _album_block(visible)) if block)
blocks.append(
"\n".join(
_line(c)
for c in visible
if c.name not in grouped and not c.name.startswith(NAME_PREFIX)
)
)
blocks.append(SPELLINGS)
return "\n\n".join(blocks)
def register_help_command(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
) -> None:
"""Register !pomoc, which reads the registry it is registered on.
The registry is read when the command *runs*, not when it is registered,
so this may be wired up in any order and still list everything.
"""
table = load_aliases() if aliases is None else aliases
async def handler(ctx: CommandContext) -> None:
await ctx.reply(render_help(registry.commands, ctx.group_id))
wrapped = decorate(handler) if decorate is not None else handler
registry.register(COMMAND_NAME, table[COMMAND_NAME], wrapped, DESCRIPTION)