signal-bot/DOCS-signal-envelopes.md
Krzysztof kuhy Rudnicki 8df2646377 feat: weekly, monthly and year-in-review posts, keyed off the day reported
A Sunday closes a week, a month's last day closes the month, and
31 December closes the year. Keyed off the day being *reported* rather
than off today, which is the only reading that stays right when the bot
has been offline: catching up on a week-old Sunday now also posts that
week's summary, and there is a test asserting the five messages a
three-day outage produces.

One scheduler, a date predicate. Three background tasks firing at the
same wall-clock time into the same group is three independent ways for
an evening to go wrong, for no gain over an if-statement. A failed
period summary is deliberately best-effort and runs after the day has
been marked, so it cannot make the bot re-post the daily one tomorrow.

Also: the quote frame arrived while this was being written, and it
carries a trap. `quote` has its own nested `attachments` array holding
the *quoted* message's media -- so a text reply to somebody's photo has
attachments in the payload that belong to somebody else. Reading them
would credit the replier with a photo they did not post, in a group
where people reply to photos constantly, inflating one number without
ever looking wrong.

The parser already read the top level only and is correct. It is now
pinned by a test built from the exact captured frame, and written up in
DOCS-signal-envelopes.md, because "it happens to be right" and "it
cannot silently stop being right" are different things. This is the same
wrong-nesting-level mistake that once made the bot ignore its own
stickers.

test_bot.py went past the 250-line cap on the way, so the report and
catch-up tests move into files named for what they cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:56:25 +02:00

7.7 KiB

Signal envelope shapes

Reference for the two JSON frames signal-cli-rest-api pushes down ws://…/v1/receive/{number}. signal_bot/models.py flattens both into one IncomingMessage; these are the shapes it flattens.

From another account: dataMessage

{
  "envelope": {
    "source": "NAME",
    "sourceNumber": "PHONE_NUMBER",
    "sourceUuid": "SOURCE_ID",
    "sourceName": "USER_DEFINED_NAME",
    "sourceDevice": 69,
    "timestamp": 1719177917717,
    "dataMessage": {
      "timestamp": 1719177917717,
      "message": "MESSAGE_CONTENT",
      "expiresInSeconds": 0,
      "viewOnce": false,
      "sticker": { "packId": "166dd1b5…", "stickerId": 6 },
      "groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
    }
  },
  "account": "BOT_ACCOUNT_PHONE_NUMBER"
}

A message with an attachment

Captured from the live API on 2026-08-30 with LOG_LEVEL=DEBUG, which makes the dispatcher log each frame's shape (keys and types, never values -- see signal_bot/envelope_shape.py). This is a caption-less PNG posted in the real group, reproduced exactly as the logger described it:

{account: str, envelope: {
  dataMessage: {
    attachments: [{caption: null, contentType: 'image/png', filename: null,
                   height: int, id: str, size: int, uploadTimestamp: int,
                   width: int} x1],
    expiresInSeconds: int,
    groupInfo: {groupId: str, groupName: str, revision: int, type: 'DELIVER'},
    isExpirationUpdate: bool,
    message: null,
    timestamp: int,
    viewOnce: bool},
  serverDeliveredTimestamp: int, serverReceivedTimestamp: int,
  source: str, sourceDevice: int, sourceName: str,
  sourceNumber: str, sourceUuid: str, timestamp: int}}

Three things this settles, none of which were guessable:

  • attachments is a list of objects and the type lives in contentType. That is what models._attachments reads and what counting.media_kind maps.
  • A caption-less photo sends message: null, not "" and not an absent key. Before 0.1.0 should_count ended in bool(message.text), so every one of these was silently worth nothing.
  • Read them off the resolved payload from _payload(), never off envelope["dataMessage"] -- see "The trap this caused" below.

Fields nothing reads yet, recorded so the next person does not have to capture them again: expiresInSeconds, isExpirationUpdate, viewOnce, groupInfo.groupName, groupInfo.revision, and the attachment's caption, filename, width, height, size and uploadTimestamp.

Open question, deliberately not assumed. caption is a field on the attachment, and it was null here because this photo had none. Whether a photo with a caption puts the text in message or in attachments[].caption has not been observed yet. char_len currently measures message only, so if it turns out to be the latter, captioned photos are recording a length of 0. Leave LOG_LEVEL=DEBUG on and watch for caption: str to settle it.

