feat: answer the message that asked, not just the room

A "!cat" in a busy group produced a cat picture floating loose in the chat with
nothing tying it to whoever asked. Replies now quote the triggering message.

The container advertises this and always could: /v1/about reports
capabilities {"v2/send": ["quotes", "mentions"]}, and /v2/send takes
quote_timestamp, quote_author and quote_message. What was missing was on this
side -- parse_envelope discarded the envelope's timestamp, which is the id
Signal quotes a message by, so nothing downstream could have built a quote even
in principle. IncomingMessage now carries it, preferring the payload's own
value over the envelope's for the case where they ever disagree.

Quote is a value object rather than three more parameters threaded through
every send: the Transport protocol has five methods and two of them send.
CommandContext grows reply_image() so quoting lives in one place instead of
each handler reaching for ctx.transport itself, which is what animals.py did.

quote_of() returns None when the timestamp or author is missing. Answering the
room is a worse reply than a quoted one but a much better outcome than a
payload the container rejects.

The e2e gate learned to tell the two apart: Expectation.quotes demands a send
either does or does not point at a message, defaulting to None so the ten
existing scenarios stay indifferent. Without it the fake would have accepted an
unquoted reply as a pass.

Verified in the testbot group on a real Signal client before this bump, per
kuhy's rule that a bump is a publish rather than a checkpoint: he sent !cat and
confirmed the picture renders attached to his message as a reply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 23:01:01 +02:00
parent f92502d18b
commit a202317072
17 changed files with 258 additions and 34 deletions

View File

@ -16,6 +16,11 @@ Add the new section at the top when you bump `RELEASE_ORDINAL`. Anything that
is not a `##` heading or a `-` bullet under one is ignored, so prose between
releases is free.
## 0.0.2 — Anaklet
- bot odpowiada teraz na konkretną wiadomość, cytując ją — w ruchliwej
rozmowie widać, czyje `!cat` to jest
## 0.0.1 — Linus
- wersje bota nazywają się teraz imionami kolejnych papieży

View File

@ -237,7 +237,7 @@ Counts live in SQLite and survive a restart.
## Develop
```sh
pytest # 359 tests, 100% branch coverage, no network
pytest # 368 tests, 100% branch coverage, no network
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
./scripts/e2e.sh # the real bot against the fake server
```

View File

@ -100,6 +100,9 @@ class Expectation:
kind: str = "text"
to: str = "reply"
contains: str | None = None
# True demands the send quote the message that triggered it; False demands
# it not. None (the default) leaves existing scenarios indifferent.
quotes: bool | None = None
@dataclass(frozen=True, slots=True)
@ -153,6 +156,11 @@ def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
return kind, str(recipients[0]), str(payload.get("message", ""))
def quotes(payload: dict[str, Any]) -> bool:
"""True when a captured send points at a specific message."""
return bool(payload.get("quote_timestamp"))
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:
"""Compare captured sends against expectations, returning failure lines."""
failures: list[str] = []
@ -170,4 +178,6 @@ def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]
failures.append(f"[{index}] recipient: expected {want_to}, got {recipient}")
if expected.contains is not None and expected.contains not in body:
failures.append(f"[{index}] body: {expected.contains!r} not in {body!r}")
if expected.quotes is not None and quotes(sent[index]) != expected.quotes:
failures.append(f"[{index}] quotes: expected {expected.quotes}")
return failures

View File

@ -0,0 +1,5 @@
name: "a command answers the message that asked, not just the room"
inject:
- { text: "!cat", uuid: uuid-quoted, name: Quoted }
expect:
- { kind: image, to: reply, quotes: true }

View File

@ -1,6 +1,6 @@
[project]
name = "signal-bot"
version = "0.0.1"
version = "0.0.2"
description = "A Signal group bot: image commands and daily message tallies."
readme = "README.md"
license = "MIT"

View File

