mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
The leak this repository had was not carelessness with a secret. It was somebody reaching for a real name because it was the example in front of them -- a fixture built from a live database, an envelope pasted out of a running container, a help text written while looking at the actual group. Three different people-shaped mistakes, none of which a rule about "be careful" would have stopped. So it is a gate. The hard part is that the thing being checked must not contain the list of what to refuse, or the check *is* the leak. It reads the names at run time from outside: the photo pack's manifest, the message database, the local .env. It never prints the word it matched, because this output reaches CI logs. On a machine with none of that private data it says "shapes only" out loud rather than passing quietly, and CI enforces the half that needs nothing -- phone numbers, uuids and Signal group ids, compared against an allowlist of values invented for the fixtures. It runs on staged files, on the commit message -- where two of the leaks actually travelled -- and over the whole tree in CI. It paid for itself twice before it was wired up. The live test group's id was in tests/test_config.py in two spellings, and truncated in three more files, after four rounds of grepping by hand had pronounced the tree clean -- nobody thinks to grep for base64. Then it refused the commit that introduced it, because DOCS-privacy.md had used a real name as the example of a name. An allowlist line may be scoped to one file, which is how the given names that are also popes stay legal in popes.json and refused in every fixture that has no business carrying them. The threshold is three characters, because one of this group's given names is three characters long. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
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.
|