signal-bot/scripts/lib_signal.sh
Krzysztof kuhy Rudnicki 5d76adf4db feat: watch several groups, and answer only where you were asked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:03:24 +02:00

152 lines
5.5 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. GROUP_IDS is a comma-separated list; replies always
# go back to whichever of those groups the message came from.
signal_write_env() {
local repo_root="$1" number="$2" group="$3" port
port="$(signal_free_port "${BOT_PORT_BASE:-8137}")"
cat > "${repo_root}/.env" <<ENV
PHONE_NUMBER=${number}
GROUP_IDS=${group}
API_BASE_URL=${API}
DATABASE_PATH=${repo_root}/signal_bot.sqlite3
BIND_PORT=${port}
ENV
echo "== wrote ${repo_root}/.env"
}