signal-bot/signal_bot/custom_aliases.py
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
A custom alias is data the command table is built from, so it cannot be a
live switch like the ones already on the tab. Instead the management page
writes aliases_custom.json in the bot's state directory and then asks the
bot to reload, in one step: whenever the answer is anything but `reloaded`
-- a word another command has, a name no command has, a bot still
connecting -- the file is restored byte for byte and the bot's own reason is
shown next to the field. What is on disk is therefore always what the bot
serves, and a typo cannot sit in the file poisoning every later reload.

The registry appends the operator's words to the shipped list at
registration, which keeps `!pomoc` on the shipped spelling and routes the
new words through the same clash check as aliases.json. An alias for a name
no command has fails the whole build (ConfigError -> `kept`), because a
silently ignored entry is how a file drifts. The tagger treats custom
aliases as reserved words too, so a person cannot be given one.

registry.py crossed the 250-line cap and now re-exports the record and the
error types from command.py; the bot's state directory lookup, previously
duplicated in pack.py and the suggestions export, is state_dir.py.

Proven end to end: !e2ekot is unknown, the file is written and reload.sh
runs, !e2ekot posts a cat, a file naming a command that does not exist is
refused with 409 and the previous table still answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 16:20:05 +02:00

99 lines
3.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The operator's own trigger words, kept outside this repository.
``aliases.json`` ships with the code and is public. The spellings one group
actually settles on -- a nickname for a person in the album, a pet name for
a command -- are not, and some of them are names. So they live in
``aliases_custom.json`` in the state directory, keyed by the command they
extend, and the registry appends them at build time: ``!pomoc`` keeps
printing the shipped spelling, and a reload is what makes an edit live.
The file is written by the management page and read here; nothing in the
chat can add to it, because an alias is a command and a command is the
operator's to define.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Final
from signal_bot.groups import ConfigError
from signal_bot.pack_store import write_json
from signal_bot.state_dir import state_dir
if TYPE_CHECKING:
from collections.abc import Sequence
FILE_NAME: Final = "aliases_custom.json"
# Where the e2e run points the bot, so a proof can write its own file
# without touching the operator's.
ENV_VAR: Final = "CUSTOM_ALIASES_FILE"
Aliases = dict[str, tuple[str, ...]]
def default_path() -> Path:
"""The file, from the environment or the state directory."""
explicit = os.environ.get(ENV_VAR, "").strip()
return Path(explicit) if explicit else state_dir() / FILE_NAME
def normalise(triggers: Sequence[str]) -> tuple[str, ...]:
"""Trimmed, bang-prefixed, deduplicated, in the order given.
Raises:
ConfigError: A trigger has spaces in it. One word is all a message
can be matched by, so a longer one could never fire.
"""
seen: list[str] = []
for raw in triggers:
word = raw.strip()
if not word:
continue
word = word if word.startswith("!") else f"!{word}"
if len(word.split()) != 1:
msg = f"a trigger is one word, not {raw.strip()!r}"
raise ConfigError(msg)
if word not in seen:
seen.append(word)
return tuple(seen)
def load(path: Path) -> Aliases:
"""Every custom alias, by command name; an absent file is no aliases.
Strict, unlike the album's audit files: a line here becomes a trigger,
and a trigger that half-loaded would be a command nobody can find.
Raises:
ConfigError: The file cannot be read or is not the expected shape.
"""
if not path.exists():
return {}
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
msg = f"{FILE_NAME} cannot be read: {error}"
raise ConfigError(msg) from error
if not isinstance(document, dict):
msg = f"{FILE_NAME} must be an object of command name to triggers"
raise ConfigError(msg)
aliases: Aliases = {}
for name, triggers in document.items():
if not isinstance(triggers, list) or not all(isinstance(t, str) for t in triggers):
msg = f"{FILE_NAME}: {name!r} must map to a list of strings"
raise ConfigError(msg)
words = normalise(triggers)
if words:
aliases[str(name)] = words
return aliases
def store(path: Path, aliases: Aliases) -> None:
"""Write the whole table, dropping commands left with no alias."""
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, {name: list(words) for name, words in aliases.items() if words})