signal-bot/DOCS-signal-envelopes.md
Krzysztof kuhy Rudnicki e102e413ff docs: record the traps this session hit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:44:28 +02:00

4.1 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"
}

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.