mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
feat: register the bot as its own Signal account, no phone app needed
Some checks are pending
CI / check (push) Waiting to run
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:
parent
9193360461
commit
53e231ee14
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
73
README.md
73
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
|
||||
|
||||
|
||||
BIN
bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite
Normal file
BIN
bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
80
fake_signal/routes_account.py
Normal file
80
fake_signal/routes_account.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
87
scripts/lib_signal.sh
Normal 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"
|
||||
}
|
||||
@ -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
210
scripts/register_bot.sh
Executable 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
100
tests/test_fake_account.py
Normal 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=="]
|
||||
Loading…
Reference in New Issue
Block a user