!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>
This commit is contained in:
Krzysztof Rudnicki 2026-09-04 21:48:03 +02:00
parent b5c4e3e37b
commit e3c7bbfe23
21 changed files with 417 additions and 8 deletions

View File

@ -32,6 +32,7 @@ This file is the trigger reference — every spelling that resolves, which
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
| random | `!losowe` `!losuj` `!random` `!rand` |
| suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` |
| good_bot | `!good bot` `!dobry bot` `!goodbot` `!dobrybot` `!good_bot` `!dobry_bot` |
| mute | `!cisza` `!cicho` `!mute` |
| unmute | `!odcisz` `!gadaj` `!wracaj` |
@ -57,6 +58,14 @@ makes the page's save a reload: the bot proves the file before it is kept.
The tagger refuses to give a person a trigger that is already somebody's
custom alias, for the same reason it refuses the shipped ones.
`good_bot` is the one two-word trigger. It works because `fold()` keeps inner
spaces and the registry matches the whole text before it looks at the first
word, so `!good bot` resolves and `!good` alone does not. Two spaces between
the words resolve to nothing, which is what the one-word spellings are for.
It is also the one command that answers with no message: it puts an emoji
on the compliment itself (`POST /v1/reactions/<number>`), drawn from a small
set, and says nothing in the group.
## A word nothing claims
An unrecognised `!word` used to be dropped in silence, which made a typo

View File

@ -3,13 +3,16 @@
from __future__ import annotations
from typing import Any
from typing import Any, Final
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, Response
from fake_signal.state import PIXEL_PNG, State
# Read off the real container's /swagger/doc.json, not remembered.
REACTION_FIELDS: Final = frozenset({"reaction", "recipient", "target_author", "timestamp"})
def _state(request: Request) -> State:
"""Return the State attached to the running app."""
@ -57,6 +60,14 @@ def build_api_router() -> APIRouter:
_state(request).record_send(payload)
return JSONResponse({"timestamp": 1719177917717}, status_code=201)
@router.post("/v1/reactions/{number}")
async def react(request: Request, number: str, payload: dict[str, Any]) -> Response:
"""The container's api.SendReactionRequest: all four fields required."""
if REACTION_FIELDS - payload.keys():
return JSONResponse({"error": "missing reaction fields"}, status_code=400)
_state(request).record_reaction(number, payload)
return Response(status_code=204)
_add_attachment_routes(router)
_add_receive_route(router)

View File

@ -172,6 +172,8 @@ def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
a clip: told it is a photo, Signal renders an unplayable file, and every
other check in this harness would still pass.
"""
if "reaction" in payload:
return "reaction", str(payload.get("recipient", "")), str(payload["reaction"])
attachments = payload.get("base64_attachments") or []
kind = "text"
if attachments:
@ -181,8 +183,12 @@ def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
def quotes(payload: dict[str, Any]) -> bool:
"""True when a captured send points at a specific message."""
return bool(payload.get("quote_timestamp"))
"""True when a captured send points at a specific message.
A reply points with ``quote_timestamp``; a reaction has nothing else to
do, and names its target as ``timestamp``.
"""
return bool(payload.get("quote_timestamp") or payload.get("timestamp"))
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:

View File

@ -0,0 +1,5 @@
name: "a compliment gets an emoji on the compliment itself, not a message in the group"
inject:
- { text: "!good bot", uuid: uuid-fan, name: Fan }
expect:
- { kind: reaction, to: reply, quotes: true }

View File

@ -46,6 +46,15 @@ class State:
self.sent.clear()
self.attachments.clear()
def record_reaction(self, number: str, payload: dict[str, Any]) -> None:
"""Capture one reaction in the same list as the sends.
One list, because a scenario's ``expect`` is the bot's outbound
traffic in order, and "it reacted instead of replying" is exactly
the kind of claim that ordering has to be able to make.
"""
self.sent.append({"number": number, **payload})
def record_send(self, payload: dict[str, Any]) -> None:
"""Capture one outgoing message and mint ids for its attachments."""
self.sent.append(payload)

View File

@ -158,6 +158,14 @@
"!suggest",
"!pomysl"
],
"good_bot": [
"!good bot",
"!dobry bot",
"!goodbot",
"!dobrybot",
"!good_bot",
"!dobry_bot"
],
"mute": [
"!cisza",
"!cicho",

View File

@ -151,6 +151,11 @@ class Config:
"""REST endpoint for outgoing messages and attachments."""
return f"{self.api_base_url}/v2/send"
@property
def reactions_url(self) -> str:
"""REST endpoint for putting an emoji on a message this account can see."""
return f"{self.api_base_url}/v1/reactions/{self.phone_number}"
@property
def attachments_url(self) -> str:
"""REST endpoint listing server-side stored attachments."""

View File

@ -49,6 +49,25 @@ class CommandContext:
async with timed_send():
await self.transport.send_text(text, self.group_id, quote_of(self.message))
async def react(self, emoji: str) -> None:
"""Put an emoji on the triggering message instead of answering it.
The one reply that adds no line to the conversation: Signal renders
it on the asker's own bubble. An envelope with no timestamp has no
message to point at, and there the emoji goes out as text -- the
same degradation reply() makes when it cannot quote, and a visible
one, so a shape this bot has never seen is noticed rather than
swallowed.
"""
target = quote_of(self.message)
if target is None:
await self.reply(emoji)
return
async with timed_send():
await self.transport.send_reaction(
emoji, self.group_id, target.author, target.timestamp
)
async def reply_image(self, image_base64: str, caption: str = "") -> None:
"""Answer the triggering message with an image, quoting it."""
async with timed_send():

View File

@ -0,0 +1,69 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!good bot: the bot says thank you the one way that adds no noise.
A reaction rather than a reply. "good bot" is a compliment, and answering a
compliment with a line of text is the kind of thing that gets a bot muted;
an emoji on the compliment itself is seen by the whole group without taking
up a line. Which emoji is a draw from a small warm set, so the second
thank-you of the evening does not look like a canned copy of the first.
Rate-limited like every other command: a heart costs a POST to the
container, and somebody typing it in a loop is the same problem as somebody
typing !cat in a loop.
The trigger is two words, which no other command has. That works because
``folding.fold`` keeps inner spaces and the registry matches the whole text
before it looks at the first word -- so "!good bot" resolves, "!good" alone
resolves to nothing, and "!good bot" typed with two spaces resolves to
nothing either. The one-word spellings in aliases.json are for that last
case.
"""
from __future__ import annotations
import secrets
from typing import TYPE_CHECKING, Final
from signal_bot.animals import load_aliases
if TYPE_CHECKING:
from collections.abc import Sequence
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.registry import CommandRegistry, Handler
COMMAND_NAME: Final = "good_bot"
DESCRIPTION: Final = "bot dziękuje za komplement reakcją, bez gadania"
# Warm and unambiguous, nothing that reads as sarcasm in a group chat.
EMOJI: Final = ("❤️", "🥰", "😊", "👍", "🎉", "🙏", "😸")
def pick(choices: Sequence[str] = EMOJI) -> str:
"""One emoji, uniformly at random.
secrets rather than random for the reason animals.paged_url gives: the
choice is not security-sensitive, but ruff's ALL rule set flags `random`
and this repository forbids the noqa that would silence it.
"""
return choices[secrets.randbelow(len(choices))]
async def good_bot_handler(ctx: CommandContext) -> None:
"""Thank the asker on their own message; say nothing in the group."""
await ctx.react(pick())
def register_good_bot_command(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
) -> None:
"""Register !good bot under every spelling aliases.json gives it."""
table = load_aliases() if aliases is None else aliases
handler: Handler = good_bot_handler
if decorate is not None:
handler = decorate(handler)
registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)

View File

@ -42,6 +42,7 @@ GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
("Papież", ("pope",)),
("Wiersze", ("poem",)),
("Propozycje", ("suggest",)),
("Podziękowania", ("good_bot",)),
("Cisza", ("mute", "unmute")),
(
"Statystyki",

View File

@ -55,6 +55,15 @@ class Transport(Protocol):
say what it is, or it arrives as an unplayable blob.
"""
async def send_reaction(
self, emoji: str, recipient: str, target_author: str, timestamp: int
) -> None:
"""Put one emoji on an existing message instead of sending a new one.
The message is named the way a quote names it, by author and sent
timestamp, because that pair is the only id Signal gives a message.
"""
async def list_attachments(self) -> list[str]:
"""Return the ids of attachments stored server-side."""

View File

@ -30,12 +30,25 @@ class SentMessage:
filename: str = ""
@dataclass(frozen=True, slots=True)
class Reaction:
"""One emoji the bot put on somebody's message."""
emoji: str
recipient: str
target_author: str
timestamp: int
@dataclass
class FakeTransport:
"""Replays canned frames and records everything the bot sends."""
frames: list[str] = field(default_factory=list)
sent: list[SentMessage] = field(default_factory=list)
# Kept apart from ``sent``: a reaction adds no message to the group, and
# a test asserting "the bot said nothing" must stay true after one.
reactions: list[Reaction] = field(default_factory=list)
attachments: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
# Attachment id to its bytes, so a test can post a real upload. An id
@ -87,6 +100,12 @@ class FakeTransport:
)
)
async def send_reaction(
self, emoji: str, recipient: str, target_author: str, timestamp: int
) -> None:
"""Record a reaction."""
self.reactions.append(Reaction(emoji, recipient, target_author, timestamp))
async def list_attachments(self) -> list[str]:
"""Return the configured attachment ids."""
return list(self.attachments)

View File

@ -108,13 +108,33 @@ class RestTransport:
await self._sleep(backoff)
backoff = min(backoff * BACKOFF_FACTOR, MAX_BACKOFF)
async def _post_send(self, payload: dict[str, Any]) -> None:
"""POST one send payload and raise unless the API accepted it."""
response = await self._client.post(self._config.send_url, json=payload)
async def _post(self, url: str, payload: dict[str, Any], what: str) -> None:
"""POST one payload and raise unless the API accepted it."""
response = await self._client.post(url, json=payload)
if response.status_code not in _OK_STATUSES:
msg = f"send failed with HTTP {response.status_code}: {response.text}"
msg = f"{what} failed with HTTP {response.status_code}: {response.text}"
raise TransportError(msg)
async def _post_send(self, payload: dict[str, Any]) -> None:
await self._post(self._config.send_url, payload, "send")
async def send_reaction(
self, emoji: str, recipient: str, target_author: str, timestamp: int
) -> None:
"""React to one message; the container answers 204 with no body.
Field names are api.SendReactionRequest's from /swagger/doc.json,
all four required there -- which is why the caller resolves the
target the way a quote is resolved and sends nothing when it cannot.
"""
payload = {
"reaction": emoji,
"recipient": recipient,
"target_author": target_author,
"timestamp": timestamp,
}
await self._post(self._config.reactions_url, payload, "reaction")
async def send_text(self, text: str, recipient: str, quote: Quote | None = None) -> None:
"""Send a plain-text message, optionally quoting one message."""
payload: dict[str, Any] = {

View File

@ -19,6 +19,7 @@ from signal_bot.art_command import register_art_command
from signal_bot.custom_aliases import default_path as custom_aliases_path
from signal_bot.custom_aliases import load as load_custom_aliases
from signal_bot.decorators import cooldown
from signal_bot.good_bot_command import register_good_bot_command
from signal_bot.groups import ConfigError
from signal_bot.help_command import register_help_command
from signal_bot.mute import UNMUTE_NAME
@ -97,6 +98,7 @@ def build_registry(
# row is asked to wait for the second. The alternative is one person
# being able to fill a table nothing prunes.
register_suggest_command(registry, decorate=rate_limit)
register_good_bot_command(registry, decorate=rate_limit)
# No cooldown: "wait before you may ask for quiet" is the wrong answer,
# and the unmute is gated by trust, not by time.
register_mute_commands(registry)

View File

@ -114,6 +114,7 @@ def test_derived_urls() -> None:
assert config.send_url == "http://localhost:9922/v2/send"
assert config.attachments_url == "http://localhost:9922/v1/attachments"
assert config.about_url == "http://localhost:9922/v1/about"
assert config.reactions_url == "http://localhost:9922/v1/reactions/+15550000000"
def test_https_base_url_yields_wss() -> None:

View File

@ -43,6 +43,22 @@ def test_send_is_captured_and_mints_attachment_ids(client: TestClient, state: St
assert state.attachments == ["attach-2-0"]
def test_a_reaction_is_captured_in_order_with_the_sends(client: TestClient, state: State) -> None:
reaction = {"reaction": "❤️", "recipient": "g", "target_author": "a", "timestamp": 17}
client.post("/v2/send", json={"message": "hi", "recipients": ["g"]})
response = client.post("/v1/reactions/+15550000000", json=reaction)
assert response.status_code == 204
assert state.sent[1] == {"number": "+15550000000", **reaction}
assert state.attachments == []
def test_a_reaction_missing_a_required_field_is_refused(client: TestClient, state: State) -> None:
"""The container's schema marks all four required; the fake must be as strict."""
response = client.post("/v1/reactions/+15550000000", json={"reaction": "❤️", "recipient": "g"})
assert response.status_code == 400
assert state.sent == []
def test_attachments_can_be_listed_and_deleted(client: TestClient) -> None:
client.post("/v2/send", json={"base64_attachments": ["A"], "recipients": ["g"]})
listed = client.get("/v1/attachments").json()

View File

@ -0,0 +1,153 @@
# 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 ")

View File

@ -13,6 +13,7 @@ from signal_bot.animals import register_animal_commands
from signal_bot.art_command import register_art_command
from signal_bot.config import DEFAULT_COMMONS_API_URL, DEFAULT_POEM_TEXT_URL
from signal_bot.context import CommandContext
from signal_bot.good_bot_command import register_good_bot_command
from signal_bot.help_command import (
COMMAND_NAME,
GROUPS,
@ -64,6 +65,7 @@ def full_registry() -> CommandRegistry:
register_stats_commands(registry)
register_analytics_command(registry)
register_suggest_command(registry)
register_good_bot_command(registry)
register_mute_commands(registry)
register_help_command(registry)
return registry

View File

@ -97,9 +97,17 @@ def test_describe_classifies_sends() -> None:
assert describe({})[1] == ""
def test_describe_tells_a_reaction_from_a_send() -> None:
reaction = {"reaction": "❤️", "recipient": "R==", "target_author": "a", "timestamp": 17}
assert describe(reaction) == ("reaction", "R==", "❤️")
assert describe({"reaction": "❤️"})[1] == ""
def test_quotes_detects_a_pointed_reply() -> None:
assert quotes({"quote_timestamp": 1719177917717}) is True
assert quotes({"message": "hi"}) is False
# A reaction has nothing to do but point, and names its target as timestamp.
assert quotes({"reaction": "❤️", "timestamp": 17}) is True
def test_check_enforces_a_quoted_reply_in_both_directions() -> None:

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from signal_bot.transport_fake import FakeTransport
from signal_bot.transport_fake import FakeTransport, Reaction
async def test_queue_and_receive() -> None:
@ -20,6 +20,13 @@ async def test_records_sends() -> None:
assert transport.sent[1].image_base64 == "B64"
async def test_records_reactions_apart_from_sends() -> None:
transport = FakeTransport()
await transport.send_reaction("👍", "g", "uuid-alice", 17)
assert transport.reactions == [Reaction("👍", "g", "uuid-alice", 17)]
assert transport.sent == []
async def test_attachments_round_trip() -> None:
transport = FakeTransport(attachments=["a1"])
assert await transport.list_attachments() == ["a1"]

View File

@ -122,6 +122,36 @@ async def test_send_image_posts_base64_attachment(config: Config) -> None:
assert seen[0]["base64_attachments"] == ["BASE64"]
async def test_a_reaction_goes_to_its_own_endpoint_with_the_four_required_keys(
config: Config,
) -> None:
"""api.SendReactionRequest, and the 204 the container answers with."""
seen: list[tuple[str, dict]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((str(request.url), __import__("json").loads(request.content)))
return httpx.Response(204)
await _transport(config, handler).send_reaction("❤️", "group==", "uuid-alice", 17)
assert seen == [
(
f"http://localhost:9922/v1/reactions/{config.phone_number}",
{
"reaction": "❤️",
"recipient": "group==",
"target_author": "uuid-alice",
"timestamp": 17,
},
)
]
async def test_a_refused_reaction_raises_and_says_so(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(400, text="bad author"))
with pytest.raises(TransportError, match="reaction failed with HTTP 400: bad author"):
await transport.send_reaction("❤️", "g", "a", 1)
async def test_send_raises_on_rejection(config: Config) -> None:
transport = _transport(config, lambda r: httpx.Response(400, text="nope"))
with pytest.raises(TransportError, match="400"):