signal-bot/scripts/install_service.sh
Krzysztof kuhy Rudnicki a24d0d5905
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:54:49 +02:00

213 lines
7.6 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Install, start and verify the signal-bot user service.
#
# A *user* unit, not a system one: the bot runs as whoever registered the
# Signal account and reads that user's .env. Lingering is what makes it start
# at boot with nobody logged in -- without it, "survives reboots" is a lie the
# unit file cannot tell you about, so this script checks and fails loudly.
# ============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly REPO_ROOT
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly UNIT_NAME="signal-bot.service"
readonly UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
readonly TEMPLATE="${REPO_ROOT}/scripts/${UNIT_NAME}"
readonly WATCHDOG_UNITS=("signal-api-watchdog.service" "signal-api-watchdog.timer")
readonly WATCHDOG_TIMER="signal-api-watchdog.timer"
readonly DEFAULT_PORT=8137
VERIFY_ONLY="false"
# shellcheck source=scripts/lib_signal.sh
source "${REPO_ROOT}/scripts/lib_signal.sh"
usage() {
echo "Usage: $SCRIPT_NAME [--verify-only]"
echo " --verify-only Check the running service without reinstalling it."
exit 0
}
die() {
echo "Error: $1" >&2
exit 1
}
# The port the bot will actually bind: .env wins, code default otherwise.
bot_port() {
local port=""
if [[ -f "${REPO_ROOT}/.env" ]]; then
port="$(sed -n 's/^BIND_PORT=[[:space:]]*//p' "${REPO_ROOT}/.env" | tail -1)"
fi
printf '%s' "${port:-$DEFAULT_PORT}"
}
# exiftool is what !add_photo strips uploads with, and the strip is not
# optional: without it every upload is refused rather than stored dirty.
# Installed here rather than documented, per the repo's own rule that a
# script owns its dependencies.
ensure_exiftool() {
if ! command -v exiftool >/dev/null; then
echo "Installing perl-image-exiftool (needed by !add_photo)..."
if ! sudo pacman -S --needed --noconfirm perl-image-exiftool; then
die "could not install perl-image-exiftool; run: sudo pacman -S perl-image-exiftool"
fi
fi
# `command -v` answers for *this shell*, which is not who has to find it.
# Arch installs to /usr/bin/vendor_perl, on an interactive PATH and not on
# a systemd unit's -- so on 2026-08-31 the bot refused every upload while
# exiftool sat plainly on the installer's PATH. Ask the bot's own resolver.
if ! env -i "PATH=/usr/local/bin:/usr/bin" "${REPO_ROOT}/.venv/bin/python" -c \
'import sys; from signal_bot.media_strip import locate; sys.exit(0 if locate() else 1)'; then
die "exiftool is installed but signal_bot.media_strip.locate() cannot find it under the unit's PATH"
fi
}
# ffmpeg drops the metadata *tracks* exiftool cannot see. An iPhone clip
# carries mebx timed-metadata holding per-frame face bounding boxes and a
# face id, and those survive a complete "-all= -time:all=" pass because they
# live in mdat as opaque samples, not as tags. Without ffmpeg every video is
# refused rather than stored with them.
ensure_ffmpeg() {
if ! command -v ffmpeg >/dev/null || ! command -v ffprobe >/dev/null; then
echo "Installing ffmpeg (needed to store video)..."
if ! sudo pacman -S --needed --noconfirm ffmpeg; then
die "could not install ffmpeg; run: sudo pacman -S ffmpeg"
fi
fi
# Same trap as exiftool: ask the bot's own resolver, under the unit's PATH.
if ! env -i "PATH=/usr/local/bin:/usr/bin" "${REPO_ROOT}/.venv/bin/python" -c \
'import sys; from signal_bot.media_tracks import locate; sys.exit(0 if locate("ffmpeg") and locate("ffprobe") else 1)'; then
die "ffmpeg is installed but signal_bot.media_tracks.locate() cannot find it under the unit's PATH"
fi
}
# The management page is built, not committed: dist/ is a build output and
# this repository does not carry those. Verified by looking at the artefact
# rather than trusting the exit code, the same way exiftool is.
build_web() {
local web="${REPO_ROOT}/web"
if ! command -v pnpm >/dev/null; then
echo "Installing pnpm (the management page is built with it)..."
if ! sudo pacman -S --needed --noconfirm pnpm; then
die "could not install pnpm; run: sudo pacman -S pnpm"
fi
fi
command -v node >/dev/null || die "node not found; the page needs node >= 24"
echo "Building the management page..."
(cd "$web" && pnpm install --frozen-lockfile && pnpm build) ||
die "pnpm build failed in ${web}"
[[ -f "${web}/dist/index.html" ]] ||
die "pnpm build reported success but ${web}/dist/index.html is not there"
}
validate_requirements() {
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
ensure_exiftool
ensure_ffmpeg
build_web
[[ -f "${REPO_ROOT}/.env" ]] ||
die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first"
[[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh"
[[ -f "$TEMPLATE" ]] || die "no unit template at $TEMPLATE"
[[ -x "${REPO_ROOT}/scripts/signal_api_watchdog.sh" ]] ||
die "no executable ${REPO_ROOT}/scripts/signal_api_watchdog.sh"
local unit
for unit in "${WATCHDOG_UNITS[@]}"; do
[[ -f "${REPO_ROOT}/scripts/${unit}" ]] || die "no unit template for ${unit}"
done
}
# Without linger, a user unit dies at logout and never starts at boot.
require_linger() {
if [[ "$(loginctl show-user "$USER" --property=Linger --value)" == "yes" ]]; then
echo "== linger enabled: the bot starts at boot with nobody logged in"
return 0
fi
echo "== enabling linger for $USER"
if ! sudo -n loginctl enable-linger "$USER" 2>/dev/null; then
echo "Error: linger is off and sudo needs a password here." >&2
echo " Without it the bot will NOT come back after a reboot." >&2
echo " Run this, then re-run with --verify-only:" >&2
echo >&2
echo " sudo loginctl enable-linger $USER" >&2
return 1
fi
}
install_unit() {
mkdir -p "$UNIT_DIR"
sed "s|@REPO_ROOT@|${REPO_ROOT}|g" "$TEMPLATE" > "${UNIT_DIR}/${UNIT_NAME}"
systemctl --user daemon-reload
systemctl --user enable "$UNIT_NAME" >/dev/null
systemctl --user restart "$UNIT_NAME"
echo "== installed and started ${UNIT_DIR}/${UNIT_NAME}"
}
# The bot cannot restart the container it depends on from inside a wedged
# event loop, so recovery from a dead Signal connection lives in a timer.
install_watchdog() {
local unit
for unit in "${WATCHDOG_UNITS[@]}"; do
sed "s|@REPO_ROOT@|${REPO_ROOT}|g" "${REPO_ROOT}/scripts/${unit}" >"${UNIT_DIR}/${unit}"
done
systemctl --user daemon-reload
systemctl --user enable --now "$WATCHDOG_TIMER" >/dev/null
echo "== installed and armed ${UNIT_DIR}/${WATCHDOG_TIMER}"
}
# Report what actually happened, not that the commands returned 0.
verify() {
local port="$1"
if ! systemctl --user is-active --quiet "$UNIT_NAME"; then
echo "Error: ${UNIT_NAME} is not active." >&2
systemctl --user --no-pager --lines=20 status "$UNIT_NAME" >&2 || true
return 1
fi
signal_wait_for_url "http://127.0.0.1:${port}/healthz" "signal-bot" 60
echo "== active, and answering on http://127.0.0.1:${port}/healthz"
echo "== enabled at boot: $(systemctl --user is-enabled "$UNIT_NAME")"
if ! systemctl --user is-active --quiet "$WATCHDOG_TIMER"; then
echo "Error: ${WATCHDOG_TIMER} is not active; nothing will recover a dead" >&2
echo " connection to Signal automatically." >&2
return 1
fi
systemctl --user list-timers --no-pager "$WATCHDOG_TIMER" | sed -n '2p'
}
main() {
validate_requirements
local port
port="$(bot_port)"
if [[ "$VERIFY_ONLY" != "true" ]]; then
require_linger
install_unit
install_watchdog
fi
verify "$port"
echo "== logs: journalctl --user -u ${UNIT_NAME} -f"
}
while [[ $# -gt 0 ]]; do
case $1 in
--verify-only)
VERIFY_ONLY="true"
shift
;;
-h | --help) usage ;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
main "$@"