feat: register the bot as its own Signal account, no phone app needed
Some checks are pending
CI / check (push) Waiting to run

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 13:41:13 +02:00
parent 9193360461
commit 53e231ee14
14 changed files with 567 additions and 99 deletions

View File

@ -48,7 +48,7 @@ jobs:
- name: shellcheck
# --severity=style is the floor CI enforces; the local pre-commit hook
# uses the same flag so a version skew cannot pass locally and fail here.
run: shellcheck --severity=style run.sh scripts/*.sh
run: shellcheck -x --severity=style run.sh scripts/*.sh
- name: pytest (100% branch coverage)
run: .venv/bin/python -m pytest -q

View File

@ -94,7 +94,7 @@ repos:
name: shellcheck
# Same severity floor as CI: a stricter runner version must not be
# the thing that discovers a finding this hook let through.
entry: shellcheck --severity=style
entry: shellcheck -x --severity=style
language: system
types: [shell]

View File

@ -53,34 +53,63 @@ cooldown lives in SQLite, so a shared actor leaks state between files.
## Run it for real
1. Start the container. Use **`json-rpc-native`** — `/v1/receive` is only a
WebSocket in a json-rpc mode; in `normal`/`native` it silently degrades to
a polling GET and the bot will never receive anything.
Two ways to give the bot a Signal identity. **Prefer the first.**
```sh
docker run -d --name signal-api --restart=always -p 9922:8080 \
-v "$HOME/signal-api:/home/.local/share/signal-cli" \
-e MODE=json-rpc-native \
bbernhard/signal-cli-rest-api:0.100
```
### A dedicated bot account (recommended)
Pin the **stable** tag. The dev series (`0.202-dev`) counts separately, so
an older `0.167-dev` is not newer than `0.100`. If linking fails with a
connection error, that is server-side client deprecation: try a recent
`*-ci` tag, which tracks a newer signal-cli.
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.
2. Link the device and pick a group — this does both, and only asks you to
scan a QR code:
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.
```sh
./scripts/live_smoke.sh
```
```sh
docker rm -f signal-api
IMAGE=bbernhard/signal-cli-rest-api:1787779383-ci \
./scripts/register_bot.sh +48123456789 # add --voice for a landline
```
3. Or do it by hand: open `http://localhost:9922/v1/qrcodelink?device_name=signal-api`,
scan it, copy `.env.sample` to `.env`, fill it in, then `./run.sh`.
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.
Send `!cat` in the group. Because this is a linked device, the reply arrives
from your own account.
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
```sh
./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:
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.
## Commands

View File

@ -0,0 +1,80 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Registration and group endpoints.
Mirrors the shapes scripts/register_bot.sh depends on, so the whole runbook
can be rehearsed offline. The real captcha and SMS steps are the only parts
that cannot be faked, and both are represented by their exact failure mode.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
if TYPE_CHECKING:
from fake_signal.state import State
CAPTCHA_REQUIRED = (
"Captcha required for verification, use --captcha CAPTCHA\n"
"To get the token, go to https://signalcaptchas.org/registration/generate.html\n"
'After solving the captcha, right-click on the "Open Signal" link and copy the link.'
)
# Mirrors signalapp/registration-service in dev mode, which accepts the last
# six digits of the number as the verification code.
CODE_LENGTH = 6
def _state(request: Request) -> State:
"""Return the State attached to the running app."""
state: State = request.app.state.fake
return state
def expected_code(number: str) -> str:
"""The verification code the fake accepts for a number."""
digits = "".join(c for c in number if c.isdigit())
return digits[-CODE_LENGTH:]
def build_account_router() -> APIRouter:
"""Build the router covering accounts, registration and groups."""
router = APIRouter()
@router.get("/v1/accounts")
async def accounts(request: Request) -> list[str]:
return list(_state(request).accounts)
@router.post("/v1/register/{number}")
async def register(
request: Request, number: str, payload: dict[str, Any] | None = None
) -> JSONResponse:
captcha = (payload or {}).get("captcha")
if not captcha:
return JSONResponse({"error": CAPTCHA_REQUIRED}, status_code=400)
_state(request).pending[number] = expected_code(number)
return JSONResponse({}, status_code=201)
@router.post("/v1/register/{number}/verify/{token}")
async def verify(request: Request, number: str, token: str) -> JSONResponse:
state = _state(request)
if number not in state.pending:
return JSONResponse(
{"error": f"{number} has no registration in progress"}, status_code=400
)
if token != state.pending[number]:
return JSONResponse({"error": "invalid verification code"}, status_code=400)
del state.pending[number]
if number not in state.accounts:
state.accounts.append(number)
return JSONResponse({}, status_code=201)
@router.get("/v1/groups/{number}")
async def groups(request: Request, number: str) -> JSONResponse:
state = _state(request)
if number not in state.accounts:
return JSONResponse({"error": f"{number} is not registered"}, status_code=400)
return JSONResponse(state.groups, status_code=200)
return router

View File

@ -29,6 +29,14 @@ def build_control_router() -> APIRouter:
delivered += 1
return {"delivered": delivered}
@router.post("/groups")
async def set_groups(request: Request, groups: list[dict[str, str]]) -> dict[str, int]:
"""Seed the group list, so the group picker can be rehearsed offline."""
state = _state(request)
state.groups.clear()
state.groups.extend(groups)
return {"groups": len(state.groups)}
@router.get("/sent")
async def captured(request: Request) -> list[dict[str, Any]]:
return list(_state(request).sent)

View File

@ -14,6 +14,7 @@ import os
from fastapi import FastAPI
from fake_signal.routes_account import build_account_router
from fake_signal.routes_api import build_api_router
from fake_signal.routes_control import build_control_router
from fake_signal.routes_fixtures import build_fixture_router
@ -25,6 +26,7 @@ def create_app(state: State | None = None) -> FastAPI:
app = FastAPI(title="fake-signal-cli-rest-api")
app.state.fake = state if state is not None else State()
app.include_router(build_api_router())
app.include_router(build_account_router())
app.include_router(build_fixture_router())
app.include_router(build_control_router())
return app

View File

@ -26,6 +26,11 @@ class State:
attachments: list[str] = field(default_factory=list)
mode: str = "json-rpc-native"
base_url: str = "http://127.0.0.1:8099"
# Registration and group state, so the registration runbook can be
# rehearsed offline instead of first executing against real Signal.
accounts: list[str] = field(default_factory=list)
pending: dict[str, str] = field(default_factory=dict)
groups: list[dict[str, str]] = field(default_factory=list)
def reset(self) -> None:
"""Forget captured sends and attachments."""

87
scripts/lib_signal.sh Normal file
View File

@ -0,0 +1,87 @@
# shellcheck shell=bash
# ============================================================================
# Shared helpers for the scripts that drive a signal-cli-rest-api container.
#
# Sourced, never executed. Callers must define API before sourcing.
#
# Prompts read from $SIGNAL_TTY, not /dev/tty directly. The redirect is
# needed because these functions' stdout is captured by the caller, but a
# hard-coded /dev/tty makes the interactive path impossible to rehearse
# without a real terminal -- so it is a seam, exactly like the injected
# clock and transport on the Python side.
# ============================================================================
: "${SIGNAL_TTY:=/dev/tty}"
# Wait until a URL answers, or give up with a named error.
signal_wait_for_url() {
local url="$1" name="$2" attempts="${3:-150}" attempt=0
until curl -sf "$url" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge $attempts ]]; then
echo "Error: $name did not come up at $url" >&2
return 1
fi
sleep 0.4
done
}
# Refuse to continue unless the container exposes a receive WebSocket.
signal_verify_mode() {
local mode
mode="$(curl -sf "${API}/v1/about" | jq -r '.mode // "unknown"')"
if [[ "$mode" != *json-rpc* ]]; then
echo "Error: container mode is '$mode'." >&2
echo " /v1/receive is only a WebSocket in a json-rpc mode." >&2
return 1
fi
echo "== container mode: $mode"
}
# Print the group id the user picks. Every prompt goes to stderr because this
# function's stdout is captured by the caller.
signal_choose_group() {
local number="$1" groups choice id
# Fetched ONCE: two calls could return a different order, so the index the
# user picks would resolve against a different list than the one shown.
groups="$(curl -sf "${API}/v1/groups/${number}")"
if [[ -z "$groups" || "$groups" == "[]" ]]; then
echo "Error: no groups on ${number}." >&2
echo " Add the bot to a private group from your phone first," >&2
echo " then re-run this script." >&2
return 1
fi
echo "== groups on this account:" >&2
printf '%s' "$groups" | jq -r '.[] | "\(.id)\t\(.name // "(unnamed)")"' \
| nl -w3 -s' ' >&2
echo >&2
read -r -p "Pick the number of the PRIVATE group to use: " choice <"$SIGNAL_TTY" >&2
if ! [[ "$choice" =~ ^[0-9]+$ ]]; then
echo "Error: '$choice' is not a number" >&2
return 1
fi
id="$(printf '%s' "$groups" | jq -r --argjson n "$choice" '.[$n - 1].id // empty')"
if [[ -z "$id" ]]; then
echo "Error: no group at position $choice" >&2
return 1
fi
printf '%s' "$id"
}
# Write .env for the bot. GROUP_ID == GROUP_ID_SEND so replies land in the
# same group that triggered them.
signal_write_env() {
local repo_root="$1" number="$2" group="$3"
cat > "${repo_root}/.env" <<ENV
PHONE_NUMBER=${number}
GROUP_ID=${group}
GROUP_ID_SEND=${group}
API_BASE_URL=${API}
DATABASE_PATH=${repo_root}/signal_bot.sqlite3
ENV
echo "== wrote ${repo_root}/.env"
}

View File

@ -20,7 +20,7 @@ readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
readonly CONTAINER="${CONTAINER:-signal-api}"
readonly PORT="${PORT:-9922}"
readonly API="http://localhost:${PORT}"
readonly API="${API:-http://localhost:${PORT}}"
readonly DATA_DIR="${DATA_DIR:-$HOME/signal-api}"
# Stable tag. The dev series counts separately, so 0.167-dev is OLDER than this.
readonly IMAGE="${IMAGE:-bbernhard/signal-cli-rest-api:0.100}"
@ -29,6 +29,9 @@ readonly FALLBACK_IMAGE="${FALLBACK_IMAGE:-bbernhard/signal-cli-rest-api:1787779
TEMP_DIR=""
# shellcheck source-path=SCRIPTDIR source=scripts/lib_signal.sh
. "${SCRIPT_DIR}/lib_signal.sh"
cleanup() {
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
@ -56,27 +59,10 @@ start_container() {
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
-e 'MODE=json-rpc-native' "$image" >/dev/null
local attempt=0
until curl -sf "${API}/v1/about" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge 150 ]]; then
echo "Error: container never became ready. Logs:" >&2
docker logs --tail 30 "$CONTAINER" >&2
return 1
fi
sleep 0.4
done
}
verify_mode() {
local mode
mode="$(curl -sf "${API}/v1/about" | jq -r '.mode // "unknown"')"
if [[ "$mode" != *json-rpc* ]]; then
echo "Error: container mode is '$mode'." >&2
echo " /v1/receive is only a WebSocket in a json-rpc mode." >&2
exit 1
if ! signal_wait_for_url "${API}/v1/about" "signal-cli-rest-api"; then
docker logs --tail 30 "$CONTAINER" >&2
return 1
fi
echo "== container mode: $mode"
}
show_qr_code() {
@ -92,57 +78,18 @@ show_qr_code() {
}
wait_for_link() {
echo "== waiting for the link (Ctrl-C to abort)"
local attempt=0
until [[ "$(curl -sf "${API}/v1/accounts" | jq -r 'length // 0')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 300 ]]; then
echo "Error: no account linked after 5 minutes." >&2
echo " If linking failed with a connection error, retry with:" >&2
echo " IMAGE=$FALLBACK_IMAGE $0" >&2
exit 1
fi
sleep 1
done
}
choose_group() {
local number="$1" groups choice id
# Fetched ONCE: two separate calls could return a different order, so the
# index the user picks would resolve against a different list.
groups="$(curl -sf "${API}/v1/groups/${number}")"
# Every prompt goes to stderr: this function's stdout is captured by $(...)
# and must contain nothing but the chosen group id.
echo "== groups on this account:" >&2
printf '%s' "$groups" | jq -r '.[] | "\(.id)\t\(.name // "(unnamed)")"' \
| nl -w3 -s' ' >&2
echo >&2
read -r -p "Pick the number of the PRIVATE group to use: " choice </dev/tty >&2
if ! [[ "$choice" =~ ^[0-9]+$ ]]; then
echo "Error: '$choice' is not a number" >&2
return 1
fi
id="$(printf '%s' "$groups" | jq -r --argjson n "$choice" '.[$n - 1].id // empty')"
if [[ -z "$id" ]]; then
echo "Error: no group at position $choice" >&2
return 1
fi
printf '%s' "$id"
}
write_env() {
local number="$1" group="$2"
cat > "${REPO_ROOT}/.env" <<ENV
PHONE_NUMBER=${number}
GROUP_ID=${group}
GROUP_ID_SEND=${group}
API_BASE_URL=${API}
DATABASE_PATH=${REPO_ROOT}/signal_bot.sqlite3
ENV
echo "== wrote ${REPO_ROOT}/.env (GROUP_ID == GROUP_ID_SEND, replies land in the same group)"
echo "== waiting for the link (Ctrl-C to abort)"
local attempt=0
until [[ "$(curl -sf "${API}/v1/accounts" | jq -r 'length // 0')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 300 ]]; then
echo "Error: no account linked after 5 minutes." >&2
echo " If linking failed with a connection error, retry with:" >&2
echo " IMAGE=$FALLBACK_IMAGE $0" >&2
exit 1
fi
sleep 1
done
}
main() {
@ -150,16 +97,16 @@ main() {
TEMP_DIR="$(mktemp -d)"
start_container "$IMAGE"
verify_mode
signal_verify_mode
show_qr_code
wait_for_link
local number group
number="$(curl -sf "${API}/v1/accounts" | jq -r '.[0]')"
echo "== linked as ${number}"
group="$(choose_group "$number")"
group="$(signal_choose_group "$number")"
[[ -n "$group" && "$group" != "null" ]] || { echo "Error: no group chosen" >&2; exit 1; }
write_env "$number" "$group"
signal_write_env "$REPO_ROOT" "$number" "$group"
echo "== starting the bot. Send '!cat' in that group; you should get a cat."
exec "$REPO_ROOT/run.sh"

210
scripts/register_bot.sh Executable file
View File

@ -0,0 +1,210 @@
#!/bin/bash
# ============================================================================
# Register a DEDICATED Signal number for the bot.
#
# The bot becomes its own PRIMARY Signal device: it is a separate identity, it
# cannot read your personal DMs, and it never posts as you. No phone app, no
# QR scan, no work profile. Shelter cannot help here anyway -- this phone has a
# Device Owner, so no_add_managed_profile is enabled and no work profile can
# be created at all.
#
# You need a number that can receive ONE SMS or voice call. Two manual steps:
# solving a captcha in the browser, and reading the code off that number.
#
# Usage: scripts/register_bot.sh +48123456789 [--voice]
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
readonly REPO_ROOT
readonly CONTAINER="${CONTAINER:-signal-api}"
readonly PORT="${PORT:-9922}"
readonly API="${API:-http://localhost:${PORT}}"
readonly DATA_DIR="${DATA_DIR:-$HOME/signal-api}"
readonly IMAGE="${IMAGE:-bbernhard/signal-cli-rest-api:0.100}"
readonly CAPTCHA_URL="https://signalcaptchas.org/registration/generate.html"
# Registering is exactly the operation that punishes a stale client. On
# 2026-03-06 Signal made the post-quantum ratchet mandatory and
# unregistered every account whose client lacked it; 0.14.7 additionally
# carries "Update to new registration/provisioning API". The stable
# container tag lags, so this is checked rather than hoped for.
readonly MIN_SIGNAL_CLI="0.14.7"
readonly CI_IMAGE="bbernhard/signal-cli-rest-api:1787779383-ci"
NUMBER=""
USE_VOICE="false"
# shellcheck source-path=SCRIPTDIR source=scripts/lib_signal.sh
. "${SCRIPT_DIR}/lib_signal.sh"
usage() {
echo "Usage: $(basename "$0") +<E164 number> [--voice]"
echo " --voice Verify by voice call instead of SMS (for landlines)."
exit 0
}
validate_requirements() {
if [[ -z "$NUMBER" ]]; then
echo "Error: a phone number in +E.164 form is required" >&2
exit 1
fi
if ! [[ "$NUMBER" =~ ^\+[1-9][0-9]{6,14}$ ]]; then
echo "Error: '$NUMBER' is not a +E.164 number (e.g. +48123456789)" >&2
exit 1
fi
for tool in docker curl jq; do
command -v "$tool" >/dev/null || {
echo "Error: $tool is required" >&2
exit 1
}
done
}
start_container() {
if curl -sf "${API}/v1/about" >/dev/null 2>&1; then
echo "== reusing the container already listening on :${PORT}"
return 0
fi
mkdir -p "$DATA_DIR"
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
echo "== starting $IMAGE on :${PORT} (MODE=json-rpc-native)"
docker run -d --name "$CONTAINER" --restart=always -p "${PORT}:8080" \
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
-e 'MODE=json-rpc-native' "$IMAGE" >/dev/null
signal_wait_for_url "${API}/v1/about" "signal-cli-rest-api"
}
# Refuse to register with a client Signal may drop. A long-lived bot that
# silently de-registers looks like a broken bot, not an outdated one.
verify_signal_cli_version() {
local version oldest
version="$(docker exec "$CONTAINER" signal-cli --version 2>/dev/null | awk '{print $2}')"
if [[ -z "$version" ]]; then
echo "Error: could not read the signal-cli version from $CONTAINER" >&2
return 1
fi
oldest="$(printf '%s\n%s\n' "$MIN_SIGNAL_CLI" "$version" | sort -V | head -1)"
if [[ "$oldest" != "$MIN_SIGNAL_CLI" ]]; then
echo "Error: this container bundles signal-cli ${version}, older than ${MIN_SIGNAL_CLI}." >&2
echo " Registering with a stale client risks the account being" >&2
echo " de-registered server-side later. Re-run against a newer image:" >&2
echo >&2
echo " docker rm -f ${CONTAINER}" >&2
echo " IMAGE=${CI_IMAGE} $0 ${NUMBER}" >&2
return 1
fi
echo "== signal-cli ${version} (>= ${MIN_SIGNAL_CLI})"
}
already_registered() {
curl -sf "${API}/v1/accounts" | jq -e --arg n "$NUMBER" 'index($n)' >/dev/null 2>&1
}
# The captcha is the one step that cannot be automated: it exists precisely to
# stop automated registration. Open the page and take the token by hand.
collect_captcha() {
echo "== solving the captcha (the only step I cannot do for you)" >&2
echo " 1. Solve the captcha in the browser." >&2
echo " 2. Right-click 'Open Signal' -> Copy link address." >&2
echo " 3. Paste it below. It looks like: signalcaptcha://signal-hcaptcha..." >&2
if command -v xdg-open >/dev/null; then
xdg-open "$CAPTCHA_URL" >/dev/null 2>&1 &
else
echo " Open this URL yourself: $CAPTCHA_URL" >&2
fi
local token
read -r -p "Captcha link: " token <"$SIGNAL_TTY" >&2
if [[ -z "$token" ]]; then
echo "Error: no captcha token given" >&2
return 1
fi
printf '%s' "$token"
}
request_code() {
local captcha="$1" response
response="$(curl -sS -X POST "${API}/v1/register/${NUMBER}" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg c "$captcha" --argjson v "$USE_VOICE" \
'{captcha: $c, use_voice: $v}')")"
if [[ -n "$response" ]] && printf '%s' "$response" | jq -e '.error' >/dev/null 2>&1; then
echo "Error from Signal:" >&2
printf '%s\n' "$response" | jq -r '.error' >&2
return 1
fi
echo "== verification code sent to ${NUMBER}"
}
submit_code() {
local code response
read -r -p "Verification code from ${NUMBER} (digits only): " code <"$SIGNAL_TTY" >&2
code="${code//[^0-9]/}"
if [[ -z "$code" ]]; then
echo "Error: no code given" >&2
return 1
fi
response="$(curl -sS -X POST "${API}/v1/register/${NUMBER}/verify/${code}")"
if [[ -n "$response" ]] && printf '%s' "$response" | jq -e '.error' >/dev/null 2>&1; then
echo "Error from Signal:" >&2
printf '%s\n' "$response" | jq -r '.error' >&2
return 1
fi
}
confirm_registered() {
if ! already_registered; then
echo "Error: ${NUMBER} still is not in /v1/accounts after verifying." >&2
return 1
fi
echo "== registered: ${NUMBER} is now its own primary Signal device"
}
main() {
validate_requirements
start_container
signal_verify_mode
verify_signal_cli_version
if already_registered; then
echo "== ${NUMBER} is already registered; skipping to group selection"
else
local captcha
captcha="$(collect_captcha)"
request_code "$captcha"
submit_code
confirm_registered
fi
echo
echo "== NEXT: from your personal Signal, add ${NUMBER} to the private group,"
echo " then press Enter. The bot must be a member before it can post."
read -r -p "Press Enter once the bot is in the group: " _ <"$SIGNAL_TTY" >&2
local group
group="$(signal_choose_group "$NUMBER")"
signal_write_env "$REPO_ROOT" "$NUMBER" "$group"
echo "== starting the bot. Send '!cat' in that group."
exec "$REPO_ROOT/run.sh"
}
while [[ $# -gt 0 ]]; do
case $1 in
--voice)
USE_VOICE="true"
shift
;;
-h | --help) usage ;;
*)
NUMBER="$1"
shift
;;
esac
done
main "$@"

100
tests/test_fake_account.py Normal file
View File

@ -0,0 +1,100 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Registration and group endpoints on the fake server."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from fake_signal.routes_account import CAPTCHA_REQUIRED, expected_code
from fake_signal.server import create_app
from fake_signal.state import State
NUMBER = "+15550001234"
@pytest.fixture
def state() -> State:
return State(base_url="http://testserver")
@pytest.fixture
def client(state: State) -> TestClient:
return TestClient(create_app(state))
def test_no_accounts_before_registration(client: TestClient) -> None:
assert client.get("/v1/accounts").json() == []
def test_register_without_captcha_is_refused(client: TestClient) -> None:
"""The exact failure the real container returns, so the script can match it."""
response = client.post(f"/v1/register/{NUMBER}", json={})
assert response.status_code == 400
assert "signalcaptchas.org" in response.json()["error"]
assert response.json()["error"] == CAPTCHA_REQUIRED
def test_register_with_captcha_starts_verification(client: TestClient, state: State) -> None:
response = client.post(f"/v1/register/{NUMBER}", json={"captcha": "signalcaptcha://x"})
assert response.status_code == 201
assert state.pending[NUMBER] == "001234"
def test_verify_completes_registration(client: TestClient) -> None:
client.post(f"/v1/register/{NUMBER}", json={"captcha": "signalcaptcha://x"})
code = expected_code(NUMBER)
assert client.post(f"/v1/register/{NUMBER}/verify/{code}").status_code == 201
assert client.get("/v1/accounts").json() == [NUMBER]
def test_verify_is_idempotent_on_the_account_list(client: TestClient) -> None:
for _ in range(2):
client.post(f"/v1/register/{NUMBER}", json={"captcha": "c"})
client.post(f"/v1/register/{NUMBER}/verify/{expected_code(NUMBER)}")
assert client.get("/v1/accounts").json() == [NUMBER]
def test_verify_rejects_a_wrong_code(client: TestClient) -> None:
client.post(f"/v1/register/{NUMBER}", json={"captcha": "c"})
response = client.post(f"/v1/register/{NUMBER}/verify/000000")
assert response.status_code == 400
assert "invalid verification code" in response.json()["error"]
def test_verify_without_a_pending_registration_is_refused(client: TestClient) -> None:
response = client.post(f"/v1/register/{NUMBER}/verify/001234")
assert response.status_code == 400
assert "no registration in progress" in response.json()["error"]
def test_groups_require_a_registered_account(client: TestClient) -> None:
response = client.get(f"/v1/groups/{NUMBER}")
assert response.status_code == 400
assert "not registered" in response.json()["error"]
def test_groups_are_listed_once_registered(client: TestClient, state: State) -> None:
state.accounts.append(NUMBER)
state.groups.extend([{"id": "gA==", "name": "Alpha"}, {"id": "gB==", "name": "Bravo"}])
body = client.get(f"/v1/groups/{NUMBER}").json()
assert [g["name"] for g in body] == ["Alpha", "Bravo"]
def test_expected_code_uses_the_last_six_digits() -> None:
assert expected_code("+48 123 456 789") == "456789"
def test_control_can_seed_groups(client: TestClient, state: State) -> None:
"""The picker in scripts/lib_signal.sh is rehearsed against this."""
state.accounts.append(NUMBER)
body = [{"id": "gA==", "name": "Alpha"}, {"id": "gB==", "name": "Bravo"}]
assert client.post("/_control/groups", json=body).json() == {"groups": 2}
assert client.get(f"/v1/groups/{NUMBER}").json() == body
def test_seeding_groups_replaces_the_previous_list(client: TestClient, state: State) -> None:
state.accounts.append(NUMBER)
client.post("/_control/groups", json=[{"id": "old==", "name": "Old"}])
client.post("/_control/groups", json=[{"id": "new==", "name": "New"}])
assert [g["id"] for g in client.get(f"/v1/groups/{NUMBER}").json()] == ["new=="]