@ -37,7 +37,7 @@ def image_handler(fetcher: ImageFetcher, api_url: str, url_path: Sequence[str |
async def handler(ctx: CommandContext) -> None:
encoded = await fetcher.fetch_base64(api_url, url_path)
await ctx.transport.send_image(encoded, ctx.group_id)
await ctx.reply_image(encoded)
return handler

View File

@ -20,6 +20,9 @@ DEFAULT_PATH: Final = Path(__file__).resolve().parent.parent / "DOCS-changelog.m
_HEADING: Final = re.compile(r"^##\s+(\d+\.\d+\.\d+)\s*[—-]\s*(.+?)\s*$")
_BULLET: Final = re.compile(r"^-\s+(.+?)\s*$")
# A wrapped bullet: indented, directly under one. Without this a bullet
# that runs past the line width is announced truncated mid-sentence.
_CONTINUATION: Final = re.compile(r"^\s+(\S.*?)\s*$")
@dataclass(frozen=True, slots=True)
@ -41,16 +44,25 @@ def parse(text: str) -> tuple[Release, ...]:
releases: list[Release] = []
version = pope = ""
lines: list[str] = []
open_bullet = False
for raw in text.splitlines():
heading = _HEADING.match(raw)
if heading is not None:
if version:
releases.append(Release(version, pope, tuple(lines)))
version, pope, lines = heading.group(1), heading.group(2), []
open_bullet = False
continue
bullet = _BULLET.match(raw)
if bullet is not None and version:
lines.append(bullet.group(1))
open_bullet = True
continue
continuation = _CONTINUATION.match(raw)
if continuation is not None and open_bullet:
lines[-1] = f"{lines[-1]} {continuation.group(1)}"
continue
open_bullet = False
if version:
releases.append(Release(version, pope, tuple(lines)))
return tuple(sorted(releases, key=lambda release: release.ordinal))

View File

@ -31,6 +31,9 @@ class IncomingMessage:
# A group membership change rather than a message: this is the only
# frame that names who added the bot to a group.
is_group_update: bool = False
# The sent timestamp, which is the id Signal quotes a message by.
# Without it a reply can only be addressed to the room.
timestamp: int = 0
@property
def is_direct_message(self) -> bool:
@ -42,6 +45,19 @@ class IncomingMessage:
return self.group_id is None
def _timestamp(payload: dict[str, Any], envelope: dict[str, Any]) -> int:
"""The sent timestamp, preferring the payload's own over the envelope's.
They agree on every frame seen so far, but a quote must point at the
message, so the message's own value wins where they ever differ.
"""
for source in (payload, envelope):
value = source.get("timestamp")
if isinstance(value, int):
return value
return 0
def _payload(envelope: dict[str, Any]) -> dict[str, Any] | None:
"""Return the dataMessage or sentMessage body, whichever is present."""
data = envelope.get("dataMessage")
@ -120,4 +136,5 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
is_reaction=isinstance(payload.get("reaction"), dict),
source_number=str(envelope.get("sourceNumber", "")),
is_group_update=_group_type(payload) == "UPDATE",
timestamp=_timestamp(payload, envelope),
)

48
signal_bot/quoting.py Normal file
View File

@ -0,0 +1,48 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Answering a specific message rather than the room.
The container advertises this: /v1/about reports
``capabilities: {"v2/send": ["quotes", "mentions"]}`` and /v2/send accepts
``quote_timestamp``, ``quote_author`` and ``quote_message``.
A quote needs the *sent timestamp of the message being answered*, which is why
``IncomingMessage`` had to start carrying one -- parse_envelope threw it away,
so until now nothing downstream could have built a quote even in principle.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from signal_bot.models import IncomingMessage
@dataclass(frozen=True, slots=True)
class Quote:
"""The message a reply points at."""
timestamp: int
author: str
text: str
def payload(self) -> dict[str, str | int]:
"""The three keys /v2/send wants for a quoted reply."""
return {
"quote_timestamp": self.timestamp,
"quote_author": self.author,
"quote_message": self.text,
}
def quote_of(message: IncomingMessage) -> Quote | None:
"""Return the quote that answers this message, or None if it has none.
Both fields are required by the API, and an envelope missing either is a
shape this bot has not seen -- worth replying without a quote rather than
sending something the container will reject.
"""
if not message.timestamp or not message.source_uuid:
return None
return Quote(message.timestamp, message.source_uuid, message.text or "")

View File

@ -12,6 +12,8 @@ from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from signal_bot.quoting import quote_of
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
@ -35,13 +37,18 @@ class CommandContext:
day: str
async def reply(self, text: str) -> None:
"""Send a text reply into the group the command came from.
"""Answer the message that triggered this command, in its group.
There is no configurable reply target. A command answers where it
was asked, which is the only routing that stays correct once the
bot watches a test group and a production group at once.
bot watches a test group and a production group at once -- and it
quotes the asker, so a busy group can tell which "!cat" this is.
"""
await self.transport.send_text(text, self.group_id)
await self.transport.send_text(text, self.group_id, quote_of(self.message))
async def reply_image(self, image_base64: str) -> None:
"""Answer the triggering message with an image, quoting it."""
await self.transport.send_image(image_base64, self.group_id, quote_of(self.message))
Handler = Callable[[CommandContext], Awaitable[None]]

View File

@ -13,6 +13,8 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from signal_bot.quoting import Quote
@runtime_checkable
class Transport(Protocol):
@ -21,11 +23,13 @@ class Transport(Protocol):
def receive(self) -> AsyncIterator[str]:
"""Yield raw envelope frames until the transport is closed."""
async def send_text(self, text: str, recipient: str) -> None:
"""Send a plain-text message to a group id or phone number."""
async def send_text(self, text: str, recipient: str, quote: Quote | None = None) -> None:
"""Send a plain-text message, optionally quoting one message."""
async def send_image(self, image_base64: str, recipient: str) -> None:
"""Send a base64-encoded image attachment to a recipient."""
async def send_image(
self, image_base64: str, recipient: str, quote: Quote | None = None
) -> None:
"""Send a base64-encoded image attachment, optionally quoting one message."""
async def list_attachments(self) -> list[str]:
"""Return the ids of attachments stored server-side."""

View File

@ -14,6 +14,8 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from signal_bot.quoting import Quote
@dataclass(frozen=True, slots=True)
class SentMessage:
@ -22,6 +24,7 @@ class SentMessage:
recipient: str
text: str | None = None
image_base64: str | None = None
quote: Quote | None = None
@dataclass
@ -42,13 +45,15 @@ class FakeTransport:
for frame in list(self.frames):
yield frame
async def send_text(self, text: str, recipient: str) -> None:
async def send_text(self, text: str, recipient: str, quote: Quote | None = None) -> None:
"""Record a text send."""
self.sent.append(SentMessage(recipient=recipient, text=text))
self.sent.append(SentMessage(recipient=recipient, text=text, quote=quote))
async def send_image(self, image_base64: str, recipient: str) -> None:
async def send_image(
self, image_base64: str, recipient: str, quote: Quote | None = None
) -> None:
"""Record an image send."""
self.sent.append(SentMessage(recipient=recipient, image_base64=image_base64))
self.sent.append(SentMessage(recipient=recipient, image_base64=image_base64, quote=quote))
async def list_attachments(self) -> list[str]:
"""Return the configured attachment ids."""

View File

@ -20,6 +20,7 @@ if TYPE_CHECKING:
import httpx
from signal_bot.config import Config
from signal_bot.quoting import Quote
logger = logging.getLogger(__name__)
@ -112,26 +113,30 @@ class RestTransport:
msg = f"send failed with HTTP {response.status_code}: {response.text}"
raise TransportError(msg)
async def send_text(self, text: str, recipient: str) -> None:
"""Send a plain-text message to a group id or phone number."""
await self._post_send(
{
"message": text,
"number": self._config.phone_number,
"recipients": [recipient],
}
)
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] = {
"message": text,
"number": self._config.phone_number,
"recipients": [recipient],
}
if quote is not None:
payload.update(quote.payload())
await self._post_send(payload)
async def send_image(self, image_base64: str, recipient: str) -> None:
"""Send a base64-encoded image attachment to a recipient."""
await self._post_send(
{
"message": "",
"base64_attachments": [image_base64],
"number": self._config.phone_number,
"recipients": [recipient],
}
)
async def send_image(
self, image_base64: str, recipient: str, quote: Quote | None = None
) -> None:
"""Send a base64-encoded image attachment, optionally quoting one message."""
payload: dict[str, Any] = {
"message": "",
"base64_attachments": [image_base64],
"number": self._config.phone_number,
"recipients": [recipient],
}
if quote is not None:
payload.update(quote.payload())
await self._post_send(payload)
async def list_attachments(self) -> list[str]:
"""Return the ids of attachments stored server-side."""

View File

@ -28,7 +28,7 @@ PATCHES_PER_MINOR: Final = 8
# Which release this build is. Bump by one per release; everything else,
# including the version string in pyproject.toml, follows from it.
RELEASE_ORDINAL: Final = 2
RELEASE_ORDINAL: Final = 3
_POPES_FILE: Final = Path(__file__).with_name("popes.json")

57
tests/test_quoting.py Normal file
View File

@ -0,0 +1,57 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Replying to the message that asked, rather than to the room."""
from __future__ import annotations
from signal_bot.models import IncomingMessage, parse_envelope
from signal_bot.quoting import Quote, quote_of
from tests.conftest import envelope
def _message(
*,
source_uuid: str = "uuid-alice",
text: str | None = "!cat",
timestamp: int = 1719177917717,
) -> IncomingMessage:
return IncomingMessage(
source_uuid=source_uuid,
source_name="Alice",
group_id="group.one",
text=text,
has_sticker=False,
is_reaction=False,
timestamp=timestamp,
)
def test_payload_carries_the_three_keys_v2_send_wants() -> None:
assert Quote(17, "uuid-alice", "!cat").payload() == {
"quote_timestamp": 17,
"quote_author": "uuid-alice",
"quote_message": "!cat",
}
def test_a_real_envelope_can_be_quoted() -> None:
"""The timestamp used to be parsed away, which made this impossible."""
message = parse_envelope(envelope(text="!cat"))
assert message is not None
quote = quote_of(message)
assert quote == Quote(1719177917717, "uuid-alice", "!cat")
def test_a_message_with_no_timestamp_is_not_quoted() -> None:
"""Answering the room beats sending a payload the container rejects."""
assert quote_of(_message(timestamp=0)) is None
def test_a_message_with_no_author_is_not_quoted() -> None:
assert quote_of(_message(source_uuid="")) is None
def test_a_message_with_no_text_quotes_an_empty_body() -> None:
"""An image with no caption is still a message worth pointing at."""
quote = quote_of(_message(text=None))
assert quote is not None
assert quote.text == ""

View File

@ -17,6 +17,7 @@ from fake_signal.scenario import (
check,
describe,
load_scenario,
quotes,
)
from signal_bot.models import parse_envelope
@ -96,6 +97,24 @@ def test_describe_classifies_sends() -> None:
assert describe({})[1] == ""
def test_quotes_detects_a_pointed_reply() -> None:
assert quotes({"quote_timestamp": 1719177917717}) is True
assert quotes({"message": "hi"}) is False
def test_check_enforces_a_quoted_reply_in_both_directions() -> None:
"""Without this the e2e gate could not tell a quoted reply from a plain one."""
quoted = [{"base64_attachments": ["A"], "recipients": ["R=="], "quote_timestamp": 17}]
plain = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
wants_quote = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=True)])
wants_none = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=False)])
assert check(wants_quote, quoted, GROUPS) == []
assert check(wants_none, plain, GROUPS) == []
assert "quotes: expected True" in check(wants_quote, plain, GROUPS)[0]
assert "quotes: expected False" in check(wants_none, quoted, GROUPS)[0]
def test_check_passes_on_a_match() -> None:
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply")])
sent = [{"base64_attachments": ["A"], "recipients": ["R=="]}]

