fluid-telegram/scripts/send-post.sh
tegwick 86cbb43965 FT-WP-0001-T14: upload post images instead of linking them
The adapter fetches visual_url from an allowlisted host (https, image types,
10 MB cap, optional read token) and uploads it as multipart sendPhoto, so the
forge can close without breaking post images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5457@bnt-lap001
Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
2026-10-08 20:55:32 +02:00

102 lines
4.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# Send one prepared post (pr-hall-of-helix/posts/<file>.yaml) through a
# short-lived local adapter, then stop it.
#
# The bot token goes from OpenBao straight into the adapter's environment and
# is never printed. The adapter's store is in memory, so idempotency on post_id
# holds only within one run: do not re-run for a post that already went out.
#
# Run it yourself inside the attended OpenBao login. warden fails closed on any
# output, so redirect both streams, and use absolute paths (HOME is replaced):
#
# warden access openbao-platform-admin-login --exec -- bash -c \
# '/home/worsch/fluid-telegram/scripts/send-post.sh \
# /home/worsch/pr-hall-of-helix/posts/<post>.yaml test \
# > /home/worsch/send-post.log 2>&1'
set -euo pipefail
post=$(realpath "$1")
channel=${2:-test}
port=${PORT:-18089}
cd "$(dirname "$(realpath "$0")")/.."
spec=${SPEC:-$(realpath ../pr-hall-of-helix/presence/telegram.yaml)}
# Under `warden access ... --exec`, HOME is a throwaway session directory that
# holds the OpenBao token helper. Keep Go's caches at the real home so the
# build does not start from nothing there.
realhome=$(getent passwd "$(id -u)" | cut -d: -f6)
export GOPATH=${GOPATH:-$realhome/go}
export GOMODCACHE=${GOMODCACHE:-$GOPATH/pkg/mod}
export GOCACHE=${GOCACHE:-$realhome/.cache/go-build}
read -r want reviewed < <(python3 -c '
import sys, yaml
d = yaml.safe_load(open(sys.argv[1]))
r = str(d.get("reviewed_by") or "").strip()
print(d["channel"], r if r and not r.startswith("<") else "-")' "$post")
[ "$reviewed" != "-" ] || { echo "reviewed_by is not signed; a person reviews and signs before sending" >&2; exit 2; }
TELEGRAM_CHANNEL_ID=$(go run ./cmd/provision adapter-config --spec "$spec" --channel "$channel" \
| sed -n 's/^TELEGRAM_CHANNEL_ID=//p')
[ -n "$TELEGRAM_CHANNEL_ID" ] || { echo "no chat id for channel '$channel'" >&2; exit 2; }
tmp=$(mktemp -d)
go build -o "$tmp/adapter" ./cmd/adapter
go build -o "$tmp/compose" ./cmd/compose
# The image, resolved before any credential is touched. A post's own
# request.visual_url wins; otherwise its source entry's rendered portrait;
# an entry with only a visual prompt is held until the portrait is rendered;
# a post with no prompt at all (an outlet post) carries the campaign image.
# Public posts always carry an image.
hall=${HALL_DIR:-$(realpath ../hall-of-helix)}
forge=${FORGE_RAW:-https://forgejo.coulomb.social/coulomb}
python3 - "$post" "$channel" "$tmp" "$hall" "$forge" <<'PY' || { rm -rf "$tmp"; exit 2; }
import json, os, subprocess, sys, yaml
post, channel, tmp, hall, forge = sys.argv[1:]
d = yaml.safe_load(open(post))
req = d["request"]
req["reviewed_by"] = str(d["reviewed_by"]).strip() # the file's sign-off is the review
if not req.get("visual_url"):
cmd = [f"{tmp}/compose", "visual", "--hall-dir", hall,
"--hall-raw-base", f"{forge}/hall-of-helix/raw/branch/main",
"--fallback", f"{forge}/pr-hall-of-helix/raw/branch/main/assets/helix-forge.png"]
if d.get("source_entry_path"):
cmd += ["--entry", os.path.join(hall, d["source_entry_path"])]
v = json.loads(subprocess.run(cmd, check=True, capture_output=True, text=True).stdout)
if v["kind"] == "needs-render":
sys.exit(f"held: the source entry has a visual prompt but no rendered portrait "
f"(expected at {v.get('target') or 'an unnamed visuals/ file'}); render it into the hall first")
req["visual_url"] = v["visual_url"]
print(f"image: {v['kind']} {v['visual_url']}")
else:
print(f"image: post {req['visual_url']}")
if channel == "public" and not req.get("visual_url"):
sys.exit("public posts carry an image")
json.dump(req, open(f"{tmp}/request.json", "w"))
PY
# Read the KV v2 data path directly: `bao kv get` first queries
# sys/internal/ui/mounts, which a workload-scoped token may not be allowed to.
token=$(bao read -format=json \
platform/data/workloads/infotech/fluid-telegram/hall-of-helix/bot-token \
| python3 -c 'import sys, json; print(json.load(sys.stdin)["data"]["data"]["token"])') \
|| { rm -rf "$tmp"; echo "could not read the bot token from OpenBao (logged in? read on platform/data/workloads/infotech/fluid-telegram/hall-of-helix/*?)" >&2; exit 2; }
# Images are fetched by the adapter and uploaded (FT-WP-0001-T14), so the
# forge need not serve them publicly. FORGE_READ_TOKEN, when set, is a
# read-only forge identity for closed repositories.
TELEGRAM_BOT_TOKEN=$token TELEGRAM_CHANNEL_ID=$TELEGRAM_CHANNEL_ID ATTACH_VISUAL=true \
UPLOAD_VISUALS=true FORGE_READ_TOKEN=${FORGE_READ_TOKEN:-} \
ADAPTER_LISTEN_ADDR=127.0.0.1:$port "$tmp/adapter" &
pid=$!
unset token
trap 'kill $pid 2>/dev/null; rm -rf "$tmp"' EXIT
for _ in $(seq 50); do
curl -s -o /dev/null "http://127.0.0.1:$port/" && break
sleep 0.1
done
curl -sS -w '\nHTTP %{http_code}\n' -X POST -H 'Content-Type: application/json' \
--data-binary @"$tmp/request.json" "http://127.0.0.1:$port/v1/channel-posts"