Go to file
Krzysztof kuhy Rudnicki aefffb2fd9
Some checks are pending
CI / check (push) Waiting to run
feat: add a photo to the album from the chat, tagged, stripped and verified
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own
groups and it lands in the pack tagged with both, retrievable through !alfa
and the new !losowe immediately -- no restart, and no tagger session on the PC.
A label matching nobody becomes a custom tag, and !losowe is the only door to
one, which is what keeps a live swap a *data* swap: it can never register a
trigger or widen a command's groups.

No typed label travels. Labels become pseudonym keys in a new registry
arg_resolver, at the dispatcher, before the line that logs the command name --
so no handler, journal entry, command_uses row or reply body ever holds a word
somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1".

Stripping is exiftool with a *positive* check on the output, never an exit
code. ffmpeg was tried first and fails silently on photos: given a JPEG with a
real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0.
The surviving-tag allowlist is per tag *name* rather than per group, because
measurement showed a group-level "must be empty" rule rejects every format
except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime
tags that are the container itself. Codec configuration (CompressorName,
VendorID, the HEVC parameter set) is on the list by decision: CompressorName
lives in the avc1 sample description and exiftool cannot write it at all, so
the alternative was refusing every video and every iPhone HEIC. Location and
identity tags are still refused, and the e2e run re-reads the stored files to
prove a QuickTime geotag did not survive.

.webm and .svg stay on the format allowlist and are stored *unstripped*:
exiftool 13.55 can write neither, and its SVG reader does not even surface
<metadata>. That is a deliberate, documented hole, not an oversight.

Two things this had to defuse along the way:
- personal_data_sources now reads custom.json, so "!add_photo kot <photo>"
  would have outlawed the word "kot" and refused every future commit, fixable
  only by hand-editing an album the committer cannot see. Minting now refuses
  a word that is already a command, and the gate subtracts the repo's own
  alias vocabulary from custom labels only -- curated people.json names are
  never excused.
- signal_bot/external.py is now the single subprocess site in the repository,
  so ruff's S603 needs exactly one scoped per-file-ignore instead of one per
  module that shells out. A test asserts nothing else imports subprocess.

A failed add changes nothing: media and the three manifests are written, the
album is reloaded, and only a load that succeeds is published; a load that
fails rolls all four back and keeps serving the pack that was already working,
without raising -- Restart=always would turn a ConfigError into a loop.

977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh
gains 8 scenarios driven against the real exiftool on media generated at run
time, and asserts the invariants a reply cannot: that a refused upload is not
stored, that a message with no attachment leaves custom.json byte-identical,
and that signal-cli's own copy of every ingested attachment is gone.

Closes TODO-add-photo.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
2026-08-31 11:20:00 +02:00
.github/workflows feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
fake_signal feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
scripts feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
signal_bot feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
tests feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
tool feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
.env.sample release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it 2026-08-30 22:35:41 +02:00
.gitignore release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it 2026-08-30 22:35:41 +02:00
.personal-data-allowlist feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
.pre-commit-config.yaml feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
DOCS-changelog.md fix: the evening report is one message, not one per thing it has to say 2026-08-30 22:49:51 +02:00
DOCS-commands.md feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
DOCS-latency.md fix: a signal-api that is not up yet no longer kills the bot at boot 2026-08-30 15:23:42 +02:00
DOCS-liveness-and-trust.md fix: a signal-api that is not up yet no longer kills the bot at boot 2026-08-30 15:23:42 +02:00
DOCS-photo-pack.md feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
DOCS-privacy.md feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
DOCS-selftest.md release: 0.0.7 «Telesfor» — !papież, and the bot can test itself 2026-08-30 17:06:25 +02:00
DOCS-signal-envelopes.md feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
DOCS-statistics.md docs: describe what the bot actually does now 2026-08-30 19:58:25 +02:00
LICENSE feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
pyproject.toml feat: add a photo to the album from the chat, tagged, stripped and verified 2026-08-31 11:20:00 +02:00
README.md feat: refuse to commit personal data, learned from outside the repository 2026-08-30 23:28:29 +02:00
requirements.txt feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
run.sh feat: rewrite the bot so it runs, and gate it like the other repos 2026-08-29 13:05:42 +02:00
SECURITY.md feat: discover groups from the account, so adding the bot is the whole setup 2026-08-29 15:30:08 +02:00

signal-bot

A Signal group bot: !cat / !dog / !gekon image commands, !papież, and group message statistics. The group's own data stays out: DOCS-privacy.md.

