mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +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>
125 lines
4.5 KiB
Python
125 lines
4.5 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""A whole management app, pointed entirely at a temp directory.
|
|
|
|
Every path the app touches is a field on ManageDeps, so nothing here can
|
|
reach the real .env or the real album. The bot at the other end is an
|
|
httpx.MockTransport, so no test needs a container, a network or a phone.
|
|
|
|
Deliberately a builder rather than a fixture: the tests want apps whose bot
|
|
is up, down, or answering nonsense, and saying which belongs in the test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
|
|
from signal_bot.config import Config
|
|
from signal_bot.manage.app import build_app
|
|
from signal_bot.manage.deps import ManageDeps
|
|
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
|
|
from signal_bot.pack_album import Album
|
|
from tests.conftest import WATCHED_GROUP
|
|
from tests.pack_fixture import PIXEL
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
PORT = 8140
|
|
# The guard refuses anything that is not loopback, so every client in the
|
|
# suite has to say so -- which is also the only way the refusal gets tested.
|
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
|
|
|
# Invented, and short on purpose: the personal-data gate blocks anything
|
|
# matching group.<16+ base64 chars>, so a realistic-looking fixture id would
|
|
# make this repository refuse every future commit.
|
|
FIXTURE_GROUP = "group.FIXTURE-ALPHA"
|
|
|
|
ENV_TEXT = """\
|
|
# The account, in +E.164 form.
|
|
PHONE_NUMBER=+15550000000
|
|
|
|
# Empty means every group this account is in.
|
|
GROUP_IDS=
|
|
|
|
COOLDOWN_SECONDS=10
|
|
"""
|
|
|
|
HEALTHY = {"status": "ok", "version": "0.0.0", "pope": "Petrus", "papacy": "nr 1"}
|
|
|
|
|
|
def album_at(root: Path) -> Album:
|
|
"""A two-item album with one person, on disk under ``root``."""
|
|
media = root / MEDIA_DIR
|
|
media.mkdir(parents=True)
|
|
(media / "one.png").write_bytes(PIXEL)
|
|
(media / "two.png").write_bytes(PIXEL)
|
|
(root / PEOPLE_FILE).write_text(
|
|
json.dumps({"groups": [WATCHED_GROUP], "people": [{"key": "p1", "triggers": ["!alfa"]}]}),
|
|
encoding="utf-8",
|
|
)
|
|
(root / TAGS_FILE).write_text(json.dumps({"one.png": ["p1"]}), encoding="utf-8")
|
|
return Album(root)
|
|
|
|
|
|
def bot_is_up(request: httpx.Request) -> httpx.Response:
|
|
"""A container and a bot that both answer normally."""
|
|
if request.url.path == "/healthz":
|
|
return httpx.Response(200, json=HEALTHY)
|
|
if request.url.path.endswith("/_selftest"):
|
|
return httpx.Response(200, json={"status": "delivered", "group": FIXTURE_GROUP})
|
|
return httpx.Response(200, json=[{"id": FIXTURE_GROUP, "name": "alfa"}, {"name": "no id here"}])
|
|
|
|
|
|
def bot_is_down(_request: httpx.Request) -> httpx.Response:
|
|
"""Nothing at the other end at all."""
|
|
msg = "connection refused"
|
|
raise httpx.ConnectError(msg)
|
|
|
|
|
|
def answers_junk(_request: httpx.Request) -> httpx.Response:
|
|
"""Something is listening, but it is not speaking JSON."""
|
|
return httpx.Response(200, text="<html>not json</html>")
|
|
|
|
|
|
def deps_at(
|
|
tmp_path: Path,
|
|
handler: Callable[[httpx.Request], httpx.Response] = bot_is_up,
|
|
*,
|
|
built: bool = False,
|
|
) -> ManageDeps:
|
|
"""Everything a management app needs, all of it under ``tmp_path``."""
|
|
env_path = tmp_path / ".env"
|
|
env_path.write_text(ENV_TEXT, encoding="utf-8")
|
|
env_path.chmod(0o600)
|
|
dist = tmp_path / "dist"
|
|
if built:
|
|
(dist / "assets").mkdir(parents=True)
|
|
(dist / "assets" / "app.js").write_text("// built", encoding="utf-8")
|
|
(dist / "index.html").write_text("<title>built</title>", encoding="utf-8")
|
|
return ManageDeps(
|
|
config=Config(phone_number="+15550000000"),
|
|
env_path=env_path,
|
|
dist_dir=dist,
|
|
album=album_at(tmp_path / "pack"),
|
|
# Both under tmp_path, and neither has a default. Config's own
|
|
# database_path is the *relative* "signal_bot.sqlite3" and pytest
|
|
# runs from the repo root, so a defaulted field here would point
|
|
# the suite at the operator's real database -- which Store would
|
|
# then open and, on a version mismatch, partly drop.
|
|
database_path=tmp_path / "signal_bot.sqlite3",
|
|
export_path=tmp_path / "export" / "DOCS-suggestions.md",
|
|
aliases_path=tmp_path / "aliases_custom.json",
|
|
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
port=PORT,
|
|
)
|
|
|
|
|
|
def client_for(deps: ManageDeps) -> TestClient:
|
|
"""A test client that talks to the app the way a browser on this box does."""
|
|
return TestClient(build_app(deps), base_url=BASE_URL)
|