diff --git a/DOCS-changelog.md b/DOCS-changelog.md index 912aa60..48d3ed1 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -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 diff --git a/README.md b/README.md index fb938e5..7f32055 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/fake_signal/scenario.py b/fake_signal/scenario.py index 485ee43..6b9d75a 100644 --- a/fake_signal/scenario.py +++ b/fake_signal/scenario.py @@ -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 diff --git a/fake_signal/scenarios/quoted_reply.yaml b/fake_signal/scenarios/quoted_reply.yaml new file mode 100644 index 0000000..f173205 --- /dev/null +++ b/fake_signal/scenarios/quoted_reply.yaml @@ -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 } diff --git a/pyproject.toml b/pyproject.toml index 633b031..e19f0af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/signal_bot/animals.py b/signal_bot/animals.py index 6ced636..01e06d5 100644 --- a/signal_bot/animals.py +++ b/signal_bot/animals.py @@ -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 diff --git a/signal_bot/changelog.py b/signal_bot/changelog.py index 31a8640..ff94513 100644 --- a/signal_bot/changelog.py +++ b/signal_bot/changelog.py @@ -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)) diff --git a/signal_bot/models.py b/signal_bot/models.py index d10c77b..8d1a10c 100644 --- a/signal_bot/models.py +++ b/signal_bot/models.py @@ -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), ) diff --git a/signal_bot/quoting.py b/signal_bot/quoting.py new file mode 100644 index 0000000..1d31501 --- /dev/null +++ b/signal_bot/quoting.py @@ -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 "") diff --git a/signal_bot/registry.py b/signal_bot/registry.py index fc6b025..c1861da 100644 --- a/signal_bot/registry.py +++ b/signal_bot/registry.py @@ -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]] diff --git a/signal_bot/transport_base.py b/signal_bot/transport_base.py index 591920c..b1cd2f6 100644 --- a/signal_bot/transport_base.py +++ b/signal_bot/transport_base.py @@ -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.""" diff --git a/signal_bot/transport_fake.py b/signal_bot/transport_fake.py index a2d86e3..6fc4d47 100644 --- a/signal_bot/transport_fake.py +++ b/signal_bot/transport_fake.py @@ -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.""" diff --git a/signal_bot/transport_rest.py b/signal_bot/transport_rest.py index b9268dd..bc78eff 100644 --- a/signal_bot/transport_rest.py +++ b/signal_bot/transport_rest.py @@ -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.""" diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index a10965f..f0dc663 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -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") diff --git a/tests/test_quoting.py b/tests/test_quoting.py new file mode 100644 index 0000000..0d12881 --- /dev/null +++ b/tests/test_quoting.py @@ -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 == "" diff --git a/tests/test_scenario.py b/tests/test_scenario.py index 9621c1e..cb5b295 100644 --- a/tests/test_scenario.py +++ b/tests/test_scenario.py @@ -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=="]}] diff --git a/tests/test_transport_rest.py b/tests/test_transport_rest.py index c1bd821..0f88901 100644 --- a/tests/test_transport_rest.py +++ b/tests/test_transport_rest.py @@ -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] = []