# 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` ```json { "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: ```json { "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. ```json { "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.