From 0a438929d07714d79b828eacf58824f4a946b92f Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 7 Jul 2026 20:37:36 -0700 Subject: [PATCH] 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) --- .claude/scripts/ask-forum.sh | 166 +++++++++++++++++------------- .claude/skills/ask-forum/SKILL.md | 19 ++++ 2 files changed, 114 insertions(+), 71 deletions(-) diff --git a/.claude/scripts/ask-forum.sh b/.claude/scripts/ask-forum.sh index 4957905a..edf78f0b 100644 --- a/.claude/scripts/ask-forum.sh +++ b/.claude/scripts/ask-forum.sh @@ -8,30 +8,23 @@ # 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 [limit] # one-shot read, no wait +# ask-forum.sh --wait [--timeout SEC] [--poll SEC] [--after ] # -# 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. +# 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 @@ -39,7 +32,7 @@ TITLE="" TAGS=() MSG="" -# --- token resolver (shared by read + ask) --- +# --- token resolver (shared by read + wait + ask) --- resolve_token() { local t t="$(bash "$ROOT/.claude/scripts/vault.sh" get-field \ @@ -51,64 +44,101 @@ resolve_token() { printf '%s' "$t" } -# --- read mode: one-shot fetch of a thread's human replies, no posting --- -# ask-forum.sh --read [limit] +# 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:" (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:-}" - LIMIT="${3:-25}" [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --read [limit]" >&2; exit 1; } - printf '%s' "$LIMIT" | grep -qE '^[0-9]+$' || LIMIT=25 # Discord caps at 100 - [ "$LIMIT" -lt 1 ] && LIMIT=1; [ "$LIMIT" -gt 100 ] && LIMIT=100 + LIMIT="$(clamp_limit "${3:-25}")" TOKEN="$(resolve_token)" - [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { echo "[ERROR] no Discord bot token" >&2; exit 2; } + { [ -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";" ")))' + printf '%s' "$MSGS" | jq -r 'sort_by(.id)[] | ((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. +# --------------------------------------------------------------------------- +# --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 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 [--timeout SEC] [--poll SEC]" >&2; exit 1; } + [ -z "$THREAD" ] && { echo "[ERROR] usage: ask-forum.sh --wait [--timeout SEC] [--poll SEC] [--after ]" >&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; } + { [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; } && { echo "[ERROR] no Discord bot token" >&2; exit 2; } auth=(-H "Authorization: Bot ${TOKEN}" -H "User-Agent: ${UA}") - # baseline: for a forum post the starter message id == the thread id, so - # after= returns every human reply after the starter -- this catches a - # reply that already landed BEFORE this --wait began (the old "newest message - # now" baseline missed it and hung to timeout). - AFTER="${THREAD}" 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}")" + 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 - # thread gone -> bail fast; otherwise (transient/429) keep waiting - printf '%s' "$MSGS" | jq -e '.code==10003' >/dev/null 2>&1 && { echo "[ERROR] thread ${THREAD} no longer exists" >&2; exit 3; } + D="$(poll_disposition "$MSGS")" + case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac sleep "$POLL"; continue fi - HUMAN="$(printf '%s' "$MSGS" | jq -c 'reverse[] | select(.author.bot|not) | {u:.author.username, c:.content}')" + HUMAN="$(emit_human "$MSGS")" 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 + 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 @@ -116,14 +146,16 @@ if [ "${1:-}" = "--wait" ]; then exit 4 fi -# --- parse args --- +# --------------------------------------------------------------------------- +# 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,30p' "$0"; exit 1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 1 ;; *) MSG="${MSG:+$MSG }$1"; shift ;; esac done @@ -132,13 +164,12 @@ 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 --- +# prepend any @mentions so the tagged person gets pinged MENTION="" for t in "${TAGS[@]:-}"; do [ -z "$t" ] && continue @@ -147,7 +178,6 @@ for t in "${TAGS[@]:-}"; do 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 @@ -156,18 +186,14 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then fi auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}") -# --- create the forum post (thread) with the question --- -# Discord caps message content at 2000 chars. Keep the starter <=1900 and send any -# overflow as follow-up messages in the created thread (rare, but don't hard-fail). -LIMIT_CHARS=1900 -STARTER_CONTENT="$CONTENT" -OVERFLOW="" +# 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="${CONTENT:0:$LIMIT_CHARS}" - nl="${STARTER_CONTENT%$'\n'*}" - [ "${#nl}" -lt "${#STARTER_CONTENT}" ] && [ "${#nl}" -gt 0 ] && STARTER_CONTENT="$nl" + 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 @-)" @@ -184,40 +210,38 @@ 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 in the new thread --- + +# --- post any overflow chunks as follow-up messages (check each; warn if one fails) --- rest="$OVERFLOW" while [ -n "$rest" ]; do - piece="${rest:0:$LIMIT_CHARS}" - nl="${piece%$'\n'*}"; [ "${#nl}" -lt "${#piece}" ] && [ "${#nl}" -gt 0 ] && piece="$nl" - printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \ - curl -s -m 15 "${auth[@]}" -X POST "$API/channels/${THREAD}/messages" --data-binary @- >/dev/null 2>&1 + 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 --- -# starter message id == thread id for a forum post, so after= = all replies. -AFTER="${STARTER:-$THREAD}" +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}")" + 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 - printf '%s' "$MSGS" | jq -e '.code==10003' >/dev/null 2>&1 && { echo "[ERROR] thread ${THREAD} disappeared" >&2; exit 3; } + D="$(poll_disposition "$MSGS")" + case "$D" in bail:*) echo "[ERROR] thread ${THREAD}: ${D#bail:}" >&2; exit 3 ;; esac continue fi - # 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}')" + HUMAN="$(emit_human "$MSGS")" 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 + 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 with: bash .claude/scripts/ask-forum.sh --read ${THREAD}" >&2 +echo " re-read: bash .claude/scripts/ask-forum.sh --read ${THREAD} | resume wait: --wait ${THREAD}" >&2 exit 4 diff --git a/.claude/skills/ask-forum/SKILL.md b/.claude/skills/ask-forum/SKILL.md index d184f93d..e9703883 100644 --- a/.claude/skills/ask-forum/SKILL.md +++ b/.claude/skills/ask-forum/SKILL.md @@ -36,8 +36,27 @@ bash .claude/scripts/ask-forum.sh --read # Resume blocking on an already-posted thread (e.g. after a timeout): bash .claude/scripts/ask-forum.sh --wait --timeout 1800 --poll 10 +# add --after to wait for replies STRICTLY AFTER a message you already +# consumed (default baseline catches any reply since the thread's starter). ``` +## Robustness (hardened after the 2026-07-08 review) +- **Pages past the 100-message window:** the poll advances its `after` cursor each + cycle, so a reply is found even behind many bot/overflow messages (no false timeout). +- **Rate-limit aware:** a `429` is honored (sleeps `retry_after`); a permanent Discord + error (Unknown Channel 10003, Missing Access 50001, etc.) **bails immediately** instead + of burning the full timeout; only genuine transients keep polling. +- **Long questions** (>1900 chars) chunk into the starter + follow-up messages, and each + overflow POST is status-checked (warns if one drops, so the question isn't silently + truncated). +- **`--wait` baseline** is the thread's starter id (starter id == thread id for a forum + post), so a reply that arrived *before* the wait started is caught, not missed. Because + of this, `--wait` on a thread that already holds a consumed answer re-returns it — pass + `--after ` when you only want the NEXT reply. +- Residual caveat: content slicing assumes a UTF-8 locale (the script sets + `LC_ALL=C.UTF-8`); a forced byte-counting `LC_ALL=C` could split a multibyte char at the + 1900-char cut. Not an issue under the fleet default locale. + Flags: `--title "short title"` (forum post name; defaults to first 60 chars of the question) · `--timeout SEC` (default 600) · `--poll SEC` (default 8) · `--tag @` (repeatable — pings that person). Discord IDs: mike `264814939619721216`,