mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 12:57:32 +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>
154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Appending to a roster that already exists, without renumbering it."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from signal_bot.custom_aliases import ENV_VAR as CUSTOM_ALIASES_ENV
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.pack import PEOPLE_FILE
|
|
from signal_bot.pack_custom import CUSTOM_FILE
|
|
from signal_bot.pack_people import add_person, merge, next_key, normalise, reserved
|
|
from tests.conftest import WATCHED_GROUP
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def _roster(tmp_path: Path, people: list[dict[str, Any]]) -> Path:
|
|
(tmp_path / PEOPLE_FILE).write_text(
|
|
json.dumps({"groups": [WATCHED_GROUP], "people": people}), encoding="utf-8"
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def test_the_next_key_is_one_past_the_highest_not_the_count() -> None:
|
|
# p2 deleted by hand: a count-based key would hand out p3 again and the
|
|
# new person would silently inherit the old p3's photos.
|
|
assert next_key([{"key": "p1"}, {"key": "p3"}]) == "p4"
|
|
|
|
|
|
def test_the_next_key_on_an_empty_roster() -> None:
|
|
assert next_key([]) == "p1"
|
|
|
|
|
|
def test_the_next_key_ignores_entries_that_are_not_keys() -> None:
|
|
assert next_key([{"key": "pX"}, {"key": 7}, {}, {"key": "p2"}]) == "p3"
|
|
|
|
|
|
def test_a_trigger_may_be_typed_with_or_without_the_bang() -> None:
|
|
assert normalise([" alfa ", "!beta", "", " "]) == ["!alfa", "!beta"]
|
|
|
|
|
|
def test_the_bot_s_own_commands_are_known() -> None:
|
|
# Folded, and folding keeps the "!" -- which is why add_person compares
|
|
# against normalise()'s "!"-prefixed spelling and not the bare word.
|
|
assert "!kot" in reserved()
|
|
assert not any(word.startswith("_") for word in reserved())
|
|
|
|
|
|
def test_the_operators_own_aliases_are_reserved_too(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
custom = tmp_path / "aliases_custom.json"
|
|
custom.write_text(json.dumps({"cat": ["!zeta"]}), encoding="utf-8")
|
|
monkeypatch.setenv(CUSTOM_ALIASES_ENV, str(custom))
|
|
assert "!zeta" in reserved()
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
with pytest.raises(ConfigError, match="already a command"):
|
|
add_person(directory, ["zeta"])
|
|
|
|
|
|
def test_a_person_is_appended_with_a_stable_key(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
assert add_person(directory, ["delta"]) == "p2"
|
|
document = json.loads((directory / PEOPLE_FILE).read_text(encoding="utf-8"))
|
|
assert document["people"][0] == {"key": "p1", "triggers": ["!alfa"]}
|
|
assert document["people"][1] == {"key": "p2", "triggers": ["!delta"]}
|
|
|
|
|
|
def test_a_person_can_be_added_to_an_album_with_no_roster_yet(tmp_path: Path) -> None:
|
|
(tmp_path / PEOPLE_FILE).write_text(json.dumps({"groups": [WATCHED_GROUP]}), encoding="utf-8")
|
|
assert add_person(tmp_path, ["!alfa"]) == "p1"
|
|
|
|
|
|
def test_a_person_with_no_triggers_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
with pytest.raises(ConfigError, match="at least one trigger"):
|
|
add_person(directory, [" "])
|
|
|
|
|
|
def test_a_trigger_that_is_already_a_command_is_refused(tmp_path: Path) -> None:
|
|
# !kot would make personal_data_sources find "kot" in aliases.json and
|
|
# block every commit forever, fixable only by editing a private album.
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
with pytest.raises(ConfigError, match="already a command"):
|
|
add_person(directory, ["kot"])
|
|
|
|
|
|
def test_a_trigger_with_a_digit_is_refused_and_nothing_is_written(tmp_path: Path) -> None:
|
|
# Folding maps 0134578 onto letters, so a digit is ambiguous by design.
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
before = (directory / PEOPLE_FILE).read_bytes()
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["!alfa2"])
|
|
assert (directory / PEOPLE_FILE).read_bytes() == before
|
|
|
|
|
|
def test_a_trigger_colliding_with_an_existing_person_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
before = (directory / PEOPLE_FILE).read_bytes()
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["!alfa"])
|
|
assert (directory / PEOPLE_FILE).read_bytes() == before
|
|
|
|
|
|
def test_a_trigger_colliding_with_a_custom_label_is_refused(tmp_path: Path) -> None:
|
|
directory = _roster(tmp_path, [{"key": "p1", "triggers": ["!alfa"]}])
|
|
(directory / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
|
|
with pytest.raises(ConfigError):
|
|
add_person(directory, ["wakacje"])
|
|
|
|
|
|
def test_a_failed_add_restores_a_roster_that_did_not_exist(tmp_path: Path) -> None:
|
|
with pytest.raises(ConfigError):
|
|
add_person(tmp_path, ["!alfa"])
|
|
assert not (tmp_path / PEOPLE_FILE).exists()
|
|
|
|
|
|
def test_a_roster_that_is_not_json_is_refused_rather_than_overwritten(tmp_path: Path) -> None:
|
|
# Strict, unlike the ledger: appending to a document that failed to parse
|
|
# would write a roster that silently dropped everyone already in it.
|
|
(tmp_path / PEOPLE_FILE).write_text("{oops", encoding="utf-8")
|
|
with pytest.raises(ConfigError, match="not valid JSON"):
|
|
add_person(tmp_path, ["!alfa"])
|
|
|
|
|
|
def test_a_roster_that_is_not_an_object_is_refused(tmp_path: Path) -> None:
|
|
(tmp_path / PEOPLE_FILE).write_text("[]", encoding="utf-8")
|
|
with pytest.raises(ConfigError, match="not an object"):
|
|
add_person(tmp_path, ["!alfa"])
|
|
|
|
|
|
def test_merge_keeps_existing_keys_where_they_are() -> None:
|
|
# The parseSetup bug, pinned: positional keys renumber everyone after an
|
|
# insertion and repoint every tags.json entry at the wrong person.
|
|
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}, {"key": "p2", "triggers": ["!b"]}]}
|
|
merged = merge(document, [{"key": "p2", "triggers": ["!b", "!beta"]}])
|
|
assert [person["key"] for person in merged] == ["p1", "p2"]
|
|
assert merged[1]["triggers"] == ["!b", "!beta"]
|
|
|
|
|
|
def test_merge_appends_someone_new_with_a_fresh_key() -> None:
|
|
document = {"people": [{"key": "p1", "triggers": ["!alfa"]}]}
|
|
merged = merge(document, [{"triggers": ["!delta"]}])
|
|
assert merged[1]["key"] == "p2"
|
|
|
|
|
|
def test_merge_on_an_empty_document() -> None:
|
|
assert merge({}, [{"triggers": ["!alfa"]}])[0]["key"] == "p1"
|