Files
claudetools/.claude/scripts/ask-forum.sh
Howard Enos 0a438929d0 ask-forum: harden per high-effort review + document capability limits
Fixes from the code-review workflow (7 findings): page the poll cursor past the
100-msg window (no false timeout behind bot/overflow messages); honor 429
retry_after and bail fast on permanent Discord errors (10003/50001/...) instead
of burning the timeout; status-check overflow follow-up chunks (warn, don't
silently truncate); clamp --read limit safely (guard >64-bit overflow); add
--wait --after for precise resumption; DRY the newline-trim; set LC_ALL=C.UTF-8
for codepoint-safe slicing. SKILL.md gains the tested bot capability/limit map
(can: post/read/edit-own/react; cannot without a grant: delete/archive/unlock/pin)
and a robustness section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:37:46 -07:00

248 lines
12 KiB
Bash

#!/usr/bin/env bash
# ask-forum.sh — ask a human a question in the #ct-forum Discord forum and BLOCK
# until a person answers, then print their answer and exit. Built for a live
# three-way: the user in the terminal, THIS Claude session, and a teammate (e.g.
# Mike) in the forum — all the same session. The wait loop runs here in bash, so
# the calling model pays for ONE tool call, not a polling loop.
#
# Usage:
# ask-forum.sh "question text" [--title "short title"] [--timeout SEC] [--poll SEC] [--tag @id ...]
# echo "question" | ask-forum.sh [flags]
# ask-forum.sh --read <thread_id> [limit] # one-shot read, no wait
# ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]
#
# Flow (ask): POST a forum post (thread) with the question, then poll that thread
# for the first NON-BOT reply (human answer), print it, exit 0.
#
# Correlation is automatic: we create the thread and read only that thread.
# Exit codes: 0 answered; 1 usage; 2 token missing; 3 Discord API error; 4 timeout.
set -u
export LC_ALL="${LC_ALL:-C.UTF-8}" # count code points, not bytes, when slicing $CONTENT
ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
API="https://discord.com/api/v10"
UA="ClaudeToolsBot (claudetools, 1.0)"
FORUM_ID="1522960388432465950" # #ct-forum (private forum; Howard + bot + Mike)
LIMIT_CHARS=1900 # Discord caps message content at 2000
TIMEOUT=600
POLL=8
TITLE=""
TAGS=()
MSG=""
# --- token resolver (shared by read + wait + ask) ---
resolve_token() {
local t
t="$(bash "$ROOT/.claude/scripts/vault.sh" get-field \
projects/discord-bot/bot-token.sops.yaml credentials.bot_token 2>/dev/null)"
if [ -z "$t" ] || [ "$t" = "null" ]; then
local env_file="$ROOT/projects/discord-bot/.env"
[ -f "$env_file" ] && t="$(grep -iE '^[[:space:]]*DISCORD_TOKEN[[:space:]]*=' "$env_file" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'"'"']//; s/["'"'"'][[:space:]]*$//')"
fi
printf '%s' "$t"
}
# trim a string to the last newline within it (keeps split chunks on line boundaries)
trim_at_newline() {
local s="$1" nl
nl="${s%$'\n'*}"
if [ "${#nl}" -lt "${#s}" ] && [ "${#nl}" -gt 0 ]; then printf '%s' "$nl"; else printf '%s' "$s"; fi
}
# clamp a read limit to Discord's valid 1..100 (guard huge values that overflow bash math)
clamp_limit() {
local n="$1"
printf '%s' "$n" | grep -qE '^[0-9]+$' || { printf '25'; return; }
[ "${#n}" -ge 4 ] && { printf '100'; return; } # >=1000 always exceeds 100
[ "$n" -lt 1 ] && n=1; [ "$n" -gt 100 ] && n=100
printf '%s' "$n"
}
# Given a non-array poll response body, decide what to do. Echoes: "retry" (transient/
# rate-limited — sleeps any retry_after first) or "bail:<reason>" (permanent Discord error).
poll_disposition() {
local body="$1" ra code
ra="$(printf '%s' "$body" | jq -r '.retry_after // empty' 2>/dev/null)"
if [ -n "$ra" ]; then
# rate limited: honor Discord's back-off (ceil), then retry
sleep "$(printf '%s\n' "$ra" | awk '{printf "%d", ($1==int($1)?$1:int($1)+1)}')" 2>/dev/null || sleep 2
printf 'retry'; return
fi
code="$(printf '%s' "$body" | jq -r '.code // empty' 2>/dev/null)"
if [ -n "$code" ]; then printf 'bail:%s %s' "$code" "$(printf '%s' "$body" | jq -r '.message // ""')"; return; fi
printf 'retry' # unrecognized/transient (network blip, empty body)
}
# emit the human (non-bot) replies from a messages array as ">>> user: text" lines
emit_human() {
printf '%s' "$1" | jq -r 'sort_by(.id)[] | select(.author.bot|not) | ">>> " + .author.username + ": " + ((.content//"")|gsub("\n";" "))'
}
# newest message id in an array (string/lexical max — snowflakes are monotonic; avoids jq number precision loss)
newest_id() { printf '%s' "$1" | jq -r 'sort_by(.id)|.[-1].id // empty'; }
# ---------------------------------------------------------------------------
# --read: one-shot fetch of a thread's messages, no posting
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--read" ]; then
THREAD="${2:-}"
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read <thread_id> [limit]" >&2; exit 1; }
LIMIT="$(clamp_limit "${3:-25}")"
TOKEN="$(resolve_token)"
{ [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; } && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=${LIMIT}")"
printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || { echo "[ERROR] could not read thread ${THREAD}: $(printf '%s' "$MSGS" | head -c 160)" >&2; exit 3; }
echo "[OK] thread ${THREAD} (oldest first; '>>>' = human, ' ' = bot):"
printf '%s' "$MSGS" | jq -r 'sort_by(.id)[] | ((if (.author.bot // false) then " " else ">>> " end) + .author.username + ": " + ((.content // "")|gsub("\n";" ")))'
exit 0
fi
# ---------------------------------------------------------------------------
# --wait: block on an EXISTING thread until a human replies, no posting
# Baselines on the starter (thread id == starter message id for a forum post),
# so a reply that already landed before --wait began is caught (not missed).
# Override the baseline with --after <msg_id> to wait for replies strictly after
# a specific message (use when resuming a thread whose earlier answer you already
# consumed and you want only the NEXT reply).
# ---------------------------------------------------------------------------
if [ "${1:-}" = "--wait" ]; then
THREAD="${2:-}"; shift 2
[ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait <thread_id> [--timeout SEC] [--poll SEC] [--after <msg_id>]" >&2; exit 1; }
AFTER="$THREAD"
while [ "$#" -gt 0 ]; do
case "$1" in
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
--poll) POLL="${2:-8}"; shift 2 ;;
--after) AFTER="${2:-$THREAD}"; shift 2 ;;
*) shift ;;
esac
done
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
TOKEN="$(resolve_token)"
{ [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; } && { echo "[ERROR] no Discord bot token" >&2; exit 2; }
auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}")
echo "[INFO] waiting on thread ${THREAD} for a human reply (up to ${TIMEOUT}s)..." >&2
DEADLINE=$(( $(date +%s) + TIMEOUT ))
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
D="$(poll_disposition "$MSGS")"
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
sleep "$POLL"; continue
fi
HUMAN="$(emit_human "$MSGS")"
if [ -n "$HUMAN" ]; then
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
fi
# all-bot batch: page forward so a reply beyond the 100-msg window isn't missed
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID"
sleep "$POLL"
done
echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2
echo "THREAD_ID=${THREAD}" >&2
exit 4
fi
# ---------------------------------------------------------------------------
# ask mode (default): post a question and block for the first human reply
# ---------------------------------------------------------------------------
while [ "$#" -gt 0 ]; do
case "$1" in
--title) TITLE="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-600}"; shift 2 ;;
--poll) POLL="${2:-8}"; shift 2 ;;
--tag) TAGS+=("${2:-}"); shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 1 ;;
*) MSG="${MSG:+$MSG }$1"; shift ;;
esac
done
if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi
if [ -z "$MSG" ]; then echo "[ERROR] no question given" >&2; exit 1; fi
printf '%s' "$TIMEOUT" | grep -qE '^[0-9]+$' || TIMEOUT=600
printf '%s' "$POLL" | grep -qE '^[0-9]+$' || POLL=8
if [ -z "$TITLE" ]; then
TITLE="$(printf '%s' "$MSG" | tr '\n' ' ' | cut -c1-60)"
[ -z "$TITLE" ] && TITLE="Question"
fi
# prepend any @mentions so the tagged person gets pinged
MENTION=""
for t in "${TAGS[@]:-}"; do
[ -z "$t" ] && continue
id="${t#@}"
printf '%s' "$id" | grep -qE '^[0-9]{17,19}$' && MENTION="${MENTION}<@${id}> "
done
CONTENT="${MENTION}${MSG}"
TOKEN="$(resolve_token)"
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "[ERROR] no Discord bot token (vault + .env both empty)" >&2
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "no Discord bot token" >/dev/null 2>&1
exit 2
fi
auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}")
# keep the forum starter <=1900 chars; any overflow goes out as follow-up messages
STARTER_CONTENT="$CONTENT"; OVERFLOW=""
if [ "${#CONTENT}" -gt "$LIMIT_CHARS" ]; then
STARTER_CONTENT="$(trim_at_newline "${CONTENT:0:$LIMIT_CHARS}")"
OVERFLOW="${CONTENT:${#STARTER_CONTENT}}"; OVERFLOW="${OVERFLOW#$'\n'}"
fi
# --- create the forum post (thread) with the question ---
BODY="$(jq -nc --arg name "$TITLE" --arg c "$STARTER_CONTENT" '{name:$name, message:{content:$c}}')"
RESP="$(printf '%s' "$BODY" | curl -s -m 20 -w $'\n%{http_code}' "${auth[@]}" \
-X POST "$API/channels/${FORUM_ID}/threads" --data-binary @-)"
HTTP="$(printf '%s' "$RESP" | tail -n1)"
JSON="$(printf '%s' "$RESP" | sed '$d')"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "[ERROR] could not post forum question (HTTP ${HTTP:-none}): $(printf '%s' "$JSON" | head -c 200)" >&2
bash "$ROOT/.claude/scripts/log-skill-error.sh" "ask-forum" "forum post failed" --context "http=${HTTP:-none} resp=$(printf '%s' "$JSON" | head -c 80)" >/dev/null 2>&1
exit 3
fi
THREAD="$(printf '%s' "$JSON" | jq -r '.id // empty')"
STARTER="$(printf '%s' "$JSON" | jq -r '.message.id // empty')"
if [ -z "$THREAD" ]; then
echo "[ERROR] posted but no thread id in response: $(printf '%s' "$JSON" | head -c 200)" >&2
exit 3
fi
# --- post any overflow chunks as follow-up messages (check each; warn if one fails) ---
rest="$OVERFLOW"
while [ -n "$rest" ]; do
piece="$(trim_at_newline "${rest:0:$LIMIT_CHARS}")"
fhttp="$(printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \
curl -s -m 15 -o /dev/null -w '%{http_code}' "${auth[@]}" -X POST "$API/channels/${THREAD}/messages" --data-binary @-)"
[ "$fhttp" != "200" ] && echo "[WARNING] overflow chunk failed (HTTP ${fhttp}) — question may be truncated in the forum" >&2
rest="${rest:${#piece}}"; rest="${rest#$'\n'}"
done
echo "[INFO] asked in #ct-forum (thread=${THREAD}) — waiting up to ${TIMEOUT}s for a human reply..." >&2
# --- block-poll the thread for the first NON-BOT reply ---
AFTER="${STARTER:-$THREAD}" # starter id == thread id for a forum post
DEADLINE=$(( $(date +%s) + TIMEOUT ))
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
sleep "$POLL"
MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}&limit=100")"
if ! printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1; then
D="$(poll_disposition "$MSGS")"
case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac
continue
fi
HUMAN="$(emit_human "$MSGS")"
if [ -n "$HUMAN" ]; then
echo "[OK] answer received in thread ${THREAD}:"; printf '%s\n' "$HUMAN"; echo "THREAD_ID=${THREAD}"; exit 0
fi
NID="$(newest_id "$MSGS")"; [ -n "$NID" ] && AFTER="$NID" # page forward past bot-only batches
done
echo "[TIMEOUT] no human reply within ${TIMEOUT}s. Thread stays open for a later read:" >&2
echo "THREAD_ID=${THREAD}" >&2
echo " re-read: bash .claude/scripts/ask-forum.sh --read ${THREAD} | resume wait: --wait ${THREAD}" >&2
exit 4