mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short public-domain Polish poem, whole. Both follow the rule the rest of the bot already keeps: the repository commits identifiers, and the words arrive over the wire when somebody asks. !wiersz was written the other way first, and the gate caught it. A pack of short public-domain poems was harvested, name-filtered and committed, and check_personal_data.sh refused it: three lines carried a real group member's given name, and two still did after a filter that had discarded 31% of the corpus. That is structural, not a tuning problem -- Polish verse capitalises every line, so a line-initial capital carries no signal, and that is exactly where a name sits. The pack now holds Wolne Lektury slugs; the poem, its title and its author are fetched at post time and appear nowhere in git. It is also the better version: with no name filter there are no false positives, so the pool is larger and the attribution is a full name again. The gate then refused the art category list too, over a painter whose given name is somebody's. That is the popes.json case exactly, so it takes the same answer: a file-scoped allowlist entry, still refused everywhere else. The Commons harvest is now three modules by what they are, not by where the line count fell: commons_api is the conversation with Commons, commons_harvest turns categories into a pool of licensed page ids, commons_cache makes an interrupted run resumable. Along the way it gained licences in the same request that lists a category (the second pass was 1,500 requests at 2.3s each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to destroy a whole run, and Wikidata for works by creators of Polish citizenship: the Polish pool grew 15,221 -> 20,092. Batching categories through CirrusSearch looked like a further 6x and was measured out again: 2,404 files where the authoritative enumeration returns 2,429, a different ~1% missing on every run. The offline run is 19s where it was four minutes, almost all of it the deafness gate waiting out three thirty-second liveness probes to prove something provable in three; the tick is configuration now, and e2e enforces a 40s budget on itself so that never silently comes back.
156 lines
6.1 KiB
Python
156 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",
|
|
),
|
|
),
|
|
)
|
|
|
|
# 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)
|