mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +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>
64 lines
2.6 KiB
Python
64 lines
2.6 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Shared mutable state for the fake server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import WebSocket
|
|
|
|
# A 1x1 PNG, inline so no binary file enters the repo.
|
|
PIXEL_PNG: Final = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQ"
|
|
"GAhKmMIQAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class State:
|
|
"""Everything the fake remembers between requests."""
|
|
|
|
sockets: list[WebSocket] = field(default_factory=list)
|
|
sent: list[dict[str, Any]] = field(default_factory=list)
|
|
attachments: list[str] = field(default_factory=list)
|
|
# Inbound attachment bytes by id, which the real container keeps in
|
|
# its own store. Without these the fake can name an attachment on an
|
|
# envelope but never hand it over, so !add_photo could not be proven
|
|
# offline at all. Deliberately not cleared by reset(): a scenario
|
|
# uploads its fixture before the run, not between injections.
|
|
stored: dict[str, bytes] = field(default_factory=dict)
|
|
mode: str = "json-rpc"
|
|
base_url: str = "http://127.0.0.1:8099"
|
|
# Registration and group state, so the registration runbook can be
|
|
# rehearsed offline instead of first executing against real Signal.
|
|
accounts: list[str] = field(default_factory=list)
|
|
pending: dict[str, str] = field(default_factory=dict)
|
|
groups: list[dict[str, Any]] = field(default_factory=list)
|
|
# Whether signal-cli can reach Signal. False reproduces the 2026-08-29
|
|
# outage: local state still answers, anything needing a round trip 400s.
|
|
connection_alive: bool = True
|
|
|
|
def reset(self) -> None:
|
|
"""Forget captured sends and attachments."""
|
|
self.sent.clear()
|
|
self.attachments.clear()
|
|
|
|
def record_reaction(self, number: str, payload: dict[str, Any]) -> None:
|
|
"""Capture one reaction in the same list as the sends.
|
|
|
|
One list, because a scenario's ``expect`` is the bot's outbound
|
|
traffic in order, and "it reacted instead of replying" is exactly
|
|
the kind of claim that ordering has to be able to make.
|
|
"""
|
|
self.sent.append({"number": number, **payload})
|
|
|
|
def record_send(self, payload: dict[str, Any]) -> None:
|
|
"""Capture one outgoing message and mint ids for its attachments."""
|
|
self.sent.append(payload)
|
|
attachments = payload.get("base64_attachments") or []
|
|
for index in range(len(attachments)):
|
|
self.attachments.append(f"attach-{len(self.sent)}-{index}")
|