Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
!catfrom the bot's number into a group does nothing whatsoever — a human has to type it, andjournalctl --user -u signal-bot.serviceshowingrunning 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.