mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
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:
parent
f92502d18b
commit
a202317072
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
5
fake_signal/scenarios/quoted_reply.yaml
Normal file
5
fake_signal/scenarios/quoted_reply.yaml
Normal 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 }
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
48
signal_bot/quoting.py
Normal 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 "")
|
||||
@ -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]]
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
57
tests/test_quoting.py
Normal 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 == ""
|
||||
@ -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=="]}]
|
||||
|
||||
@ -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] = []
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user