diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2adf001..341e8a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9d3eb7..f53eaca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index e34d324..67386c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite new file mode 100644 index 0000000..4b95fc9 Binary files /dev/null and b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite 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 new file mode 100644 index 0000000..2aad196 Binary files /dev/null and b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm 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 new file mode 100644 index 0000000..9f411fa Binary files /dev/null and b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-wal differ diff --git a/fake_signal/routes_account.py b/fake_signal/routes_account.py new file mode 100644 index 0000000..a1b7913 --- /dev/null +++ b/fake_signal/routes_account.py @@ -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 diff --git a/fake_signal/routes_control.py b/fake_signal/routes_control.py index ec3b367..47ffa74 100644 --- a/fake_signal/routes_control.py +++ b/fake_signal/routes_control.py @@ -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) diff --git a/fake_signal/server.py b/fake_signal/server.py index 22049a1..d29d65f 100644 --- a/fake_signal/server.py +++ b/fake_signal/server.py @@ -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 diff --git a/fake_signal/state.py b/fake_signal/state.py index f3efd05..93f3ee0 100644 --- a/fake_signal/state.py +++ b/fake_signal/state.py @@ -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.""" diff --git a/scripts/lib_signal.sh b/scripts/lib_signal.sh new file mode 100644 index 0000000..cea90fd --- /dev/null +++ b/scripts/lib_signal.sh @@ -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" </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 &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" <&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" diff --git a/scripts/register_bot.sh b/scripts/register_bot.sh new file mode 100755 index 0000000..5f26fff --- /dev/null +++ b/scripts/register_bot.sh @@ -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") + [--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 "$@" diff --git a/tests/test_fake_account.py b/tests/test_fake_account.py new file mode 100644 index 0000000..64cbdcd --- /dev/null +++ b/tests/test_fake_account.py @@ -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=="]