signal-bot/signal_bot/transport_base.py
Krzysztof kuhy Rudnicki e3c7bbfe23 !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>
2026-09-04 21:48:03 +02:00

80 lines
2.7 KiB
Python

# 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."""