signal-bot/signal_bot/wiring.py
Krzysztof kuhy Rudnicki 77795ee6f8
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Today's food and workout logs, read from where diet_guard and screen_locker keep them
Two commands, food_today and workout_today, each printing the current
Warsaw day out of a log another program owns: every meal with its time,
calories and source plus the day's total and macros; every workout with
every field it carries. Three workout fields are withheld (a reservation
phone number, a screenshot path, the sync record id), and the food budget
is never printed -- diet_guard keeps that number on the machine on purpose.

The spellings the group uses carry a surname, so they are custom aliases in
the state directory; aliases.json ships only plain words. The suite points
both logs at absent temp files so it can never read the operator's own,
and the e2e run writes one invented entry of each under today's date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:07:46 +02:00

123 lines
6.0 KiB
Python

# 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.mute import UNMUTE_NAME
from signal_bot.mute_command import MUTE_NAME, register_mute_commands
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})
def _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None:
"""Wire the album's commands, all of them reading one mutable holder.
!random takes the same cooldown as every other photo command. !add_photo
is not wired: a photo posted to a group must not reach the album until
somebody has approved it, and no approval queue exists yet.
"""
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)
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