mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:27:17 +01:00
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
211 lines
6.7 KiB
Bash
Executable File
211 lines
6.7 KiB
Bash
Executable File
#!/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 "$@"
|