signal-bot/signal_bot/manage/routes_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

104 lines
3.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The operator's aliases: one file, written here, proven by the bot.
An alias is data the command table is built from, so unlike a switch it
goes through ``POST /_reload``. The write and the reload are one step on
purpose: the file is put back exactly as it was whenever the bot does not
come back ``reloaded``, so what is on disk is always what the bot serves.
Otherwise a typo'd name would sit in the file, refused at every later
reload of anything else, until somebody worked out which edit did it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from signal_bot.custom_aliases import load, normalise, store
from signal_bot.groups import ConfigError
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.manage.deps import ManageDeps
class AliasRequest(BaseModel):
"""Every custom trigger one command should have; empty removes them all."""
triggers: list[str]
def _restore(path: Path, previous: bytes | None) -> None:
"""Put the file back as it was before the write, byte for byte."""
if previous is None:
path.unlink(missing_ok=True)
else:
path.write_bytes(previous)
def router(deps: ManageDeps) -> APIRouter:
"""The table and the per-command PUT.
Args:
deps: The running app's configuration and shared HTTP client.
Returns:
A router to include in the management app.
"""
api = APIRouter(prefix="/api")
def current() -> dict[str, tuple[str, ...]]:
"""The file as it stands, or the reason it cannot be read (409)."""
try:
return load(deps.aliases_path)
except ConfigError as error:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
async def reload() -> dict[str, Any]:
"""Ask the bot to rebuild its table; anything but ``reloaded`` raises."""
try:
response = await deps.client.post(f"{deps.bot_url}/_reload", timeout=30.0)
except httpx.HTTPError as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the bot did not answer: {error}",
) from error
body: dict[str, Any] = response.json()
if response.status_code != status.HTTP_200_OK:
# ``kept`` (409) carries the bot's own sentence about the data,
# ``connecting`` (503) why there is no table yet. Both are the
# bot's words, passed on.
raise HTTPException(status_code=response.status_code, detail=body.get("reason", body))
return body
@api.get("/aliases")
async def list_aliases() -> dict[str, dict[str, list[str]]]:
"""Every custom alias, by command name."""
return {"aliases": {name: list(words) for name, words in current().items()}}
@api.put("/aliases/{name}")
async def put_aliases(name: str, request: AliasRequest) -> dict[str, Any]:
"""Set one command's custom triggers and make them live, or change nothing."""
try:
words = normalise(request.triggers)
except ConfigError as error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)
) from error
aliases = current()
path = deps.aliases_path
previous = path.read_bytes() if path.exists() else None
store(path, {**aliases, name: words})
try:
answer = await reload()
except HTTPException:
_restore(path, previous)
raise
return {"name": name, "triggers": list(words), **answer}
return api