A reply carries quote -- with its own attachments

Also captured live, on 2026-08-30. This is a text reply to somebody else's photo:

dataMessage: {
  expiresInSeconds: int, isExpirationUpdate: bool, viewOnce: bool,
  groupInfo: {groupId: str, groupName: str, revision: int, type: 'DELIVER'},
  message: str,
  quote: {
    attachments: [{contentType: 'image/jpeg', filename: str,
                   thumbnail: {caption: null, contentType: 'image/jpeg',
                               filename: str, height: int, id: str, size: int,
                               uploadTimestamp: int, width: int}} x1],
    author: str, authorNumber: str, authorUuid: str, id: int, text: str},
  timestamp: int}

quote has its own attachments array, holding the quoted message's media. There is no top-level attachments here at all, because the replier sent only text.

So anything looking for attachments must read them off the top level of the resolved payload and nowhere else. Searching the payload for an attachments key would credit the replier with a photo somebody else posted -- and in a group where people reply to photos constantly, that inflates one number without ever looking wrong. It is the same wrong-nesting-level mistake that once made the bot ignore its own stickers.

quote.text is the quoted message's body, and is not read for the same reason: it is somebody else's message, already counted when they sent it. tests/test_models.py::test_a_quoted_photo_is_not_the_replier_s_photo pins this against the exact frame above.

Somebody changed the membership: groupInfo.type is UPDATE

The only frame that names who added the bot to a group, which is what signal_bot/trust.py needs. It carries no message at all, so it counts toward nothing and answers nothing; source/sourceNumber is the person who made the change. Captured live on 2026-08-29:

{
  "envelope": {
    "source": "+48123456789",
    "sourceNumber": "+48123456789",
    "sourceUuid": "b1c2d3e4-…",
    "sourceName": "kuhy",
    "sourceDevice": 1,
    "timestamp": 1788006851503,
    "dataMessage": {
      "timestamp": 1788006851503,
      "message": null,
      "groupInfo": { "groupId": "Y29uZmlnLWdy/…", "groupName": "testbot", "type": "UPDATE" }
    }
  },
  "account": "+420111222333"
}

Note source here is a phone number, not the display name this document used to show for dataMessage. Both spellings have been observed, which is why models.py reads the sender's number from sourceNumber and nothing trusts source.

From the bot's own account: syncMessage.sentMessage

When the account is deployed as a linked device, its own outgoing messages come back as sync messages. Same fields, one level deeper.

This deployment never receives them. The bot registers its own number as its own primary device and has no linked devices, so Signal sends it no copy of its own traffic. The consequence to know before you try to test anything: the bot cannot self-trigger a command. Posting !cat from the bot's number into a group does nothing whatsoever — a human has to type it, and journalctl --user -u signal-bot.service showing running command … is how you confirm it landed. The parsing below stays because the linked-device deployment is still supported, not because it fires here.

{
  "envelope": {
    "sourceUuid": "SOURCE_ID",
    "sourceName": "USER_DEFINED_NAME",
    "timestamp": 1719177728639,
    "syncMessage": {
      "sentMessage": {
        "destination": "DEST_NAME",
        "destinationNumber": "DEST_PHONE_NUMBER",
        "timestamp": 1719177728639,
        "message": "MESSAGE_CONTENT",
        "sticker": { "packId": "166dd1b5…", "stickerId": 6 },
        "groupInfo": { "groupId": "ID_OF_GROUP", "type": "DELIVER" }
      }
    }
  },
  "account": "BOT_ACCOUNT_PHONE_NUMBER"
}

The trap this caused

The original bot read envelope.dataMessage.sticker even when the frame was a syncMessage. Its own stickers therefore never matched the sticker filter and were counted as messages. Four consecutive commits (f1931b8, b325126, 62352e5, d35aa59) fought the symptom.

models.py resolves the payload first — dataMessage or syncMessage.sentMessage — and reads sticker, reaction, message and groupInfo off whichever it found. tests/test_models.py asserts both shapes.

No groupInfo means a direct message

A frame without groupInfo arrived in a one-to-one conversation. A linked device would receive every one of those on the account, so counting.watched_group rejects them explicitly rather than relying on a falsy comparison. See the scoping scenario in fake_signal/scenarios/.

Frames the bot ignores

Receipts, typing indicators and anything else with no dataMessage and no syncMessage.sentMessage parse to None and are dropped.