mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +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>
104 lines
3.7 KiB
Python
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
|