mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:50:46 +01:00
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
200 lines
7.7 KiB
Markdown
200 lines
7.7 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"
|
|
}
|
|
```
|
|
|
|
## 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:
|
|
|
|
```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.
|