diff --git a/DOCS-commands.md b/DOCS-commands.md index 6c56715..da2ff82 100644 --- a/DOCS-commands.md +++ b/DOCS-commands.md @@ -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/`), 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 diff --git a/fake_signal/routes_api.py b/fake_signal/routes_api.py index 2bca3f0..37e815a 100644 --- a/fake_signal/routes_api.py +++ b/fake_signal/routes_api.py @@ -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) diff --git a/fake_signal/scenario.py b/fake_signal/scenario.py index 2fab361..082a4f0 100644 --- a/fake_signal/scenario.py +++ b/fake_signal/scenario.py @@ -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]: diff --git a/fake_signal/scenarios/good_bot.yaml b/fake_signal/scenarios/good_bot.yaml new file mode 100644 index 0000000..8ac13b9 --- /dev/null +++ b/fake_signal/scenarios/good_bot.yaml @@ -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 } diff --git a/fake_signal/state.py b/fake_signal/state.py index 3c89b0e..689a5f0 100644 --- a/fake_signal/state.py +++ b/fake_signal/state.py @@ -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) diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index 4977178..8237055 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -158,6 +158,14 @@ "!suggest", "!pomysl" ], + "good_bot": [ + "!good bot", + "!dobry bot", + "!goodbot", + "!dobrybot", + "!good_bot", + "!dobry_bot" + ], "mute": [ "!cisza", "!cicho", diff --git a/signal_bot/config.py b/signal_bot/config.py index 99d964e..876d497 100644 --- a/signal_bot/config.py +++ b/signal_bot/config.py @@ -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.""" diff --git a/signal_bot/context.py b/signal_bot/context.py index 9135717..0c67190 100644 --- a/signal_bot/context.py +++ b/signal_bot/context.py @@ -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(): diff --git a/signal_bot/good_bot_command.py b/signal_bot/good_bot_command.py new file mode 100644 index 0000000..f0b22ef --- /dev/null +++ b/signal_bot/good_bot_command.py @@ -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) diff --git a/signal_bot/help_command.py b/signal_bot/help_command.py index 5cbf743..a787592 100644 --- a/signal_bot/help_command.py +++ b/signal_bot/help_command.py @@ -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", diff --git a/signal_bot/transport_base.py b/signal_bot/transport_base.py index f0f84d3..0ebb2f2 100644 --- a/signal_bot/transport_base.py +++ b/signal_bot/transport_base.py @@ -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.""" diff --git a/signal_bot/transport_fake.py b/signal_bot/transport_fake.py index b9ea9c2..7d260a0 100644 --- a/signal_bot/transport_fake.py +++ b/signal_bot/transport_fake.py @@ -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) diff --git a/signal_bot/transport_rest.py b/signal_bot/transport_rest.py index 1f161f8..5f8e32c 100644 --- a/signal_bot/transport_rest.py +++ b/signal_bot/transport_rest.py @@ -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] = { diff --git a/signal_bot/wiring.py b/signal_bot/wiring.py index 9388cae..7f66293 100644 --- a/signal_bot/wiring.py +++ b/signal_bot/wiring.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index 07bac35..ee70718 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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: diff --git a/tests/test_fake_server.py b/tests/test_fake_server.py index 7a0efea..1d79f87 100644 --- a/tests/test_fake_server.py +++ b/tests/test_fake_server.py @@ -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() diff --git a/tests/test_good_bot_command.py b/tests/test_good_bot_command.py new file mode 100644 index 0000000..167401c --- /dev/null +++ b/tests/test_good_bot_command.py @@ -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 ") diff --git a/tests/test_help_command.py b/tests/test_help_command.py index fd6a2df..3e82f91 100644 --- a/tests/test_help_command.py +++ b/tests/test_help_command.py @@ -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 diff --git a/tests/test_scenario.py b/tests/test_scenario.py index 3603738..c331983 100644 --- a/tests/test_scenario.py +++ b/tests/test_scenario.py @@ -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: diff --git a/tests/test_transport_fake.py b/tests/test_transport_fake.py index 5f4509c..7aaa38c 100644 --- a/tests/test_transport_fake.py +++ b/tests/test_transport_fake.py @@ -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"] diff --git a/tests/test_transport_rest.py b/tests/test_transport_rest.py index ea9249f..0ac86f1 100644 --- a/tests/test_transport_rest.py +++ b/tests/test_transport_rest.py @@ -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"):