mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:47:17 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
6.6 KiB
Bash
175 lines
6.6 KiB
Bash
# 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 is in exactly the right mode.
|
|
#
|
|
# Not a *json-rpc* glob: 'json-rpc-native' also matches one, exposes the
|
|
# same WebSocket, and is a GraalVM build with no AWT -- so every attachment
|
|
# send fails with "Can't load library: awt" while text replies work, which
|
|
# looks like a bug in the bot. Accepting it is how that returns.
|
|
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', not 'json-rpc'." >&2
|
|
echo " Recreate the container with -e MODE=json-rpc." >&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"
|
|
}
|
|
|
|
# Give the account a profile before anyone adds it to a group.
|
|
#
|
|
# Signal only adds an account to a group outright when the inviter already
|
|
# holds its profile key; with no profile there is no key to hold, so the
|
|
# add silently degrades into a pending *invitation* -- and signal-cli
|
|
# cannot accept one addressed to a phone number ("Cannot find service ID
|
|
# for self to accept invite"). That dead end cost a whole session on
|
|
# 2026-08-29: the bot looked healthy and received nothing, for hours.
|
|
signal_set_profile() {
|
|
local number="$1" name="$2" status
|
|
status="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT \
|
|
"${API}/v1/profiles/${number}" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"name\":\"${name}\"}")"
|
|
if [[ "$status" != "2"* ]]; then
|
|
echo "Error: could not set the bot's profile (HTTP $status)." >&2
|
|
echo " Without a profile, adding the bot to a group creates an" >&2
|
|
echo " invitation it can never accept." >&2
|
|
return 1
|
|
fi
|
|
echo "== profile set to '${name}' (required before joining a group)"
|
|
}
|
|
|
|
# Refuse to finish on a group the bot was only *invited* to.
|
|
signal_require_membership() {
|
|
local number="$1" group="$2" detail
|
|
detail="$(curl -sf "${API}/v1/groups/${number}/${group}")"
|
|
if [[ "$(printf '%s' "$detail" | jq -r '.member')" == "true" ]]; then
|
|
echo "== the bot is a full member of $(printf '%s' "$detail" | jq -r '.name')"
|
|
return 0
|
|
fi
|
|
echo "Error: the bot is only INVITED to this group, not a member." >&2
|
|
echo " It would receive nothing. signal-cli cannot accept an" >&2
|
|
echo " invite addressed to a phone number." >&2
|
|
echo " Fix: in Signal, remove the pending invite for ${number}," >&2
|
|
echo " then add it again -- now that it has a profile, Signal" >&2
|
|
echo " adds it as a real member instead of inviting it." >&2
|
|
return 1
|
|
}
|
|
|
|
# Print the first free TCP port at or after $1.
|
|
#
|
|
# The bot's default is 8137, but writing whatever is actually free means a
|
|
# machine that already runs something there gets a working .env instead of
|
|
# a bot that refuses to start.
|
|
signal_free_port() {
|
|
local port="$1" limit=$(($1 + 50))
|
|
while [[ $port -lt $limit ]]; do
|
|
if ! ss -ltn "sport = :${port}" 2>/dev/null | grep -q LISTEN; then
|
|
printf '%s' "$port"
|
|
return 0
|
|
fi
|
|
port=$((port + 1))
|
|
done
|
|
echo "Error: no free port between $1 and ${limit}" >&2
|
|
return 1
|
|
}
|
|
|
|
# Write .env for the bot.
|
|
#
|
|
# The third argument decides whether GROUP_IDS is written at all, and it is
|
|
# not a style choice:
|
|
#
|
|
# own account (register_bot.sh) -> no GROUP_IDS. The bot is its own Signal
|
|
# identity, so "every group it is in" is exactly the groups somebody
|
|
# added it to. Adding it to a group is then the entire procedure.
|
|
# linked device (live_smoke.sh) -> GROUP_IDS pinned to the chosen group.
|
|
# A linked device rides a PERSONAL account and receives every group on
|
|
# it, so an unpinned bot there would read and answer in all of them.
|
|
#
|
|
# Getting this backwards is the one way discovery becomes a privacy problem,
|
|
# which is why the mode picks the value rather than the operator remembering.
|
|
signal_write_env() {
|
|
local repo_root="$1" number="$2" group="$3" mode="${4:-linked}" port
|
|
port="$(signal_free_port "${BOT_PORT_BASE:-8137}")"
|
|
cat > "${repo_root}/.env" <<ENV
|
|
PHONE_NUMBER=${number}
|
|
API_BASE_URL=${API}
|
|
DATABASE_PATH=${repo_root}/signal_bot.sqlite3
|
|
BIND_PORT=${port}
|
|
ENV
|
|
if [[ "$mode" == "linked" ]]; then
|
|
cat >> "${repo_root}/.env" <<ENV
|
|
# A linked device sees every group on the account it is linked to, so this
|
|
# bot is pinned. Remove this line only if the account is the bot's own.
|
|
GROUP_IDS=${group}
|
|
ENV
|
|
else
|
|
cat >> "${repo_root}/.env" <<ENV
|
|
# No GROUP_IDS: the bot answers in every group this account is added to, and
|
|
# discovers new ones by itself. Set it to a comma-separated list to pin it.
|
|
ENV
|
|
fi
|
|
echo "== wrote ${repo_root}/.env"
|
|
}
|