Shelter cannot work on this phone, and it would not have helped anyway.
It cannot work: setting a Device Owner makes Android enable
no_add_managed_profile, no_add_private_profile and no_add_clone_profile on
every user, which kills Shelter, Island, Private Space and app cloning
alike. Verified on-device rather than inferred -- `pm create-user
--profileOf 0 --managed` returns "Cannot add user: no_add_managed_profile is
enabled". This is the Android 11 COMP deprecation, enforced in three places
in DevicePolicyManagerService and unchanged through Android 16.
It would not have helped: a second Signal *app* is not a second Signal
*account*. Every account needs its own phone number, so the second number
was always the real requirement -- and once you have one, signal-cli can
register it as a PRIMARY device with no phone app, no QR scan and no work
profile at all. That is strictly better than both the linked-device setup
and the Shelter plan: the bot cannot read personal DMs and never posts as
the account owner.
scripts/register_bot.sh drives that flow. It gates on signal-cli >= 0.14.7
rather than hoping: stable container tag 0.100 bundles 0.14.5, and on
2026-03-06 Signal made the post-quantum ratchet mandatory and de-registered
every account whose client lacked it -- a stale client is how a bot dies
silently months later. Verified that the recommended CI tag really does
bundle 0.14.7 instead of assuming it.
Rehearsed offline, not written blind. fake_signal grew /v1/accounts,
/v1/register/{number}, /v1/register/{number}/verify/{token} and
/v1/groups/{number}, so the whole runbook including every error path
(missing captcha, stray verify, wrong code, unregistered number, empty group
list, out-of-range and non-numeric picks) runs against the fake. The picker
was proven to resolve the group the user actually pointed at.
Prompts now read from $SIGNAL_TTY instead of /dev/tty directly. A hard-coded
/dev/tty made the interactive path untestable without a real terminal; the
seam is the same idea as the injected clock and transport on the Python side,
and is what let the picker be rehearsed at all.
live_smoke.sh and register_bot.sh share scripts/lib_signal.sh so the two
cannot drift, and shellcheck now runs with -x so the sourced lib is actually
linted from its callers.
201 tests, 100% branch coverage, all 23 gates green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
|
||
|---|---|---|
| .github/workflows | ||
| bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051 | ||
| fake_signal | ||
| scripts | ||
| signal_bot | ||
| tests | ||
| .env.sample | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| DOCS-signal-envelopes.md | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| run.sh | ||
| SECURITY.md | ||
signal-bot
A Signal group bot: !cat / !dog image commands and a daily message tally.
Runs as a linked device on an existing Signal account via signal-cli-rest-api.
Scoping, first
A linked device receives every direct message and every group on the
account it is linked to. GROUP_ID is the only thing that keeps this bot
inside one conversation, so:
- the bot refuses to start without
GROUP_IDandGROUP_ID_SEND; - direct messages are dropped explicitly, never by a falsy check;
- the
scopingscenario asserts that DMs and other groups produce zero outbound messages, andscripts/e2e.shfails 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.
A dedicated bot account (recommended)
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 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 and starts.
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. GROUP_ID is the only thing scoping
it, which is why that scoping is a tested, CI-enforced invariant rather than a
convention.
Why not Shelter / a second Signal app on the phone
Two independent reasons, both verified rather than assumed:
- 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.
- 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_profileandno_add_clone_profileon every user — killing Shelter, Island, Private Space and app cloning alike. Verified on-device:pm create-user --profileOf 0 --managedreturnsCannot add user: no_add_managed_profile is enabled. This is the Android 11 COMP deprecation and is unchanged through Android 16.
Commands
| Command | Aliases |
|---|---|
| cat | !kot !koty !kots !cat !cats !meow !miau + styled variants |
| dog | !pies !psy !dog !dogs !woof !szczek + styled variants |
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.
Daily tally
Every non-sticker, non-reaction message in the watched group increments its
sender's count. At REPORT_HOUR:REPORT_MINUTE (default 21:37) the tally is
posted to GROUP_ID_SEND and reset. Counts live in SQLite and survive a
restart.
Develop
pytest # 189 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. Docs on the wire format
are in DOCS-signal-envelopes.md.
Licence
MIT. See LICENSE.