diff --git a/.gitignore b/.gitignore index cd0cf62..6063b50 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,12 @@ cython_debug/ *.jpg *.gif nohup.out + +# The message database is private and never leaves this machine. A live +# SQLite database is three files, so all three are named. +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm + +# Wine/Bottles scratch dir, never part of the bot. +bottles/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 976358d..ae547ee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,16 @@ repos: language: pygrep types: [python] + # This repository is public and holds a private message database. The + # database is gitignored, but three binary SQLite files reached the + # history anyway before this gate existed, so .gitignore is evidently + # not the whole defence. Exceptions go in .binary-allowlist. + - id: no-binaries + name: no binary or image files + entry: scripts/check_no_binaries.sh + language: script + pass_filenames: true + # The shared 250-line cap. Checks only the files in the commit, so an # unrelated commit never breaks. No baseline, no allowlist -- those are # suppressions. The exemption list lives with the checker in ~/utils so diff --git a/DOCS-signal-envelopes.md b/DOCS-signal-envelopes.md index 3cdc780..c3f8a4d 100644 --- a/DOCS-signal-envelopes.md +++ b/DOCS-signal-envelopes.md @@ -28,6 +28,52 @@ Reference for the two JSON frames `signal-cli-rest-api` pushes down } ``` +## 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. + ## Somebody changed the membership: `groupInfo.type` is `UPDATE` The only frame that names *who* added the bot to a group, which is what diff --git a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite deleted file mode 100644 index 4b95fc9..0000000 Binary files a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite and /dev/null differ diff --git a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm deleted file mode 100644 index 2b14358..0000000 Binary files a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm and /dev/null differ diff --git a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-wal b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-wal deleted file mode 100644 index 9f411fa..0000000 Binary files a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-wal and /dev/null differ diff --git a/scripts/check_no_binaries.sh b/scripts/check_no_binaries.sh new file mode 100755 index 0000000..13ecefd --- /dev/null +++ b/scripts/check_no_binaries.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# ============================================================================ +# Thin delegate to the shared gate in ~/utils. +# +# Copying the logic here is what lets one repo's idea of the rule drift from +# every other repo's, so this script only locates the shared checker and +# forwards its arguments. +# ============================================================================ + +set -euo pipefail + +readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_no_binaries.sh" + +main() { + if [[ ! -x "$SHARED_GATE" ]]; then + echo "Error: shared gate not found at $SHARED_GATE" >&2 + echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2 + echo " UTILS_ROOT to where it lives." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@"