Runs on its own registered Signal number (or, if you prefer, as a linked device on an existing account) via signal-cli-rest-api.

Scoping, first

How wide the bot's reach is depends entirely on whose account it runs as, and the setup scripts pick the safe answer for you rather than leaving it to be remembered:

Run as Written to .env Reach
its own number (register_bot.sh) no GROUP_IDS every group somebody adds it to — and nothing else, because that account has no other chats
a linked device (live_smoke.sh) GROUP_IDS=<one group> only that group. A linked device receives every DM and group on the personal account it rides, so it is pinned

In both cases:

  • direct messages are dropped explicitly, never by a falsy check;
  • the scoping scenario asserts that DMs and other groups produce zero outbound messages, and scripts/e2e.sh fails the build if that stops holding.

Run it offline, with no Signal account

Everything works against a local fake that speaks the real protocol:

uv venv .venv && uv pip install -e '.[dev]'
./scripts/e2e.sh

That boots the fake server, boots the unmodified bot pointed at it, replays every scenario in fake_signal/scenarios/, and exits non-zero on mismatch. It needs no container, no network and no phone.

== booting the fake Signal server on :8099
== booting the bot on :8098, pointed at the fake
ok    !cat in the watched group posts an image to the reply group
ok    one user's cooldown does not silence anybody else
ok    direct messages and other groups are never answered
...

Add a case by dropping a YAML file in fake_signal/scenarios/:

name: "what this proves"
inject:
  - { text: "!cat", uuid: uuid-alice, name: Alice }   # group: watched|other|null
expect:
  - { kind: image, to: reply }

Give each scenario its own uuid: one bot process serves them all and the cooldown lives in SQLite, so a shared actor leaks state between files.

Run it for real

Two ways to give the bot a Signal identity. Prefer the first.

The bot registers its own number as its own primary device. It is a separate identity: it cannot read your personal DMs and never posts as you. No phone app, no QR scan, no work profile.

You need a number that can receive one SMS or voice call. A real mobile SIM/eSIM is the only reliable kind — Signal itself has no VoIP blocklist, but SMS carriers reject many VoIP numbers, so a VoIP number is a gamble and registration attempts are rate-limited.

docker rm -f signal-api
IMAGE=bbernhard/signal-cli-rest-api:1787779383-ci \
  ./scripts/register_bot.sh +48123456789      # add --voice for a landline

The image override is not optional noise. Stable 0.100 bundles signal-cli 0.14.5; registration needs ≥ 0.14.7, which carries "Update to new registration/provisioning API". On 2026-03-06 Signal made the post-quantum ratchet mandatory and de-registered every account whose client lacked it, so a stale client is how a bot dies silently months later. The script checks the bundled version and refuses rather than warning.

The container runs MODE=json-rpc, not json-rpc-native. Both expose the receive WebSocket, but -native is a GraalVM native-image build with no AWT, so every attachment send fails with Can't load library: awt — text replies work and !cat returns nothing, which reads as a bug in this repo and is not.

The script solves everything except two steps only a human can do: pasting a captcha token (the captcha exists to stop exactly this automation) and reading the SMS code. Then add the bot's number to your private group from your personal Signal, and it picks the group, verifies it really joined, installs the service and tails the log.

Adding the bot to a group: invited is not joined

Signal adds an account to a group outright only if the person adding it already holds that account's profile key. With no profile there is no key, so the add silently becomes a pending invitation — and signal-cli cannot accept one addressed to a phone number:

POST /v1/groups/<number>/<group>/join → 400
{"error":"Cannot find service ID for self to accept invite"}

This is why register_bot.sh sets the bot's profile before telling you to add it. A group in that state is indistinguishable from a working one from the outside — /v1/groups lists it, the WebSocket connects, /healthz answers, and not one message arrives — so the bot checks member at startup and refuses to run rather than looking healthy and staying deaf:

TransportError: the account is invited to 'testbot' but has not joined it,
so it will receive nothing.

If you hit it on an existing group: add the bot's number again from Signal. Verified on 2026-08-29 against a group that already held an unacceptable invite — re-adding turned member: false into member: true, members: 2, and the bot answered !cat in that same group. (Signal keeps the dead invite listed in pending_invites; member is the field that matters, which is what the startup gate reads.)

It runs as a service, not in a terminal

register_bot.sh finishes by installing a user systemd unit, so the bot outlives the terminal that started it:

./scripts/install_service.sh                # install, start, verify
./scripts/install_service.sh --verify-only  # re-check without reinstalling
systemctl --user status signal-bot.service
journalctl --user -u signal-bot.service -f

