mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +01:00
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
157 lines
6.1 KiB
Python
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)
|