mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:17:02 +01:00
tool/tagger.py did not run at all: 48957a1 renamed pack.default_dir to
default_dirs and missed the caller, so the tagger raised ImportError at
module scope through two releases. Nothing could have caught it -- tool/ is
outside --cov and mypy ran with [signal_bot, fake_signal]. Both are widened
here, and tests/test_tool_imports.py imports every script under tool/.
--import was a filename check and shutil.copy2. Pointed at a phone export
that overlaps the album it re-imported the same photo under whatever name
the exporter used that time. It now identifies files by magic bytes, strips
them through the same media_strip path !add_photo uses, and dedups on exact
bytes twice: the source hash, then -- only for what survives -- the hash
after stripping. sources.json records both, so a second run on the same
folder is a no-op. What the album refuses is moved to _skipped/<reason>/
beside the source, never deleted.
Two things that made "strip on import" impossible until now:
media_tags claims -all= zeroes the QuickTime date tags and allows them by
value. It does not; measured, it leaves CreateDate at its real value, and
every mp4 was being refused. -time:all= is what zeroes them.
And a tag allowlist cannot see the worst class at all. IMG_6924.mp4 carries
mebx timed-metadata holding per-frame face bounding boxes and a face id,
and those survive a complete tag strip because they live in mdat as opaque
samples. media_tracks.py drops the track with ffmpeg and verifies with
ffprobe that only video and audio survived. This is not the ffmpeg mistake
media_strip warns about: that was ffmpeg replacing exiftool for tags, this
is track removal with exiftool still doing every tag, and the gate is a
positive read-back rather than an exit code.
The roster was write-once. The setup form only appeared on an empty album,
Album.setup overwrote the file whole, and keys were positional (p{len+1}),
so inserting somebody renumbered everyone after them and repointed every
tags.json entry at the wrong person. Keys now come from the highest number
in use, the form merges, and a person can be added mid-session.
--untagged filters the page and never the server's list. /media/{index} and
/api/tag both index the full album, so a shorter list server-side would
show item i of the album while tagging item i of the filtered set.
Measured on the live album: 174 source files -> 1 not media, 82 already in
pack, 24 duplicate in batch, 67 imported, and re-running imports nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
193 lines
6.8 KiB
Bash
Executable File
193 lines
6.8 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
|
|
}
|
|
|
|
validate_requirements() {
|
|
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
|
|
ensure_exiftool
|
|
ensure_ffmpeg
|
|
[[ -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 "$@"
|