# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Which commands exist: the one function that fills a CommandRegistry. Split out of bot.py when the registry stopped being built once. ``build_bot`` still assembles the object graph; this is the part of it that reads data -- aliases.json, every album's manifests -- and so the part ``RegistryHolder`` runs again when that data changes. Everything with a lifetime longer than one table (the HTTP client, the portrait cache, the store) is passed in, so a rebuild reuses them rather than opening a second of each. """ from __future__ import annotations from typing import TYPE_CHECKING, Final from signal_bot.analytics_command import register_analytics_command from signal_bot.animals import load_aliases, register_animal_commands from signal_bot.art_command import register_art_command from signal_bot.custom_aliases import default_path as custom_aliases_path from signal_bot.custom_aliases import load as load_custom_aliases from signal_bot.decorators import cooldown from signal_bot.food_command import register_food_command from signal_bot.good_bot_command import register_good_bot_command from signal_bot.groups import ConfigError from signal_bot.help_command import register_help_command from signal_bot.media_strip import ExiftoolStripper from signal_bot.mute import UNMUTE_NAME from signal_bot.mute_command import MUTE_NAME, register_mute_commands from signal_bot.pack_add import COMMAND_NAME as ADD_NAME from signal_bot.pack_add import register_add_command from signal_bot.pack_command import register_pack_commands from signal_bot.pack_holder import PackHolder from signal_bot.pack_load import load as load_pack from signal_bot.pack_random import register_random_command from signal_bot.poem_command import register_poem_command from signal_bot.popes_command import register_pope_command from signal_bot.registry import CommandRegistry from signal_bot.stats.commands import register_stats_commands from signal_bot.suggest_command import register_suggest_command from signal_bot.traps_command import register_trap_command from signal_bot.workout_command import register_workout_command if TYPE_CHECKING: from pathlib import Path from signal_bot.config import Config from signal_bot.decorators import Decorator from signal_bot.http_client import ImageFetcher from signal_bot.portraits import PortraitCache COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} {unit}." # The commands registered below without ``decorate=rate_limit``. Named so # the switchboard can say "no cooldown" for them instead of showing the # deployment's number next to an input that would do nothing. # test_wiring_ungated walks the real table to keep this honest. UNGATED: Final = frozenset({MUTE_NAME, UNMUTE_NAME, ADD_NAME}) def _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None: """Wire the album's commands, all of them reading one holder. !random takes the same cooldown as every other photo command. !add_photo has none, and writes only to the album's approval queue: a photo posted to a group reaches the album when the management page approves it. """ aliases = load_aliases() holder = PackHolder(load_pack(directory)) register_pack_commands(registry, holder, decorate=rate_limit) register_random_command(registry, holder, tuple(aliases["random"]), decorate=rate_limit) register_add_command(registry, holder, ExiftoolStripper(), tuple(aliases["add_photo"])) def build_registry( config: Config, fetcher: ImageFetcher, portraits: PortraitCache ) -> CommandRegistry: """Read the data and register every command on a fresh table. Raises: ConfigError: An album no longer loads, the operator's alias file does not, or it names a command that does not exist. DuplicateTriggerError: Two commands live in one group claim a word. """ registry = CommandRegistry(load_custom_aliases(custom_aliases_path())) rate_limit = cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE) register_animal_commands( registry, fetcher, decorate=rate_limit, endpoints=config.image_api_urls, ) register_pope_command(registry, portraits, decorate=rate_limit) register_trap_command(registry, fetcher, config.commons_api_url, decorate=rate_limit) register_art_command(registry, fetcher, config.commons_api_url, decorate=rate_limit) register_poem_command(registry, fetcher, config.poem_text_url, decorate=rate_limit) register_stats_commands(registry, decorate=rate_limit) # Reads the *command* history rather than the message history, and # reads the registry when it runs -- so the album's commands, which # are registered below this line, are still in the near-miss set. register_analytics_command(registry, decorate=rate_limit) # Rate-limited like every other command. The cost is real and accepted: # somebody told "napisz !zaproponuj ..." who then files two ideas in a # row is asked to wait for the second. The alternative is one person # being able to fill a table nothing prunes. register_suggest_command(registry, decorate=rate_limit) register_good_bot_command(registry, decorate=rate_limit) # Two logs other programs keep, read where they lie; the spellings the # group uses for them carry a surname and come from aliases_custom.json. register_food_command(registry, decorate=rate_limit) register_workout_command(registry, decorate=rate_limit) # No cooldown: "wait before you may ask for quiet" is the wrong answer, # and the unmute is gated by trust, not by time. register_mute_commands(registry) # Absent unless this installation has an album, and scoped to that # album's own groups when it does -- so on every other machine, and in # every other group, these commands simply do not exist. for album in config.photo_pack_dirs: _register_album(registry, album, rate_limit) # Last, so !pomoc trails the list it prints; it reads the registry # when it runs, so the order is presentation only. register_help_command(registry, decorate=rate_limit) if registry.unclaimed_aliases: msg = f"aliases for commands that do not exist: {sorted(registry.unclaimed_aliases)}" raise ConfigError(msg) return registry