mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +01:00
!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:
parent
b5c4e3e37b
commit
e3c7bbfe23
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
5
fake_signal/scenarios/good_bot.yaml
Normal file
5
fake_signal/scenarios/good_bot.yaml
Normal 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 }
|
||||
@ -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)
|
||||
|
||||
@ -158,6 +158,14 @@
|
||||
"!suggest",
|
||||
"!pomysl"
|
||||
],
|
||||
"good_bot": [
|
||||
"!good bot",
|
||||
"!dobry bot",
|
||||
"!goodbot",
|
||||
"!dobrybot",
|
||||
"!good_bot",
|
||||
"!dobry_bot"
|
||||
],
|
||||
"mute": [
|
||||
"!cisza",
|
||||
"!cicho",
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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():
|
||||
|
||||
69
signal_bot/good_bot_command.py
Normal file
69
signal_bot/good_bot_command.py
Normal 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)
|
||||
@ -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",
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
153
tests/test_good_bot_command.py
Normal file
153
tests/test_good_bot_command.py
Normal 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 ")
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user