Restart=always brings it back from a crash; lingering (checked by the installer, because without it the promise is false) starts it at boot with nobody logged in. A container that is not up yet is no longer fatal — the bot serves a 503 /healthz and keeps retrying — so StartLimitBurst=5 is left doing only what it was for: catching a config error in a restart loop.

The health probe binds 127.0.0.1:8137 (BIND_PORT). Not 8000: that port attracts unrelated dev servers, and 8137 sits below the ephemeral range so the kernel cannot hand it out. If it is taken anyway the bot names the offender and exits non-zero, because uvicorn logs the collision and exits 0, which every supervisor reads as success:

cannot bind 127.0.0.1:8000 -- held by pid 82385: /usr/bin/python -m ...

A linked device on your own account

./scripts/live_smoke.sh

Starts the container, opens the QR code, waits for the link, then picks a group. Caveat: a linked device rides your account — it sends as you and receives every DM and group you are in, so signal_write_env pins it to the single group you picked. Prefer scripts/register_bot.sh, which gives the bot its own number and no access to your chats at all.

Why not Shelter / a second Signal app on the phone

Two independent reasons, both verified rather than assumed:

  1. A second app instance is not a second account. Every Signal account needs its own phone number, so Shelter never removed the actual blocker. Signal's phone-number-free "Signal Login" is announced but not shipped.
  2. It cannot be installed anyway on a phone with a Device Owner. Setting a Device Owner makes Android enable no_add_managed_profile, no_add_private_profile and no_add_clone_profile on every user — killing Shelter, Island, Private Space and app cloning alike. Verified on-device: pm create-user --profileOf 0 --managed returns Cannot add user: no_add_managed_profile is enabled. This is the Android 11 COMP deprecation and is unchanged through Android 16.

When it cannot hear, and who may add it

The bot was up, healthy and deaf for three hours on 2026-08-29, and dead in failed for eight after the 2026-08-30 reboot. /healthz now has three answers — ok, connecting, deaf — a two-minute timer restarts the container, and TRUSTED_INVITERS decides who may add this bot to a group: DOCS-liveness-and-trust.md.

Commands

!pomoc (or !help) prints every command in the group, in Polish, one line of description each. It is built from the registry at run time and CommandRegistry.register takes that description as a required argument, so a command cannot be added without appearing there. Every trigger that resolves, and why the printed spelling is not one of them: DOCS-commands.md.

Aliases live in signal_bot/aliases.json, not in source. Each command is rate-limited per user, per command — the original bot shared one 10-second window across everybody.

signal_bot/decorators.py also ships daily_limit and vote_gated (run only once N distinct people ask, within a window). Both are generic and tested; attach them to any command.

Watching more than one group

GROUP_IDS takes a comma-separated list, so one process can serve a test group and the production group at once:

GROUP_IDS=group.<base64-of-test>,group.<base64-of-prod>

Everything downstream is keyed by group. A command is answered in the group that asked and nowhere else; cooldowns, daily limits, votes and the tally are per group, so a !cat in the test group neither throttles nor counts against the production one. There is no reply-target setting to get wrong — GROUP_ID_SEND was removed, and setting it now fails startup rather than quietly posting the day's report into one group's chat.

The per_group_isolation scenario is the gate: one person asks in both groups inside a single cooldown window and must get two images, one in each.

Statistics

One row of metadata per message — sender, Warsaw day, hour, media, length — and never the message body. At REPORT_HOUR:REPORT_MINUTE (default 21:37) each group gets its report for the previous full calendar day: silent members at 0, up to three highlights. Sundays close a week, month ends a month. Ask it things: !staty !wczoraj !tydzień !miesiąc !rok !rekordy !korona !dni !ja. Full behaviour: DOCS-statistics.md.

Develop

pytest                       # 708 tests, 100% branch coverage, no network
pre-commit run --all-files   # ruff ALL + mypy strict + 250-line cap + gates
./scripts/e2e.sh             # the real bot against the fake server

No # noqa, no # type: ignore, no coverage omits. Never bump SCHEMA_VERSION to add a table — read signal_bot/schema.py first; a bump DROPs every table and throws the live tallies away, while CREATE TABLE IF NOT EXISTS is additive and safe. Wire format: DOCS-signal-envelopes.md. Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: DOCS-changelog.md. Latency: DOCS-latency.md. Signal never delivers an account its own sends: scripts/selftest.sh testbot '!papież' 3 drives the live dispatcher instead, DOCS-selftest.md.

Licence

MIT. See LICENSE.