signal-bot/fake_signal/state.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

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}")