mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
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>
80 lines
2.7 KiB
Python
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."""
|