mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:27:17 +01:00
Some checks are pending
CI / check (push) Waiting to run
DOCS-signal-envelopes.md exists because this wire format is not obvious and guessing at it has cost real bugs -- the sticker read off the wrong nesting level, and 0.0.3's !gekon shipping green on an assumption nobody had checked against the live API. The recurring cost is that answering "what does a frame with an attachment actually look like?" needed a code change, a restart, and somebody sending one at the right moment. So at DEBUG the dispatcher now logs each frame's *shape*: keys and value types, never values. Two fields keep their values because they are wire format rather than anything a person said -- a MIME type and a groupInfo type. A message body, a name, a number, a uuid, quoted text and a sticker id all come out as "str". An album collapses to one element and a count instead of printing the same dict twenty times. Verified against a live frame: the body, the quoted text, the uuid and the phone number do not survive, and contentType does. This also had to fix DEBUG being unusable. LOG_LEVEL=DEBUG set the root logger, which turns on httpx, httpcore and asyncio; the bot polls a group listing every 30 seconds, so the one interesting line arrived buried and journald started dropping. Libraries are now held at WARNING whatever the bot is set to -- at INFO httpx was already emitting an "HTTP Request: GET ... 200 OK" line every thirty seconds, forever. Measured on the running unit: 17 library lines per minute down to 2. Two bugs in that setup found by its own tests rather than by review: basicConfig is a documented no-op once the root logger has a handler, so the level silently did not apply under pytest; and the floor belongs at INFO too, not only below it. A self-send does not produce a capturable attachment envelope, by the way -- signal-cli emits no syncMessage for a send made over the same connection. Two probe images returned HTTP 201 and no frame came back, so the inbound shape still needs a human to post one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
79 lines
2.8 KiB
Python
79 lines
2.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Describing a frame's shape without logging what anybody wrote."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from signal_bot.envelope_shape import shape_of
|
|
from tests.conftest import WATCHED_GROUP, envelope
|
|
|
|
PRIVATE_BODY = "the actual message body"
|
|
|
|
|
|
def test_values_are_replaced_by_their_types() -> None:
|
|
"""The whole point: a log line that cannot leak a message."""
|
|
shape = shape_of(envelope(text=PRIVATE_BODY))
|
|
assert PRIVATE_BODY not in shape
|
|
assert "message: str" in shape
|
|
|
|
|
|
def test_names_numbers_and_uuids_do_not_survive() -> None:
|
|
shape = shape_of(envelope(text="hi", sender=("ffcae71a-dcd1", "kuhy")))
|
|
assert "ffcae71a" not in shape
|
|
assert "kuhy" not in shape
|
|
assert "+15555550123" not in shape
|
|
|
|
|
|
def test_quoted_text_does_not_survive() -> None:
|
|
shape = shape_of(envelope(text="hi", extra="quote"))
|
|
assert "..." not in shape
|
|
assert "quote: {author: str, id: int, text: str}" in shape
|
|
|
|
|
|
def test_the_group_id_is_a_type_but_its_kind_is_a_value() -> None:
|
|
"""DELIVER vs UPDATE is wire format; the id identifies a conversation."""
|
|
shape = shape_of(envelope(text="hi"))
|
|
assert WATCHED_GROUP not in shape
|
|
assert "groupId: str" in shape
|
|
assert "type: 'DELIVER'" in shape
|
|
|
|
|
|
def test_attachment_content_types_are_kept() -> None:
|
|
"""The one thing this exists to answer, so it must survive redaction."""
|
|
shape = shape_of(envelope(text=None, attachments=("image/jpeg", "video/mp4")))
|
|
assert "contentType: 'image/jpeg'" in shape
|
|
assert "x2]" in shape
|
|
|
|
|
|
def test_an_album_is_summarised_not_repeated() -> None:
|
|
shape = shape_of(envelope(text=None, attachments=("image/jpeg",) * 20))
|
|
assert "x20]" in shape
|
|
assert shape.count("contentType") == 1
|
|
|
|
|
|
def test_an_empty_list_is_shown_as_empty() -> None:
|
|
assert shape_of(json.dumps({"envelope": {"members": []}})) == "{envelope: {members: []}}"
|
|
|
|
|
|
def test_null_is_distinguished_from_absent() -> None:
|
|
"""A caption-less photo may send message: null rather than omitting it."""
|
|
assert "message: null" in shape_of(json.dumps({"envelope": {"message": None}}))
|
|
|
|
|
|
def test_numbers_and_booleans_show_their_type() -> None:
|
|
frame = json.dumps({"envelope": {"timestamp": 1, "urgent": True, "score": 1.5}})
|
|
assert shape_of(frame) == "{envelope: {score: float, timestamp: int, urgent: bool}}"
|
|
|
|
|
|
def test_an_unparseable_frame_says_so() -> None:
|
|
assert shape_of("not json") == "<unparseable>"
|
|
assert shape_of(b"\xff\xfe") == "<unparseable>"
|
|
|
|
|
|
def test_keys_are_sorted_so_two_frames_compare() -> None:
|
|
"""Same shape, different key order, identical description."""
|
|
first = json.dumps({"envelope": {"b": 1, "a": "x"}})
|
|
second = json.dumps({"envelope": {"a": "x", "b": 1}})
|
|
assert shape_of(first) == shape_of(second)
|