mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
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>
99 lines
3.5 KiB
Python
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})
|