mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
Some checks are pending
CI / check (push) Waiting to run
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
198 lines
7.5 KiB
Python
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,
|
|
)
|