mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
8.0 KiB
Bash
Executable File
235 lines
8.0 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 BOT_NAME="${BOT_NAME:-signal-bot}"
|
|
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() {
|
|
# Only reuse a container that is in the mode we need. A leftover
|
|
# json-rpc-native container answers /v1/about perfectly well and then
|
|
# fails every image send, so reusing it silently undoes the fix below.
|
|
if curl -sf "${API}/v1/about" >/dev/null 2>&1; then
|
|
if signal_verify_mode >/dev/null 2>&1; then
|
|
echo "== reusing the container already listening on :${PORT}"
|
|
return 0
|
|
fi
|
|
echo "== replacing the container on :${PORT}: wrong mode for attachments"
|
|
fi
|
|
mkdir -p "$DATA_DIR"
|
|
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
# MODE=json-rpc, not json-rpc-native. Both expose the receive
|
|
# WebSocket, but the -native mode runs a GraalVM native-image build
|
|
# with no AWT, so every attachment send fails with
|
|
# "Can't load library: awt (UnsatisfiedLinkError)" -- meaning !cat
|
|
# and !dog return nothing while text replies work. Verified live on
|
|
# 2026-08-29: identical request, HTTP 400 native vs 201 on the JVM.
|
|
echo "== starting $IMAGE on :${PORT} (MODE=json-rpc)"
|
|
docker run -d --name "$CONTAINER" --restart=always -p "${PORT}:8080" \
|
|
-v "${DATA_DIR}:/home/.local/share/signal-cli" \
|
|
-e 'MODE=json-rpc' "$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
|
|
|
|
signal_set_profile "$NUMBER" "$BOT_NAME"
|
|
|
|
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_require_membership "$NUMBER" "$group"
|
|
# "own": this number is the bot's own account, so it may watch every
|
|
# group it is added to. See signal_write_env for why the mode decides.
|
|
signal_write_env "$REPO_ROOT" "$NUMBER" "$group" own
|
|
|
|
# Install the service rather than running in the foreground: a bot that
|
|
# only lives as long as this terminal is a demo, not a bot.
|
|
"$REPO_ROOT/scripts/install_service.sh"
|
|
echo
|
|
echo "== running. Send '!cat' in that group."
|
|
echo "== to use it in another group later, just add ${NUMBER} to it."
|
|
echo "== following the log; Ctrl-C stops watching, not the bot."
|
|
exec journalctl --user -u signal-bot.service -f
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--voice)
|
|
USE_VOICE="true"
|
|
shift
|
|
;;
|
|
-h | --help) usage ;;
|
|
*)
|
|
NUMBER="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
main "$@"
|