diff --git a/.env.example b/.env.example index 93837e8..ba4713b 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ TELEGRAM_ALLOWED_USERS= # MATRIX_ACCESS_TOKEN=... # MATRIX_ALLOWED_USERS=@you:matrix.example.org (admins; replaces TELEGRAM_*) # MATRIX_GUEST_USERS= (read-only subset) +# MATRIX_NOTIFY_ROOM=!roomid:matrix.example.org (where bin/notify posts +# scheduled alerts; the bot must be a member of the room) # The bot auto-joins room invites, but only from allowed/guest users. # OGMA_TRANSPORT=telegram diff --git a/.gitignore b/.gitignore index 90019be..20a16a8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ bin/* !bin/ogmactl !bin/restore !bin/setup +!bin/notify !bin/tg-send !bin/uninstall diff --git a/CHANGELOG.md b/CHANGELOG.md index be9c0d4..35c966b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project aims to follow [Semantic Versioning](https://semver.org/). +## [2.3.0] — 2026-07-09 + +### Added +- **`bin/notify` — transport-aware notifications.** Scheduled routines (backup, + health-check, briefing, security checks, watchers) used to push alerts through + `bin/tg-send`, which always spoke Telegram — after switching the gateway to + `OGMA_TRANSPORT=matrix` they would keep posting to a channel nobody watches. + `bin/notify` dispatches on the same `OGMA_TRANSPORT`/credential config as the + gateway (real env > `.env` > `config/matrix_bot.env.local`); the matrix + backend posts to `MATRIX_NOTIFY_ROOM` (new setting — the bot must be a member). + Access tokens travel to curl via `--config` on a private fd, never argv. + `bin/tg-send` remains as a deprecated shim (`exec notify "$@"`) so host-local + callers keep working; all in-repo callers now use notify. + ## [2.2.0] — 2026-07-09 ### Added diff --git a/README.md b/README.md index c0d535b..a0d6348 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ CORE — the command runner (stdlib only, no Claude anywhere) transport_matrix.py the same seam speaking Matrix (self-hosted homeserver) bin/ogmactl the ONLY executable the bot can run (whitelisted subcommands) bin/ogmactl.local + config/commands.local.json — YOUR commands (docs/extending.md) - bin/setup|backup|restore|health-check|logtrim|tg-send, systemd/, tickets/ + bin/setup|backup|restore|health-check|logtrim|notify, systemd/, tickets/ LLM LAYER (optional; OGMA_LLM=claude) — free-text chat llm_claude.py headless `claude -p` runs, per-chat resumable sessions diff --git a/bin/backup b/bin/backup index 0aa95b5..14219cb 100755 --- a/bin/backup +++ b/bin/backup @@ -125,7 +125,7 @@ fi if [ ! -s "$mf" ]; then warn "no host-local files found to back up — nothing to do." - [ "$NOTIFY" = 1 ] && printf '🗄️ Ogma backup: nothing to archive (no host-local files).' | "$OGMA_DIR/bin/tg-send" 2>/dev/null + [ "$NOTIFY" = 1 ] && printf '🗄️ Ogma backup: nothing to archive (no host-local files).' | "$OGMA_DIR/bin/notify" 2>/dev/null exit 0 fi @@ -143,7 +143,7 @@ if [ "$FORCE" = 0 ] && [ -n "$fingerprint" ] \ && [ "$(cat "$state_file" 2>/dev/null)" = "$fingerprint" ] \ && [ -n "$(list_archives | tail -n 1)" ]; then ok "No changes since last backup ($count files) — skipping. Use --force to archive anyway." - [ "$NOTIFY" = 1 ] && printf '🗄️ Ogma backup: no changes since last run — skipped.' | "$OGMA_DIR/bin/tg-send" 2>/dev/null + [ "$NOTIFY" = 1 ] && printf '🗄️ Ogma backup: no changes since last run — skipped.' | "$OGMA_DIR/bin/notify" 2>/dev/null exit 0 fi @@ -170,7 +170,7 @@ if tar -C "$OGMA_DIR" --null -czf "$archive.partial" -T "$mf" 2>/dev/null; then printf '%s\n' "$fingerprint" > "$state_file" 2>/dev/null || warn "could not record backup state in $OUT" else err "tar failed — archive not written." - [ "$NOTIFY" = 1 ] && printf '⚠️ Ogma backup FAILED on %s (tar error).' "$HOST" | "$OGMA_DIR/bin/tg-send" 2>/dev/null + [ "$NOTIFY" = 1 ] && printf '⚠️ Ogma backup FAILED on %s (tar error).' "$HOST" | "$OGMA_DIR/bin/notify" 2>/dev/null rm -f "$archive.partial" 2>/dev/null exit 1 fi @@ -184,5 +184,5 @@ if (( KEEP > 0 )); then fi fi -[ "$NOTIFY" = 1 ] && printf '%s' "$result" | "$OGMA_DIR/bin/tg-send" 2>/dev/null +[ "$NOTIFY" = 1 ] && printf '%s' "$result" | "$OGMA_DIR/bin/notify" 2>/dev/null exit 0 diff --git a/bin/briefing b/bin/briefing index f918c07..34553cc 100755 --- a/bin/briefing +++ b/bin/briefing @@ -106,7 +106,7 @@ out="$(claude -p "$prompt" \ if [ -z "$out" ]; then echo "$(date '+%F %T') briefing FAILED to generate" >&2 - [ "${BRIEFING_DRYRUN:-0}" = 1 ] || "$BIN/tg-send" "⚠️ Morning briefing failed to generate ($(date '+%H:%M'))." + [ "${BRIEFING_DRYRUN:-0}" = 1 ] || "$BIN/notify" "⚠️ Morning briefing failed to generate ($(date '+%H:%M'))." exit 1 fi @@ -118,6 +118,6 @@ if [ "${BRIEFING_DRYRUN:-0}" = 1 ]; then exit 0 fi -if "$BIN/tg-send" "$out"; then +if "$BIN/notify" "$out"; then echo "$now" > "$LAST" # only advance the window after a successful send fi diff --git a/bin/health-check b/bin/health-check index b469ecf..3353b60 100755 --- a/bin/health-check +++ b/bin/health-check @@ -3,7 +3,7 @@ # # Reads CPU temp, 1-min load, disk usage on /, and available RAM directly from # the kernel. If any threshold is crossed it sends ONE Telegram alert via -# tg-send, then suppresses repeats for COOLDOWN seconds via a lockfile. A clean +# bin/notify, then suppresses repeats for COOLDOWN seconds via a lockfile. A clean # (healthy) check clears the lock, so a fresh breach alerts immediately. # # Runs every ~5 min from ogma-health.timer (systemd --user). No Claude in the @@ -64,8 +64,8 @@ fi host="$(hostname)" msg="$(printf '⚠️ Ogma health alert on %s:\n%s' "$host" "$(printf '%s\n' "${alerts[@]}")")" -if "$BIN/tg-send" "$msg"; then +if "$BIN/notify" "$msg"; then touch "$LOCK" else - echo "health-check: tg-send failed" >&2 + echo "health-check: notify failed" >&2 fi diff --git a/bin/notify b/bin/notify new file mode 100755 index 0000000..55f2393 --- /dev/null +++ b/bin/notify @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# notify — push a message to the owner over the ACTIVE Ogma transport. +# Reusable delivery for any scheduled routine. Message comes from $1 or stdin. +# Dispatches on OGMA_TRANSPORT (telegram is the default) and reads the same +# files as the gateway: .env, plus config/matrix_bot.env.local for matrix. +# bin/tg-send is a back-compat shim over this — new callers use notify. +set -uo pipefail + +BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV="$BASE/.env" +MATRIX_ENV="$BASE/config/matrix_bot.env.local" +. "$BASE/bin/_env.sh" + +# cfg — real environment wins, then .env, then the matrix creds file: +# the same precedence gateway.py's load_env() produces. +cfg() { + local v="${!1-}" + [ -n "$v" ] || v="$(env_get "$ENV" "$1")" + [ -n "$v" ] || v="$(env_get "$MATRIX_ENV" "$1")" + printf '%s' "$v" +} + +msg="${1:-$(cat)}" +[ -n "$msg" ] || { echo "notify: empty message" >&2; exit 1; } +msg="${msg:0:4000}" # both backends cap a single message around this size + +transport="$(cfg OGMA_TRANSPORT)" +case "${transport:-telegram}" in + + telegram) + tok="$(cfg TELEGRAM_BOT_TOKEN)" + users="$(cfg TELEGRAM_ALLOWED_USERS | tr ',' ' ')" + [ -n "$tok" ] || { echo "notify: no TELEGRAM_BOT_TOKEN in .env" >&2; exit 1; } + [ -n "$users" ] || { echo "notify: no TELEGRAM_ALLOWED_USERS in .env" >&2; exit 1; } + rc=0 + for chat in $users; do + # The URL carries the bot token, so it goes to curl via --config on a + # private fd instead of the command line (argv is world-readable in + # /proc while curl runs). + curl -sS -m 20 --config <(printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$tok") \ + --data-urlencode "chat_id=${chat}" \ + --data-urlencode "text=${msg}" >/dev/null || rc=1 + done + exit $rc + ;; + + matrix) + hs="$(cfg MATRIX_HOMESERVER)" + tok="$(cfg MATRIX_ACCESS_TOKEN)" + room="$(cfg MATRIX_NOTIFY_ROOM)" + [ -n "$hs" ] && [ -n "$tok" ] || { + echo "notify: MATRIX_HOMESERVER/MATRIX_ACCESS_TOKEN not set" >&2; exit 1; } + [ -n "$room" ] || { + echo "notify: MATRIX_NOTIFY_ROOM not set — the room id scheduled" \ + "notifications go to (the bot must be a member)" >&2; exit 1; } + room_q="$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$room")" + body="$(printf '%s' "$msg" | python3 -c 'import sys,json;print(json.dumps({"msgtype":"m.text","body":sys.stdin.read()}))')" + # Same /proc-argv rule as above: the access token travels in a header, so + # the header line goes to curl via --config on a private fd. + curl -sS -m 20 -X PUT \ + --config <(printf 'header = "Authorization: Bearer %s"\n' "$tok") \ + -H 'Content-Type: application/json' --data-binary "$body" \ + "${hs%/}/_matrix/client/v3/rooms/${room_q}/send/m.room.message/notify$(date +%s%N)" \ + >/dev/null + ;; + + *) + # An unknown transport is a misconfiguration, not a reason to guess where + # an alert should go. + echo "notify: unknown OGMA_TRANSPORT '${transport}' (use 'telegram' or 'matrix')" >&2 + exit 1 + ;; +esac diff --git a/bin/setup b/bin/setup index 9109abf..876b2a8 100755 --- a/bin/setup +++ b/bin/setup @@ -112,7 +112,7 @@ say "This configures your own copy. Nothing is sent anywhere; secrets you enter step "Checking prerequisites" command -v "$PY" >/dev/null || { err "python3 not found — install it first."; exit 1; } ok "python3: $($PY --version 2>&1)" -command -v curl >/dev/null && ok "curl present" || warn "curl not found — Telegram delivery (tg-send/briefing) needs it." +command -v curl >/dev/null && ok "curl present" || warn "curl not found — message delivery (notify/briefing) needs it." CLAUDE_BIN="$(command -v claude || true)"; [ -n "$CLAUDE_BIN" ] || CLAUDE_BIN="$HOME/.local/bin/claude" if [ -x "$CLAUDE_BIN" ]; then ok "claude CLI: $CLAUDE_BIN"; else warn "claude CLI not found at $CLAUDE_BIN — that's fine for command-only mode; install Claude Code (or set CLAUDE_BIN in .env) to enable the assistant layer."; fi HAVE_SYSTEMD=0; if command -v systemctl >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then HAVE_SYSTEMD=1; ok "systemd --user available"; else warn "systemd --user not available — you'll run the gateway manually."; fi diff --git a/bin/tg-send b/bin/tg-send index 126c062..a5b46ce 100755 --- a/bin/tg-send +++ b/bin/tg-send @@ -1,27 +1,5 @@ #!/usr/bin/env bash -# tg-send — push a message to the Ogma Telegram chat(s). -# Reusable delivery for any scheduled routine. Message comes from $1 or stdin. -# Reads token + allowed chat IDs straight from the gateway's .env. -set -uo pipefail - -BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ENV="$BASE/.env" -. "$BASE/bin/_env.sh" -tok="$(env_get "$ENV" TELEGRAM_BOT_TOKEN)" -users="$(env_get "$ENV" TELEGRAM_ALLOWED_USERS | tr ',' ' ')" -[ -n "$tok" ] || { echo "tg-send: no TELEGRAM_BOT_TOKEN in .env" >&2; exit 1; } -[ -n "$users" ] || { echo "tg-send: no TELEGRAM_ALLOWED_USERS in .env" >&2; exit 1; } - -msg="${1:-$(cat)}" -[ -n "$msg" ] || { echo "tg-send: empty message" >&2; exit 1; } -msg="${msg:0:4000}" # stay under Telegram's 4096-char limit - -rc=0 -for chat in $users; do - # The URL carries the bot token, so it goes to curl via --config on a private fd - # instead of the command line (argv is world-readable in /proc while curl runs). - curl -sS -m 20 --config <(printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$tok") \ - --data-urlencode "chat_id=${chat}" \ - --data-urlencode "text=${msg}" >/dev/null || rc=1 -done -exit $rc +# tg-send — deprecated name, kept so existing host-local scripts keep working. +# Delivery lives in bin/notify now, which routes over the active transport +# (OGMA_TRANSPORT) instead of assuming Telegram. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/notify" "$@"