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

119 lines
4.1 KiB
Markdown

# 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.