signal-bot/signal_bot/models.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

198 lines
7.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Parsing of signal-cli-rest-api envelopes into a flat, typed message."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class IncomingMessage:
"""One inbound Signal message, normalised across envelope shapes.
signal-cli reports messages from other people under ``dataMessage`` and
the account's own outgoing messages under ``syncMessage.sentMessage``.
The two carry the same fields at different depths; everything downstream
works on this flattened form instead of re-deriving the nesting.
"""
source_uuid: str
source_name: str
group_id: str | None
text: str | None
has_sticker: bool
is_reaction: bool
# The sender's +E.164 number. "source" carries it too, but only
# sometimes: live envelopes have shown a display name there and the
# number here, so this is the field a trust decision may rely on.
source_number: str = ""
# A group membership change rather than a message: this is the only
# frame that names who added the bot to a group.
is_group_update: bool = False
# The sent timestamp, which is the id Signal quotes a message by.
# Without it a reply can only be addressed to the room.
timestamp: int = 0
# contentType of each attachment, in the order Signal listed them.
# Empty for a text-only message.
attachment_types: tuple[str, ...] = ()
# The server-side id of each attachment. A parallel tuple
# rather than a richer attachment_types, so counting.media_kind,
# counting.should_count and stats.recorder keep reading exactly what they
# always read. !add_photo is the only thing that needs the id: it is what
# GET /v1/attachments/{id} fetches and what DELETE removes afterwards.
attachment_ids: tuple[str, ...] = ()
# True when this message quotes another one.
is_reply: bool = False
# True when the bot's own account sent this. Taken from *which* envelope
# branch carried the payload, not from comparing identities: the account
# is spelled as a uuid on some frames and a +E.164 number on others, so
# any comparison misses one of them. syncMessage.sentMessage means "this
# account sent it", exactly, on every frame shape.
is_own: bool = False
@property
def is_direct_message(self) -> bool:
"""True when the message did not arrive via a group.
A linked device receives every direct conversation on the account, so
this is the discriminator that keeps the bot scoped to its group.
"""
return self.group_id is None
def _timestamp(payload: dict[str, Any], envelope: dict[str, Any]) -> int:
"""The sent timestamp, preferring the payload's own over the envelope's.
They agree on every frame seen so far, but a quote must point at the
message, so the message's own value wins where they ever differ.
"""
for source in (payload, envelope):
value = source.get("timestamp")
if isinstance(value, int):
return value
return 0
def _payload(envelope: dict[str, Any]) -> tuple[dict[str, Any], bool] | None:
"""Return the message body and whether the bot's own account sent it.
signal-cli reports other people under ``dataMessage`` and this account's
own outgoing messages under ``syncMessage.sentMessage``. Which branch
matched is the only reliable "did we send this" signal, so it is returned
rather than re-derived downstream.
"""
data = envelope.get("dataMessage")
if isinstance(data, dict):
return data, False
sync = envelope.get("syncMessage")
if isinstance(sync, dict):
sent = sync.get("sentMessage")
if isinstance(sent, dict):
return sent, True
return None
def _attachment_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""The attachment objects on this payload, read off the top level only.
Never out of ``quote``: a reply carries the *quoted* message's media in
``quote.attachments``, so a search for "attachments" anywhere in the
payload would let "!add_photo" steal a photo somebody else posted.
"""
items = payload.get("attachments")
if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
def _attachments(payload: dict[str, Any]) -> tuple[str, ...]:
"""Return each attachment's contentType, in the order Signal listed them."""
return tuple(str(item.get("contentType", "")) for item in _attachment_items(payload))
def _attachment_ids(payload: dict[str, Any]) -> tuple[str, ...]:
"""Return the server-side id of every attachment that has one.
An attachment with no id is dropped rather than carried as "": the id is
only ever used to fetch bytes, and a blank one is a request that cannot
succeed. That means this tuple can be shorter than attachment_types, so
the two are not safe to zip -- nothing does, and nothing should.
"""
return tuple(str(item["id"]) for item in _attachment_items(payload) if str(item.get("id", "")))
def _group_id(payload: dict[str, Any]) -> str | None:
"""Return the group id, or None for a direct message."""
info = payload.get("groupInfo")
if not isinstance(info, dict):
return None
group_id = info.get("groupId")
if not isinstance(group_id, str) or not group_id:
return None
return group_id
def _group_type(payload: dict[str, Any]) -> str:
"""Return the groupInfo type ("DELIVER", "UPDATE", ...), or ""."""
info = payload.get("groupInfo")
if not isinstance(info, dict):
return ""
group_type = info.get("type")
return group_type if isinstance(group_type, str) else ""
def _text(payload: dict[str, Any]) -> str | None:
"""Return the message body, or None when there is no text."""
message = payload.get("message")
if not isinstance(message, str):
return None
return message
def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
"""Parse one raw websocket frame.
Args:
raw: The JSON frame exactly as received.
Returns:
The normalised message, or None when the frame carries no message
body at all (receipts, typing indicators, malformed JSON).
"""
try:
decoded = json.loads(raw)
except json.JSONDecodeError, UnicodeDecodeError:
return None
if not isinstance(decoded, dict):
return None
envelope = decoded.get("envelope")
if not isinstance(envelope, dict):
return None
resolved = _payload(envelope)
if resolved is None:
return None
payload, is_own = resolved
return IncomingMessage(
source_uuid=str(envelope.get("sourceUuid", "")),
source_name=str(envelope.get("sourceName", "")),
group_id=_group_id(payload),
text=_text(payload),
# The original bot looked for the sticker under dataMessage even when
# the envelope was a syncMessage, so its own stickers were never
# filtered. Reading it off the resolved payload fixes both shapes.
has_sticker=isinstance(payload.get("sticker"), dict),
is_reaction=isinstance(payload.get("reaction"), dict),
source_number=str(envelope.get("sourceNumber", "")),
is_group_update=_group_type(payload) == "UPDATE",
timestamp=_timestamp(payload, envelope),
attachment_types=_attachments(payload),
attachment_ids=_attachment_ids(payload),
is_reply=isinstance(payload.get("quote"), dict),
is_own=is_own,
)