signal-bot/tests/test_good_bot_command.py
Krzysztof kuhy Rudnicki e3c7bbfe23 !good bot: thank the asker with an emoji on their message, not a reply
A compliment gets a reaction drawn from a small warm set (❤️ 🥰 😊 👍 🎉 🙏
😸) put on the compliment itself, and nothing in the group -- the one
answer that adds no line. Same cooldown as every other command.

The transport learns reactions: send_reaction on the Protocol, POST
/v1/reactions/<number> on the REST adapter with the four fields
api.SendReactionRequest requires (204 back), a recorded list on the fake.
CommandContext.react() names the target the way a quote does, and falls
back to the emoji as text for an envelope with no timestamp, so a shape
this bot has never seen is visible rather than swallowed.

The fake container gains the endpoint, strict about the schema, and
records reactions in order with the sends; a scenario can now expect
{kind: reaction, quotes: true}. First two-word trigger: fold() keeps the
inner space and the registry matches whole text first, so "!good bot"
resolves and "!good" does not; one-word spellings cover a double space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:48:03 +02:00

154 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!good bot: a reaction on the compliment, and not a word in the group."""
from __future__ import annotations
import datetime as dt
import json
from typing import TYPE_CHECKING
import pytest
from signal_bot.animals import ALIAS_FILE
from signal_bot.context import CommandContext
from signal_bot.decorators import cooldown
from signal_bot.dispatcher import Dispatcher
from signal_bot.good_bot_command import (
COMMAND_NAME,
EMOJI,
pick,
register_good_bot_command,
)
from signal_bot.help_command import GROUPS
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRegistry
from signal_bot.registry_holder import RegistryHolder
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
ALIASES = {COMMAND_NAME: ["!good bot", "!dobry bot", "!goodbot"]}
SENT_AT = 1719177917717
DRAWS = 200
NOW = dt.datetime(2026, 9, 4, 12, 0, tzinfo=dt.UTC)
def _registry() -> CommandRegistry:
registry = CommandRegistry()
register_good_bot_command(registry, aliases=ALIASES)
return registry
async def _run(
store: Store, transport: FakeTransport, config: Config, text: str, *, timestamp: int
) -> None:
"""Type one line at the bot through the real resolver and handler."""
message = parse_envelope(envelope(text=text, timestamp=timestamp))
assert message is not None
resolved = _registry().resolve_with_args(text, WATCHED_GROUP)
assert resolved is not None
ctx = CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name=resolved.command.name,
now=1_000.0,
day="2026-09-04",
args=resolved.args,
)
await resolved.command.handler(ctx)
@pytest.mark.parametrize(
"text", ["!good bot", "!Good Bot", "!dobry bot", "!goodbot", " !good bot "]
)
async def test_the_compliment_gets_an_emoji_on_itself_and_no_reply(
store: Store, transport: FakeTransport, config: Config, text: str
) -> None:
await _run(store, transport, config, text, timestamp=SENT_AT)
(reaction,) = transport.reactions
assert reaction.emoji in EMOJI
# On the asker's message, in the asker's group: what a quote would name.
assert (reaction.recipient, reaction.target_author) == (WATCHED_GROUP, "uuid-alice")
assert reaction.timestamp == SENT_AT
assert transport.sent == []
async def test_a_message_that_cannot_be_pointed_at_gets_the_emoji_as_text(
store: Store, transport: FakeTransport, config: Config
) -> None:
"""The visible degradation, the same one reply() makes when it cannot quote."""
await _run(store, transport, config, "!good bot", timestamp=0)
assert transport.reactions == []
(sent,) = transport.sent
assert sent.text in EMOJI
assert sent.quote is None
def test_only_the_two_words_together_are_the_command() -> None:
"""The bare first word is nothing, and so is a compliment with a tail."""
registry = _registry()
assert registry.resolve_with_args("!good", WATCHED_GROUP) is None
assert registry.resolve_with_args("!good bot!!!", WATCHED_GROUP) is None
assert registry.resolve_with_args("!good bot thanks", WATCHED_GROUP) is None
assert registry.resolve_with_args("!goodbot", WATCHED_GROUP) is not None
def test_every_emoji_in_the_set_comes_up() -> None:
"""A draw over seven; two hundred draws that miss one would be a broken draw."""
assert {pick() for _ in range(DRAWS)} == set(EMOJI)
assert pick(("x",)) == "x"
def test_the_shipped_aliases_are_command_words_and_the_first_is_the_two_word_one() -> None:
"""_repo_vocabulary() subtracts these from the personal-data gate.
The first alias is what !pomoc prints, and "!good bot" is the spelling
the group asked for; the one-word ones exist for a double space.
"""
document = json.loads(ALIAS_FILE.read_text(encoding="utf-8"))
triggers = document[COMMAND_NAME]
assert triggers[0] == "!good bot"
for trigger in triggers:
assert trigger.startswith("!")
assert trigger[1:].replace(" ", "").replace("_", "").isalpha()
def test_help_files_it_under_its_own_heading() -> None:
assert any(COMMAND_NAME in names for _, names in GROUPS)
async def test_through_the_dispatcher_it_is_a_command_with_a_cooldown(
config: Config, directory: GroupDirectory, store: Store, transport: FakeTransport
) -> None:
"""Served like any other command: recorded, timed, and rate-limited."""
registry = CommandRegistry()
register_good_bot_command(registry, aliases=ALIASES, decorate=cooldown(10, "wait {seconds}"))
dispatcher = Dispatcher(
config=config,
directory=directory,
holder=RegistryHolder(lambda: registry),
store=store,
transport=transport,
clock=lambda: NOW,
)
first = await dispatcher.handle(envelope(text="!good bot"))
second = await dispatcher.handle(envelope(text="!good bot"))
assert first is not None
assert (first.command, first.outcome) == (COMMAND_NAME, "ok")
assert first.send_ms >= 0
assert second is not None
assert second.outcome == "refused_cooldown"
assert len(transport.reactions) == 1
assert transport.texts[0].startswith("wait ")