mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
A "!cat" in a busy group produced a cat picture floating loose in the chat with
nothing tying it to whoever asked. Replies now quote the triggering message.
The container advertises this and always could: /v1/about reports
capabilities {"v2/send": ["quotes", "mentions"]}, and /v2/send takes
quote_timestamp, quote_author and quote_message. What was missing was on this
side -- parse_envelope discarded the envelope's timestamp, which is the id
Signal quotes a message by, so nothing downstream could have built a quote even
in principle. IncomingMessage now carries it, preferring the payload's own
value over the envelope's for the case where they ever disagree.
Quote is a value object rather than three more parameters threaded through
every send: the Transport protocol has five methods and two of them send.
CommandContext grows reply_image() so quoting lives in one place instead of
each handler reaching for ctx.transport itself, which is what animals.py did.
quote_of() returns None when the timestamp or author is missing. Answering the
room is a worse reply than a quoted one but a much better outcome than a
payload the container rejects.
The e2e gate learned to tell the two apart: Expectation.quotes demands a send
either does or does not point at a message, defaulting to None so the ten
existing scenarios stay indifferent. Without it the fake would have accepted an
unquoted reply as a pass.
Verified in the testbot group on a real Signal client before this bump, per
kuhy's rule that a bump is a publish rather than a checkpoint: he sent !cat and
confirmed the picture renders attached to his message as a reply.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
180 lines
6.2 KiB
Python
180 lines
6.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Scenario loading, rendering and adjudication."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fake_signal.scenario import (
|
|
Expectation,
|
|
GroupArrival,
|
|
Injection,
|
|
Scenario,
|
|
ScenarioError,
|
|
check,
|
|
describe,
|
|
load_scenario,
|
|
quotes,
|
|
)
|
|
from signal_bot.models import parse_envelope
|
|
|
|
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
|
|
|
|
|
|
def _write(tmp_path: Path, document: str) -> Path:
|
|
path = tmp_path / "s.yaml"
|
|
path.write_text(document, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_ships_scenarios_load_and_are_valid() -> None:
|
|
paths = sorted(Path("fake_signal/scenarios").glob("*.yaml"))
|
|
assert paths
|
|
for path in paths:
|
|
assert load_scenario(path).name
|
|
|
|
|
|
def test_injection_renders_a_parseable_group_envelope() -> None:
|
|
frame = Injection(text="!cat").to_frame(GROUPS)
|
|
message = parse_envelope(frame)
|
|
assert message is not None
|
|
assert message.text == "!cat"
|
|
assert message.group_id == "W=="
|
|
|
|
|
|
def test_injection_renders_a_direct_message() -> None:
|
|
message = parse_envelope(Injection(text="hi", group=None).to_frame(GROUPS))
|
|
assert message is not None
|
|
assert message.is_direct_message
|
|
|
|
|
|
def test_injection_renders_sync_sticker_and_reaction() -> None:
|
|
sticker = parse_envelope(Injection(sticker=True, sync=True).to_frame(GROUPS))
|
|
reaction = parse_envelope(Injection(reaction=True).to_frame(GROUPS))
|
|
assert sticker is not None
|
|
assert sticker.has_sticker
|
|
assert reaction is not None
|
|
assert reaction.is_reaction
|
|
|
|
|
|
def test_unknown_group_alias_is_rejected() -> None:
|
|
with pytest.raises(ScenarioError, match="unknown group alias"):
|
|
Injection(group="nope").to_frame(GROUPS)
|
|
|
|
|
|
def test_loads_a_full_scenario(tmp_path: Path) -> None:
|
|
path = _write(
|
|
tmp_path,
|
|
"name: demo\ninject:\n - {text: '!cat'}\nexpect:\n - {kind: image}\n",
|
|
)
|
|
scenario = load_scenario(path)
|
|
assert scenario.name == "demo"
|
|
assert scenario.inject[0].text == "!cat"
|
|
assert scenario.expect[0].kind == "image"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("document", "match"),
|
|
[
|
|
("- a list\n", "must contain a mapping"),
|
|
("inject: []\n", "missing a name"),
|
|
("name: x\ninject:\n - {bogus: 1}\n", "unknown inject keys"),
|
|
("name: x\nexpect:\n - {bogus: 1}\n", "unknown expect keys"),
|
|
("name: x\ninject:\n - not-a-mapping\n", "must be a mapping"),
|
|
],
|
|
)
|
|
def test_malformed_scenarios_are_rejected(tmp_path: Path, document: str, match: str) -> None:
|
|
with pytest.raises(ScenarioError, match=match):
|
|
load_scenario(_write(tmp_path, document))
|
|
|
|
|
|
def test_describe_classifies_sends() -> None:
|
|
assert describe({"message": "hi", "recipients": ["R=="]}) == ("text", "R==", "hi")
|
|
assert describe({"base64_attachments": ["A"], "recipients": ["R=="]})[0] == "image"
|
|
assert describe({})[1] == ""
|
|
|
|
|
|
def test_quotes_detects_a_pointed_reply() -> None:
|
|
assert quotes({"quote_timestamp": 1719177917717}) is True
|
|
assert quotes({"message": "hi"}) is False
|
|
|
|
|
|
def test_check_enforces_a_quoted_reply_in_both_directions() -> None:
|
|
"""Without this the e2e gate could not tell a quoted reply from a plain one."""
|
|
quoted = [{"base64_attachments": ["A"], "recipients": ["R=="], "quote_timestamp": 17}]
|
|
plain = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
|
|
wants_quote = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=True)])
|
|
wants_none = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=False)])
|
|
|
|
assert check(wants_quote, quoted, GROUPS) == []
|
|
assert check(wants_none, plain, GROUPS) == []
|
|
assert "quotes: expected True" in check(wants_quote, plain, GROUPS)[0]
|
|
assert "quotes: expected False" in check(wants_none, quoted, GROUPS)[0]
|
|
|
|
|
|
def test_check_passes_on_a_match() -> None:
|
|
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply")])
|
|
sent = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
|
|
assert check(scenario, sent, GROUPS) == []
|
|
|
|
|
|
def test_check_reports_a_count_mismatch() -> None:
|
|
scenario = Scenario("s", expect=[Expectation()])
|
|
failures = check(scenario, [], GROUPS)
|
|
assert "expected 1 send(s), captured 0" in failures[0]
|
|
assert any("missing" in line for line in failures)
|
|
|
|
|
|
def test_check_reports_kind_recipient_and_body() -> None:
|
|
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply", contains="nope")])
|
|
sent = [{"message": "hello", "recipients": ["W=="]}]
|
|
failures = check(scenario, sent, GROUPS)
|
|
assert any("kind:" in f for f in failures)
|
|
assert any("recipient:" in f for f in failures)
|
|
assert any("body:" in f for f in failures)
|
|
|
|
|
|
def test_check_accepts_a_literal_recipient() -> None:
|
|
scenario = Scenario("s", expect=[Expectation(to="literal==")])
|
|
assert check(scenario, [{"message": "", "recipients": ["literal=="]}], GROUPS) == []
|
|
|
|
|
|
def test_unexpected_sends_are_a_failure() -> None:
|
|
"""A scoping regression shows up here: expect [] and the bot replied."""
|
|
failures = check(Scenario("s"), [{"message": "leak", "recipients": ["R=="]}], GROUPS)
|
|
assert "expected 0 send(s), captured 1" in failures[0]
|
|
|
|
|
|
def test_frame_is_json() -> None:
|
|
assert json.loads(Injection().to_frame(GROUPS))["account"] == "+15550000000"
|
|
|
|
|
|
def test_group_arrival_renders_a_listing_row() -> None:
|
|
"""A scenario names aliases; the fake needs both real spellings."""
|
|
entry = GroupArrival(id="reply", internal_id="watched", name="added").to_entry(GROUPS)
|
|
assert entry == {
|
|
"id": GROUPS["reply"],
|
|
"internal_id": GROUPS["watched"],
|
|
"name": "added",
|
|
"member": True,
|
|
"joinable": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["id", "internal_id"])
|
|
def test_group_arrival_rejects_an_unknown_alias(field: str) -> None:
|
|
arrival = GroupArrival(**{"id": "reply", "internal_id": "watched", field: "nope"})
|
|
with pytest.raises(ScenarioError, match="unknown group alias"):
|
|
arrival.to_entry(GROUPS)
|
|
|
|
|
|
def test_arrivals_are_parsed_from_a_file(tmp_path: Path) -> None:
|
|
path = tmp_path / "s.yaml"
|
|
path.write_text(
|
|
"name: demo\narrive:\n - {id: reply, internal_id: watched}\n", encoding="utf-8"
|
|
)
|
|
assert load_scenario(path).arrive[0].internal_id == "watched"
|