# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """What the bot says about a "!" word it does not know, and what it keeps.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from signal_bot.models import parse_envelope from signal_bot.registry import CommandRegistry from signal_bot.unknown import ( SUGGEST_PRIMARY, answer_unknown, reply_for, unknown_in, ) from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope if TYPE_CHECKING: from signal_bot.context import CommandContext from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport async def _noop(ctx: CommandContext) -> None: """A handler that does nothing.""" def loaded() -> CommandRegistry: """A registry with one global command and one scoped to a single group.""" registry = CommandRegistry() registry.register("cat", ["!kot", "!cat"], _noop, "kot") # The album's shape: scoped, and named after a person. registry.register("pack:p1", ["!alfa"], _noop, "zdjęcia", groups=frozenset({WATCHED_GROUP})) return registry @pytest.mark.parametrize("text", [None, "", " ", "just chatting", "hej! super"]) def test_a_message_that_is_not_a_command_attempt_is_not_one(text: str | None) -> None: """Nothing without a leading "!" word reaches the rest of this module.""" assert unknown_in(text, loaded()) is None @pytest.mark.parametrize("text", ["!", "!!", "!?", "!...", "!😀", "!x"]) def test_punctuation_shaped_like_a_command_is_ignored(text: str) -> None: """No reply and no row: this is ordinary typing, not a mistyped command.""" assert unknown_in(text, loaded()) is None def test_a_real_trigger_with_words_after_it_stays_silent() -> None: """ "!kot ładny" has always been silent and this feature does not change it.""" assert unknown_in("!kot ładny", loaded()) is None def test_a_command_scoped_to_another_group_is_never_reported() -> None: """The album's privacy guarantee. "!alfa" is a person's name. In a group with no album it is unclaimed *there*, so a check scoped to the asking group would answer it and store it -- announcing the album and putting the name in the database. The check is global, so this is silence. """ assert unknown_in("!alfa", loaded()) is None def test_an_unclaimed_word_is_reported_with_the_whole_message() -> None: """The word is folded; the body is kept exactly as it was typed.""" found = unknown_in("!CTA teraz proszę", loaded()) assert found is not None assert found.word == "!cta" assert found.body == "!CTA teraz proszę" def test_a_styled_or_cyrillic_spelling_folds_before_anything_else() -> None: """The same folding the registry does, so both sides can only agree.""" found = unknown_in("!ᴄᴛᴀ", loaded()) assert found is not None assert found.word == "!cta" def test_a_digit_survives_folding_and_is_still_recorded() -> None: """fold() maps 0134578 onto letters but leaves 2, 6 and 9 alone. A letters-only shape guard would silently drop "!rok2", which is exactly the kind of request this feature exists to collect. """ found = unknown_in("!rok2", loaded()) assert found is not None assert found.word == "!rok2" def test_a_near_miss_is_answered_with_the_polish_spelling() -> None: """`triggers` are folded skeletons; only `primary` still reads correctly.""" registry = CommandRegistry() registry.register("pope", ["!papież", "!pope"], _noop, "papież") live = registry.triggers_in(WATCHED_GROUP) assert "!papież" in reply_for("!papiesz", live) assert "!papiez?" not in reply_for("!papiesz", live) def test_a_word_with_no_near_miss_points_at_the_suggestion_command() -> None: """Otherwise there is nothing for the person to do with the refusal.""" text = reply_for("!nosuchcommand", loaded().triggers_in(WATCHED_GROUP)) assert SUGGEST_PRIMARY in text assert "!nosuchcommand" in text def test_a_suggestion_never_names_a_command_from_another_group() -> None: """ "!alfaa" must not come back as "chodziło o !alfa?" where there is no album. Asserted on the correction clause rather than on the bare name, because the refusal quotes the word that was typed and "!alfa" is a substring of "!alfaa" -- a naive check passes for the wrong reason. """ registry = loaded() live = registry.triggers_in(SECOND_GROUP) assert "!alfa" not in live answer = reply_for("!alfaa", live) assert "Chodziło" not in answer assert SUGGEST_PRIMARY in answer # ...and the album's own group is where the correction is allowed. assert "Chodziło" in reply_for("!alfaa", registry.triggers_in(WATCHED_GROUP)) async def test_answering_records_the_token_and_the_body( store: Store, transport: FakeTransport ) -> None: """One row, one reply, keyed on the envelope's own timestamp.""" message = parse_envelope(envelope(text="!cta", timestamp=4242)) assert message is not None await answer_unknown( message=message, group_id=WATCHED_GROUP, registry=loaded(), store=store, transport=transport, ) assert len(transport.sent) == 1 assert "!kot" in (transport.sent[0].text or "") (row,) = store.suggestions.unknown() assert row.word == "!cta" assert row.bodies == ("!cta",) assert row.last_seen == 4242 async def test_answering_quotes_the_person_who_asked( store: Store, transport: FakeTransport ) -> None: """A busy group has to be able to tell which "!cta" this is about.""" message = parse_envelope(envelope(text="!cta")) assert message is not None await answer_unknown( message=message, group_id=WATCHED_GROUP, registry=loaded(), store=store, transport=transport, ) assert transport.sent[0].quote is not None async def test_a_message_that_is_not_an_attempt_writes_nothing( store: Store, transport: FakeTransport ) -> None: """The early return, from the caller's side.""" message = parse_envelope(envelope(text="just chatting")) assert message is not None await answer_unknown( message=message, group_id=WATCHED_GROUP, registry=loaded(), store=store, transport=transport, ) assert transport.sent == [] assert store.suggestions.unknown() == []