mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 13:37:32 +01:00
Anybody may ask; ten quiet minutes is the whole downside, so there is no trust gate on the way in and no cooldown. The confirmation is the last thing said: below it the dispatcher drops every reply in that group -- commands, the near-miss suggestion, and the nightly report, which is held back (never marked) rather than lost. Messages are still counted. !odcisz is the one command that runs through a mute, and only for TRUSTED_INVITERS; a stranger is refused without a reply, because a refusal is a reply. Unset, anybody may -- the same reading InviteTrust gives an unset variable, now shared as trust.is_trusted. There is deliberately no !unmute alias: it is one edit from !mute, and tests/test_nearest.py forbids two commands within reach of each other. The state is a deadline in group_meta, so it survives a restart and a reload and is cleared with the group. tests/test_help_command.py hit the 250-line cap and its album half moved to tests/test_help_album.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
6.1 KiB
Python
158 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",)),
|
|
("Cisza", ("mute", "unmute")),
|
|
(
|
|
"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)
|