signal-bot/fake_signal/scenario.py
Krzysztof kuhy Rudnicki aefffb2fd9
Some checks are pending
CI / check (push) Waiting to run
feat: add a photo to the album from the chat, tagged, stripped and verified
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own
groups and it lands in the pack tagged with both, retrievable through !alfa
and the new !losowe immediately -- no restart, and no tagger session on the PC.
A label matching nobody becomes a custom tag, and !losowe is the only door to
one, which is what keeps a live swap a *data* swap: it can never register a
trigger or widen a command's groups.

No typed label travels. Labels become pseudonym keys in a new registry
arg_resolver, at the dispatcher, before the line that logs the command name --
so no handler, journal entry, command_uses row or reply body ever holds a word
somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1".

Stripping is exiftool with a *positive* check on the output, never an exit
code. ffmpeg was tried first and fails silently on photos: given a JPEG with a
real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0.
The surviving-tag allowlist is per tag *name* rather than per group, because
measurement showed a group-level "must be empty" rule rejects every format
except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime
tags that are the container itself. Codec configuration (CompressorName,
VendorID, the HEVC parameter set) is on the list by decision: CompressorName
lives in the avc1 sample description and exiftool cannot write it at all, so
the alternative was refusing every video and every iPhone HEIC. Location and
identity tags are still refused, and the e2e run re-reads the stored files to
prove a QuickTime geotag did not survive.

.webm and .svg stay on the format allowlist and are stored *unstripped*:
exiftool 13.55 can write neither, and its SVG reader does not even surface
<metadata>. That is a deliberate, documented hole, not an oversight.

Two things this had to defuse along the way:
- personal_data_sources now reads custom.json, so "!add_photo kot <photo>"
  would have outlawed the word "kot" and refused every future commit, fixable
  only by hand-editing an album the committer cannot see. Minting now refuses
  a word that is already a command, and the gate subtracts the repo's own
  alias vocabulary from custom labels only -- curated people.json names are
  never excused.
- signal_bot/external.py is now the single subprocess site in the repository,
  so ruff's S603 needs exactly one scoped per-file-ignore instead of one per
  module that shells out. A test asserts nothing else imports subprocess.

A failed add changes nothing: media and the three manifests are written, the
album is reloaded, and only a load that succeeds is published; a load that
fails rolls all four back and keeps serving the pack that was already working,
without raising -- Restart=always would turn a ConfigError into a loop.

977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh
gains 8 scenarios driven against the real exiftool on media generated at run
time, and asserts the invariants a reply cannot: that a refused upload is not
stored, that a message with no attachment leaves custom.json byte-identical,
and that signal-cli's own copy of every ingested attachment is gone.

Closes TODO-add-photo.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
2026-08-31 11:20:00 +02:00