View File

@ -10,6 +10,7 @@ import httpx
import pytest
from websockets.exceptions import ConnectionClosedError
from signal_bot.quoting import Quote
from signal_bot.transport_rest import RestTransport, TransportError
if TYPE_CHECKING:
@ -81,6 +82,35 @@ async def test_send_text_posts_expected_payload(config: Config) -> None:
}
async def test_a_quoted_text_send_carries_the_quote_keys(config: Config) -> None:
"""This is what makes the bot answer a message rather than the room."""
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(__import__("json").loads(request.content))
return httpx.Response(201)
quote = Quote(1719177917717, "uuid-alice", "!cat")
await _transport(config, handler).send_text("hi", "group==", quote)
assert seen[0]["quote_timestamp"] == 1719177917717
assert seen[0]["quote_author"] == "uuid-alice"
assert seen[0]["quote_message"] == "!cat"
async def test_a_quoted_image_send_carries_the_quote_keys(config: Config) -> None:
seen: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(__import__("json").loads(request.content))
return httpx.Response(201)
quote = Quote(17, "uuid-bob", "!dog")
await _transport(config, handler).send_image("Zm9v", "group==", quote)
assert seen[0]["base64_attachments"] == ["Zm9v"]
assert seen[0]["quote_timestamp"] == 17
assert seen[0]["quote_author"] == "uuid-bob"
async def test_send_image_posts_base64_attachment(config: Config) -> None:
seen: list[dict] = []