#!/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] # # Flow: # 1. POST a new forum post (thread) to #ct-forum with the question. # 2. Poll that thread server-side for the first NON-BOT reply (the human answer). # The bot's own starter message and any bot chatter are ignored (author.bot). # 3. Print the answer (author + text) + the thread id, exit 0. # On timeout: exit 4 with the thread id so the caller can re-read or keep waiting. # # Correlation is automatic: we create the thread and read only that thread, so an # answer can never be confused with another question's. # # Token/behavior notes: # - Default timeout 600s, poll every 8s. The model is not involved during the wait. # - Only NON-BOT authors count as answers, so this is safe even if the BEAST bot # is still (mis)answering the channel — its replies are filtered out. # # Exit codes: 0 answered; 1 usage; 2 token missing; 3 Discord API error; 4 timeout. set -u 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) TIMEOUT=600 POLL=8 TITLE="" TAGS=() MSG="" # --- token resolver (shared by read + 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" } # --- read mode: one-shot fetch of a thread's human replies, no posting --- # ask-forum.sh --read [limit] if [ "${1:-}" = "--read" ]; then THREAD="${2:-}" LIMIT="${3:-25}" [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read [limit]" >&2; exit 1; } 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 'reverse[] | ((if (.author.bot // false) then " " else ">>> " end) + .author.username + ": " + ((.content // "")|gsub("\n";" ")))' exit 0 fi # --- wait mode: block on an EXISTING thread until a human replies, no posting --- # ask-forum.sh --wait [--timeout SEC] [--poll SEC] # Baselines on the newest message present now and returns the first NON-BOT reply # that arrives after it. Use to resume waiting on a question already posted. if [ "${1:-}" = "--wait" ]; then THREAD="${2:-}"; shift 2 [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait [--timeout SEC] [--poll SEC]" >&2; exit 1; } while [ "$#" -gt 0 ]; do case "$1" in --timeout) TIMEOUT="${2:-600}"; shift 2 ;; --poll) POLL="${2:-8}"; 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}") # baseline = id of newest message present right now AFTER="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?limit=1" | jq -r '.[0].id // "0"' 2>/dev/null)" [ -z "$AFTER" ] && AFTER="0" 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 sleep "$POLL" MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?after=${AFTER}")" printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content}')" if [ -n "$HUMAN" ]; then echo "[OK] answer received in thread ${THREAD}:" printf '%s\n' "$HUMAN" | jq -r '">>> " + .u + ": " + (.c|gsub("\n";" "))' echo "THREAD_ID=${THREAD}" exit 0 fi done echo "[TIMEOUT] no human reply on thread ${THREAD} within ${TIMEOUT}s" >&2 echo "THREAD_ID=${THREAD}" >&2 exit 4 fi # --- parse args --- 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,30p' "$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 # --- default title: first ~60 chars of the question --- 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}" # --- bot token: vault first, then .env (same resolution as discord-dm.sh) --- 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}") # --- create the forum post (thread) with the question --- BODY="$(jq -nc --arg name "$TITLE" --arg c "$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 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:-0}" DEADLINE=$(( $(date +%s) + TIMEOUT )) while [ "$(date +%s)" -lt "$DEADLINE" ]; do sleep "$POLL" Q="after=${AFTER}" [ "$AFTER" = "0" ] && Q="limit=50" MSGS="$(curl -s -m 20 "${auth[@]}" "$API/channels/${THREAD}/messages?${Q}")" printf '%s' "$MSGS" | jq -e 'type=="array"' >/dev/null 2>&1 || continue # oldest-first; keep only human (non-bot) messages HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content, id:.id}')" if [ -n "$HUMAN" ]; then echo "[OK] answer received in thread ${THREAD}:" printf '%s\n' "$HUMAN" | jq -r '">>> " + .u + ": " + (.c|gsub("\n";" "))' echo "THREAD_ID=${THREAD}" exit 0 fi 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 with: bash .claude/scripts/ask-forum.sh --read ${THREAD}" >&2 exit 4