# 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" <> "${repo_root}/.env" <> "${repo_root}/.env" <