208 lines
7.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Scenario files: what to inject, and what the bot must do in response."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final
import yaml
if TYPE_CHECKING:
from pathlib import Path
class ScenarioError(ValueError):
"""Raised when a scenario file is malformed."""
@dataclass(frozen=True, slots=True)
class Injection:
"""One inbound message to push down the websocket."""
text: str | None = None
uuid: str = "uuid-alice"
name: str = "Alice"
group: str | None = "watched"
sticker: bool = False
reaction: bool = False
sync: bool = False
# The id of an attachment the scenario put in the store beforehand, via
# POST /_control/attachment. Named rather than inlined: the bytes are
# generated at run time by scripts/e2e.sh, because a fixture photo in
# this repository is exactly what the binary gate exists to prevent.
attachment: str | None = None
attachment_type: str = "image/jpeg"
def to_frame(self, groups: dict[str, str]) -> str:
"""Render this injection as a raw signal-cli envelope."""
payload: dict[str, Any] = {"timestamp": 1719177917717}
if self.text is not None:
payload["message"] = self.text
if self.group is not None:
if self.group not in groups:
msg = f"unknown group alias {self.group!r}; known: {sorted(groups)}"
raise ScenarioError(msg)
payload["groupInfo"] = {"groupId": groups[self.group], "type": "DELIVER"}
if self.attachment is not None:
# Top level, never inside "quote": that is where a *reply* carries
# the quoted message's media, and !add_photo must never take a
# photo somebody else posted.
payload["attachments"] = [{"contentType": self.attachment_type, "id": self.attachment}]
if self.sticker:
payload["sticker"] = {"packId": "abc", "stickerId": 6}
if self.reaction:
payload["reaction"] = {"emoji": "\U0001f44d", "targetAuthor": "x"}
body: dict[str, Any] = {"sourceUuid": self.uuid, "sourceName": self.name}
if self.sync:
body["syncMessage"] = {"sentMessage": payload}
else:
body["dataMessage"] = payload
return json.dumps({"envelope": body, "account": "+15550000000"})
@dataclass(frozen=True, slots=True)
class GroupArrival:
"""A group that appears on the account partway through a scenario.
This is what makes "somebody added the bot to a group" expressible
offline. ``id`` and ``internal_id`` are scenario alias names, resolved
through the same table the injections use, so a scenario never contains
a real group id.
"""
id: str
internal_id: str
name: str = "added-later"
member: bool = True
joinable: bool = True
def to_entry(self, groups: dict[str, str]) -> dict[str, Any]:
"""Render this as a /v1/groups row.
Args:
groups: Scenario alias to real group id.
Returns:
The listing row the fake server should start returning.
Raises:
ScenarioError: An alias is not in the table.
"""
for alias in (self.id, self.internal_id):
if alias not in groups:
msg = f"unknown group alias {alias!r}; known: {sorted(groups)}"
raise ScenarioError(msg)
return {
"id": groups[self.id],
"internal_id": groups[self.internal_id],
"name": self.name,
"member": self.member,
"joinable": self.joinable,
}
@dataclass(frozen=True, slots=True)
class Expectation:
"""One message the bot is expected to have sent."""
kind: str = "text"
to: str = "reply"
contains: str | None = None
# True demands the send quote the message that triggered it; False demands
# it not. None (the default) leaves existing scenarios indifferent.
quotes: bool | None = None
@dataclass(frozen=True, slots=True)
class Scenario:
"""A named list of injections and the sends they must produce."""
name: str
arrive: list[GroupArrival] = field(default_factory=list)
inject: list[Injection] = field(default_factory=list)
expect: list[Expectation] = field(default_factory=list)
def _build[T](cls: type[T], entry: object, what: str) -> T:
"""Instantiate a scenario dataclass, rejecting unknown keys."""
if not isinstance(entry, dict):
msg = f"each {what} entry must be a mapping, got {type(entry).__name__}"
raise ScenarioError(msg)
allowed = set(cls.__annotations__)
unknown = set(entry) - allowed
if unknown:
msg = f"unknown {what} keys {sorted(unknown)}; allowed: {sorted(allowed)}"
raise ScenarioError(msg)
return cls(**entry)
def load_scenario(path: Path) -> Scenario:
"""Load and validate one scenario file.
Raises:
ScenarioError: The file is not a mapping or carries unknown keys.
"""
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
msg = f"{path} must contain a mapping"
raise ScenarioError(msg)
if "name" not in document:
msg = f"{path} is missing a name"
raise ScenarioError(msg)
return Scenario(
name=str(document["name"]),
arrive=[_build(GroupArrival, e, "arrive") for e in document.get("arrive", [])],
inject=[_build(Injection, e, "inject") for e in document.get("inject", [])],
expect=[_build(Expectation, e, "expect") for e in document.get("expect", [])],
)
# How signal-cli-rest-api spells an attachment whose type the bot stated.
# Anything else is bare base64, which is what an ordinary photo travels as.
_VIDEO_PREFIX: Final = "data:video/"
def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
"""Reduce a captured send to (kind, recipient, body) for comparison.
"video" is its own kind so a scenario can assert that a clip went out as
a clip: told it is a photo, Signal renders an unplayable file, and every
other check in this harness would still pass.
"""
attachments = payload.get("base64_attachments") or []
kind = "text"
if attachments:
kind = "video" if str(attachments[0]).startswith(_VIDEO_PREFIX) else "image"
recipients = payload.get("recipients") or [""]
return kind, str(recipients[0]), str(payload.get("message", ""))
def quotes(payload: dict[str, Any]) -> bool:
"""True when a captured send points at a specific message."""
return bool(payload.get("quote_timestamp"))
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:
"""Compare captured sends against expectations, returning failure lines."""
failures: list[str] = []
if len(sent) != len(scenario.expect):
failures.append(f"expected {len(scenario.expect)} send(s), captured {len(sent)}")
for index, expected in enumerate(scenario.expect):
if index >= len(sent):
failures.append(f"[{index}] missing: expected a {expected.kind}")
continue
kind, recipient, body = describe(sent[index])
want_to = groups.get(expected.to, expected.to)
if kind != expected.kind:
failures.append(f"[{index}] kind: expected {expected.kind}, got {kind}")
if recipient != want_to:
failures.append(f"[{index}] recipient: expected {want_to}, got {recipient}")
if expected.contains is not None and expected.contains not in body:
failures.append(f"[{index}] body: {expected.contains!r} not in {body!r}")
if expected.quotes is not None and quotes(sent[index]) != expected.quotes:
failures.append(f"[{index}] quotes: expected {expected.quotes}")
return failures