# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The seam between the bot and Signal. Everything above this interface is transport-agnostic, which is what lets the same bot code run against the real signal-cli-rest-api container and against the local fake used by the tests and the end-to-end script. """ from __future__ import annotations 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): """What the bot needs from a Signal backend.""" def receive(self) -> AsyncIterator[str]: """Yield raw envelope frames until the transport is closed.""" 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, quote: Quote | None = None, caption: str = "", ) -> None: """Send an image attachment, optionally captioned and quoting a message. /v2/send carries the caption in the same payload as the attachment, so a captioned photo is one message in the group rather than two. """ async def send_media( self, payload_base64: str, recipient: str, quote: Quote | None = None, caption: str = "", media_type: str = "", filename: str = "", ) -> None: """Send an attachment of a stated type, optionally captioned. send_image is the case where the type can be left to signal-cli. Anything Signal renders differently -- a video, above all -- has to 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.""" async def fetch_attachment(self, attachment_id: str) -> bytes: """Download one stored attachment by id. The inbound half of the store list_attachments/delete_attachment already covered. !add_photo is the first caller: an envelope names an attachment but never carries its bytes. """ async def delete_attachment(self, attachment_id: str) -> None: """Delete one stored attachment by id."""