From bda03188389975988a067d0ed11fb94c8e98c0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Posp=C3=AD=C5=A1il?= Date: Thu, 13 Aug 2026 17:20:10 +0200 Subject: [PATCH] A relayed report can be pulled into a local inbox and closed out again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADDED: discord-reports/ingest.sh pulls new #ppc-reports threads into a gitignored local inbox//, one folder per report (report.md, screenshot.png, meta.json), tracking "already ingested" via a reaction on the starter message rather than local state so a wiped inbox re-downloads instead of silently skipping. ADDED: discord-reports/resolve.sh archives and locks the Discord thread for every inbox entry marked resolved locally, and with --cleanup --yes purges local copies closed longer than DISCORD_REPORTS_CLEANUP_DAYS (default 90) without touching the thread itself. ADDED: discord-reports-ingest and discord-reports-resolve skills, and a CLAUDE.md pointer to them alongside the other recurring-workflow skills. ADDED: discord-reports/.env.example and README.md documenting bot setup, including that the Message Content privileged intent must be enabled — Discord strips content/attachments/embeds from REST responses for messages the bot didn't author without it, so a report reads as empty otherwise. --- .../skills/discord-reports-ingest/SKILL.md | 51 ++++++++++ .../skills/discord-reports-resolve/SKILL.md | 44 +++++++++ .gitignore | 4 + CLAUDE.md | 7 +- discord-reports/.env.example | 25 +++++ discord-reports/README.md | 72 ++++++++++++++ discord-reports/_api.sh | 26 +++++ discord-reports/_env.sh | 38 ++++++++ discord-reports/ingest.sh | 95 +++++++++++++++++++ discord-reports/resolve.sh | 74 +++++++++++++++ 10 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/discord-reports-ingest/SKILL.md create mode 100644 .claude/skills/discord-reports-resolve/SKILL.md create mode 100644 discord-reports/.env.example create mode 100644 discord-reports/README.md create mode 100644 discord-reports/_api.sh create mode 100644 discord-reports/_env.sh create mode 100755 discord-reports/ingest.sh create mode 100755 discord-reports/resolve.sh diff --git a/.claude/skills/discord-reports-ingest/SKILL.md b/.claude/skills/discord-reports-ingest/SKILL.md new file mode 100644 index 0000000..671f353 --- /dev/null +++ b/.claude/skills/discord-reports-ingest/SKILL.md @@ -0,0 +1,51 @@ +--- +name: discord-reports-ingest +description: "Pull new bug reports out of the private #ppc-reports Discord forum into a local, gitignored inbox for investigation. Use when the user asks to check Discord for new reports, pull in reports, or catch up on what's been reported — this only downloads and pre-processes, it never investigates or fixes anything itself." +--- + +# Ingesting reports from Discord + +[worker/README.md](../../../worker/README.md) describes the relay: every bug report the app sends +lands as its own thread in the private `#ppc-reports` forum, with a `report.md` attachment written +to be pasted into a GitHub issue unedited. This skill is the other end — it reads that forum and +drops each report's content locally so a person or another agent can go through it without a +Discord client open. + +**It does no investigation.** It downloads, tags with metadata, and stops. Diagnosing what a report +actually found is the **item-capture** skill's job, one report at a time, after this has run. + +## One-time setup + +[discord-reports/README.md](../../../discord-reports/README.md) — create the bot application, +grant it View Channel / Read Message History / Add Reactions on the forum channel specifically (it +is private, per worker/README.md's step 1.2), and fill in `discord-reports/.env` from +`.env.example`. Nobody has done this yet if `discord-reports/.env` does not exist — say so and +stop rather than guessing at credentials. + +## Running it + +```sh +cd discord-reports && ./ingest.sh +``` + +Each new report lands in `discord-reports/inbox/_/`: + +| File | Contents | +| --- | --- | +| `report.md` | Exactly what the app sent — the item, the parse dump, the reporter's comment, version meta. Same file the doc says is ready to paste into an issue. | +| `screenshot.png` | The masked panel capture, if the reporter included one. | +| `meta.json` | `thread_id`, `url` (jumps straight to the Discord thread), `title`, `tags` (the forum's own triage tags), `created_at`, `ingested_at`, and `status` — starts `"new"`. | + +"Already ingested" is tracked by reacting to the thread's starter message with 📥, not a local +state file — so it survives the inbox being deleted or moved, and re-running is always safe: a +thread the bot has already reacted to is skipped, everything else is pulled. If a report seems to +be missing after a run, check the bot actually has channel access before assuming it was already +handled. + +## After this runs + +Report the new folders to the user (count and titles is enough) and hand off — either they read +`report.md` themselves, or ask you to work through them with **item-capture**. Whoever investigates +a report writes their finding into that report's own directory (e.g. a `findings.md` alongside +`report.md`) and, once it's actually handled, sets `meta.json`'s `status` to `"resolved"` — that +field is what **discord-reports-resolve** watches for. diff --git a/.claude/skills/discord-reports-resolve/SKILL.md b/.claude/skills/discord-reports-resolve/SKILL.md new file mode 100644 index 0000000..a4abc5b --- /dev/null +++ b/.claude/skills/discord-reports-resolve/SKILL.md @@ -0,0 +1,44 @@ +--- +name: discord-reports-resolve +description: Close out Discord threads for reports that have been marked resolved locally, and optionally purge old already-closed local report folders. Use when the user asks to close resolved reports, mark Discord reports as done, sync resolved status back to Discord, or clean up old report folders. +--- + +# Closing out resolved reports + +The mirror image of **discord-reports-ingest**: that skill pulls reports in, this one pushes the +"done" state back to Discord and, only if asked, tidies the local copies of the oldest closed ones. + +## What counts as resolved + +Nothing here decides that. A report in `discord-reports/inbox/_/` is resolved when its +`meta.json` has `"status": "resolved"` — written by hand, or by whatever investigated it +(**item-capture**, a person). Add a one-line `"resolution"` too if you know it (`"fixed in +a1b2c3d"`, `"wontfix: ..."`, `"duplicate of ..."`) — it isn't required, but it's the only place +that context survives once the folder is gone. + +## Running it + +```sh +cd discord-reports && ./resolve.sh +``` + +For every folder marked `resolved`, this archives and locks the matching Discord thread — the same +"handled" state [worker/README.md](../../../worker/README.md) describes for closing a report by +hand — then flips the local `status` to `"closed"` and stamps `closed_at`. The Discord thread is +never deleted, only archived: it stays the permanent, searchable record. + +```sh +./resolve.sh --cleanup +``` + +Lists local folders `closed` for longer than `DISCORD_REPORTS_CLEANUP_DAYS` +(`discord-reports/.env`, default 90 days) **without deleting anything** — it's a dry run by +default. Show that list to the user before re-running with `--yes`: + +```sh +./resolve.sh --cleanup --yes +``` + +This only ever removes the local pre-processed copy — the Discord thread it came from is untouched +and stays the record if anyone needs it later. Don't skip the confirmation step: deleting the local +folder is the one irreversible thing either skill does. diff --git a/.gitignore b/.gitignore index b2724ae..bc22ebc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ worker/.env worker/.dev.vars worker/node_modules/ worker/.wrangler/ + +# Discord report ingestion: local-only bot credential and pre-processed report content. +discord-reports/.env +discord-reports/inbox/ diff --git a/CLAUDE.md b/CLAUDE.md index bb70431..2396a53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,11 @@ read whole; each is one layer. Skills carry the recurring workflows: **commit-work** (commit and PR messages), **item-capture** (a clipboard capture that parses, prices or searches wrong), **clipboard-debug** (a copy that hung or -came back stale), **run-overlay** (build and drive the app without the game). Invoke the skill -rather than reconstructing the procedure. +came back stale), **run-overlay** (build and drive the app without the game), +**discord-reports-ingest** / **discord-reports-resolve** (pull bug reports out of the Discord relay +channel into a local, gitignored inbox, and close them out once resolved — see +[discord-reports/README.md](discord-reports/README.md)). Invoke the skill rather than +reconstructing the procedure. ## Locked technical decisions (do not relitigate without asking) diff --git a/discord-reports/.env.example b/discord-reports/.env.example new file mode 100644 index 0000000..b02bcc9 --- /dev/null +++ b/discord-reports/.env.example @@ -0,0 +1,25 @@ +# Copy to discord-reports/.env and fill in. Gitignored; lives on the maintainer's machine only — +# it is a bot token with read/react/archive access to a private channel, not a webhook. +# +# Setup: +# 1. https://discord.com/developers/applications -> New Application -> Bot -> Reset Token, then +# on the same Bot page enable the "Message Content" privileged intent. This never opens a +# gateway connection, only REST — but Discord strips content/attachments/embeds from REST +# responses for messages the bot didn't author unless that intent is on, so without it every +# report reads as empty. +# 2. OAuth2 -> URL Generator -> scope "bot" -> permissions View Channel, Read Message History, +# Add Reactions, Manage Threads. Open the generated URL and add the bot to the server the +# #ppc-reports forum lives in. +# 3. The channel is private (worker/README.md step 1.2 denies @everyone) — server membership +# does not give the bot the channel. Open the channel's own Permissions tab and add the bot +# (or its role) with the same four permissions there. +# 4. Turn on Developer Mode (User Settings -> Advanced), then right-click the server and the +# forum channel to copy the guild and channel IDs. + +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +DISCORD_CHANNEL_ID= + +# Optional, resolve.sh --cleanup only: local folders closed longer than this many days are purge +# candidates. The Discord thread is never touched by cleanup, only the local copy. +# DISCORD_REPORTS_CLEANUP_DAYS=90 diff --git a/discord-reports/README.md b/discord-reports/README.md new file mode 100644 index 0000000..1b157ab --- /dev/null +++ b/discord-reports/README.md @@ -0,0 +1,72 @@ +# Discord report ingestion + +A local-only maintainer tool: pulls bug reports out of the private `#ppc-reports` Discord forum +(see [worker/README.md](../worker/README.md) for how a report gets there) into `inbox/`, so they +can be triaged without a Discord client open. The only things it ever writes back to Discord are a +reaction, to mark a report as pulled, and — once a report is marked resolved locally — archiving +that report's own thread. + +Two Claude Code skills drive it day to day: **discord-reports-ingest** and +**discord-reports-resolve** (`.claude/skills/`). The scripts below work standalone too. + +## Setup, once + +### 1. A bot, not a webhook + +The relay posts *to* Discord with a webhook; reading requires an actual bot user with a token. + +1. → **New Application** → **Bot** → **Reset + Token**, then on the same page enable the **Message Content** privileged intent. This never + opens a gateway connection, only REST — but Discord strips content/attachments/embeds from REST + responses for messages the bot didn't author unless that intent is on, so without it every + report reads as empty. +2. **OAuth2 → URL Generator** → scope `bot` → permissions **View Channel, Read Message History, + Add Reactions, Manage Threads**. Open the generated URL and add the bot to the server the forum + lives in. +3. The channel is private (worker/README.md's step 1.2 denies `@everyone`) — server membership + does not give the bot the channel. Open the channel's own **Permissions** tab and add the bot + (or its role) with the same four permissions there. +4. Turn on **Developer Mode** (User Settings → Advanced), then right-click the server and the + forum channel to copy the guild and channel IDs. + +### 2. Local credentials + +```sh +cd discord-reports +cp .env.example .env +$EDITOR .env # DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, DISCORD_CHANNEL_ID +``` + +`.env` is gitignored, same as `worker/.env` — it lives on your machine and nowhere else. + +## Day to day + +| | | +| --- | --- | +| Pull new reports | `./ingest.sh` | +| Close reports marked resolved | `./resolve.sh` | +| See what old closed copies would be purged | `./resolve.sh --cleanup` | +| Actually purge them | `./resolve.sh --cleanup --yes` | + +`inbox/` is gitignored in full — it is pre-processed report content, the same category of thing +`report.md` itself is (arbitrary text a stranger typed). + +## Layout + +``` +inbox/_/ + report.md # exactly what the app sent — item, parse dump, comment, version meta + screenshot.png # the masked panel capture, if the reporter included one + meta.json # thread_id, url, title, tags, created_at, ingested_at, status, resolution, closed_at +``` + +Whoever investigates a report is free to add more files alongside these (a `findings.md`, say) — +nothing here reads or expects any particular shape beyond `meta.json`. + +## How it tracks state + +No local "last seen" file. "Already pulled" is a 📥 reaction the bot leaves on a thread's starter +message — open Discord and you can see at a glance which reports a run already took, and deleting +`inbox/` and re-running `ingest.sh` is always safe: already-reacted threads are skipped, everything +else is pulled fresh. "Resolved" is `meta.json`'s own `status` field, set by whatever investigated +the report — `resolve.sh` only ever reads that; it never decides it. diff --git a/discord-reports/_api.sh b/discord-reports/_api.sh new file mode 100644 index 0000000..ebe429d --- /dev/null +++ b/discord-reports/_api.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Shared Discord REST helper for ingest.sh and resolve.sh. Sourced after _env.sh, never run alone. +# +# api METHOD PATH [JSON_BODY] -> response body on stdout. A 429 is retried once after the delay +# Discord itself asks for; anything else >= 300 is printed to stderr and fails the call. +api() { + local method=$1 path=$2 body=${3:-} resp code out + if [[ -n $body ]]; then + resp=$(curl -sS -w '\n%{http_code}' -X "$method" -H "$AUTH_HEADER" \ + -H 'Content-Type: application/json' -d "$body" "$DISCORD_API$path") + else + resp=$(curl -sS -w '\n%{http_code}' -X "$method" -H "$AUTH_HEADER" "$DISCORD_API$path") + fi + code=${resp##*$'\n'} + out=${resp%$'\n'*} + if [[ $code == 429 ]]; then + sleep "$(jq -r '.retry_after // 1' <<<"$out")" + api "$method" "$path" "$body" + return + fi + if [[ $code -ge 300 ]]; then + echo "Discord API $method $path -> $code: $out" >&2 + return 1 + fi + printf '%s' "$out" +} diff --git a/discord-reports/_env.sh b/discord-reports/_env.sh new file mode 100644 index 0000000..8f21917 --- /dev/null +++ b/discord-reports/_env.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Loads discord-reports/.env for ingest.sh and resolve.sh. Sourced, never run on its own. Callers +# are expected to have already `cd`ed into discord-reports/. +# +# Tracing is suspended across the read and restored afterwards, same reason as worker/_env.sh: +# `bash -x` would otherwise print the bot token to the terminal. + +_ppc_xtrace=$(set +o | grep -E 'xtrace$') +set +x + +if [[ ! -f .env ]]; then + echo "discord-reports/.env is missing — copy .env.example and fill it in (see README.md)" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +. ./.env +set +a + +if [[ -z ${DISCORD_BOT_TOKEN:-} ]]; then + echo "DISCORD_BOT_TOKEN is not set in discord-reports/.env" >&2 + exit 1 +fi +if [[ -z ${DISCORD_GUILD_ID:-} ]]; then + echo "DISCORD_GUILD_ID is not set in discord-reports/.env" >&2 + exit 1 +fi +if [[ -z ${DISCORD_CHANNEL_ID:-} ]]; then + echo "DISCORD_CHANNEL_ID is not set in discord-reports/.env" >&2 + exit 1 +fi + +DISCORD_API="https://discord.com/api/v10" +AUTH_HEADER="Authorization: Bot $DISCORD_BOT_TOKEN" + +eval "$_ppc_xtrace" +unset _ppc_xtrace diff --git a/discord-reports/ingest.sh b/discord-reports/ingest.sh new file mode 100755 index 0000000..6834305 --- /dev/null +++ b/discord-reports/ingest.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Pull new reports out of the #ppc-reports Discord forum into inbox/, one folder per thread, ready +# for something else — a human or another agent — to investigate. Ingestion does no investigation +# of its own; see .claude/skills/discord-reports-ingest/SKILL.md. +# +# "Already ingested" is tracked with a reaction on the thread's starter message, not a local state +# file, so re-running after wiping inbox/ re-downloads instead of silently skipping everything — +# see README.md. +set -euo pipefail + +cd "$(dirname "$0")" +# shellcheck source=_env.sh +. ./_env.sh +# shellcheck source=_api.sh +. ./_api.sh + +MARK='📥' +MARK_ENC='%F0%9F%93%A5' # percent-encoded UTF-8 for the reactions endpoint +mkdir -p inbox + +slugify() { + tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g' | cut -c1-60 +} + +threads_json=$(mktemp) +trap 'rm -f "$threads_json"' EXIT + +# Active threads are listed at the guild level (not the channel) since a Discord API change; +# archived ones are still channel-scoped and paginated 100 at a time, oldest cut off first. +api GET "/guilds/$DISCORD_GUILD_ID/threads/active" \ + | jq -c --arg cid "$DISCORD_CHANNEL_ID" \ + '.threads[] | select(.parent_id == $cid) | {id, name, applied_tags}' \ + >>"$threads_json" + +before="" +while :; do + page=$(api GET "/channels/$DISCORD_CHANNEL_ID/threads/archived/public?limit=100${before:+&before=$before}") + jq -c '.threads[] | {id, name, applied_tags}' <<<"$page" >>"$threads_json" + [[ $(jq -r '.has_more' <<<"$page") == "true" ]] || break + before=$(jq -r '[.threads[].thread_metadata.archive_timestamp] | min' <<<"$page") +done + +tag_map=$(api GET "/channels/$DISCORD_CHANNEL_ID" | jq -c '[.available_tags[] | {(.id): .name}] | add // {}') + +ingested=0 +skipped=0 +while IFS= read -r thread; do + id=$(jq -r '.id' <<<"$thread") + name=$(jq -r '.name' <<<"$thread") + + msg=$(api GET "/channels/$id/messages/$id") || continue + already=$(jq -r --arg e "$MARK" '[.reactions[]? | select(.emoji.name == $e) | .me] | any' <<<"$msg") + if [[ $already == "true" ]]; then + skipped=$((skipped + 1)) + continue + fi + + report_url=$(jq -r '.attachments[]? | select(.filename == "report.md") | .url' <<<"$msg") + if [[ -z $report_url ]]; then + echo "thread $id ($name) has no report.md attachment, skipping" >&2 + continue + fi + # A screenshot rides as the embed's image, not a plain attachment: the worker uploads it and + # points embed.image at it via attachment://, which folds it into the embed instead of leaving + # it in .attachments (see worker/src/index.js). + shot_url=$(jq -r '.embeds[0].image.url // empty' <<<"$msg") + + dir="inbox/${id}_$(slugify "$name")" + mkdir -p "$dir" + curl -sS -o "$dir/report.md" "$report_url" + [[ -n $shot_url ]] && curl -sS -o "$dir/screenshot.png" "$shot_url" + + tags_json=$(jq -c --argjson m "$tag_map" '[.applied_tags[]? as $t | ($m[$t] // $t)]' <<<"$thread") + + jq -n \ + --arg thread_id "$id" \ + --arg guild_id "$DISCORD_GUILD_ID" \ + --arg channel_id "$DISCORD_CHANNEL_ID" \ + --arg title "$name" \ + --arg url "https://discord.com/channels/$DISCORD_GUILD_ID/$id" \ + --arg created_at "$(jq -r '.timestamp' <<<"$msg")" \ + --arg ingested_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson tags "$tags_json" \ + '{thread_id:$thread_id, guild_id:$guild_id, channel_id:$channel_id, title:$title, url:$url, + tags:$tags, created_at:$created_at, ingested_at:$ingested_at, + status:"new", resolution:null, closed_at:null}' \ + >"$dir/meta.json" + + api PUT "/channels/$id/messages/$id/reactions/$MARK_ENC/@me" >/dev/null + + echo "ingested $id -> $dir" + ingested=$((ingested + 1)) +done <"$threads_json" + +echo "$ingested new, $skipped already ingested" diff --git a/discord-reports/resolve.sh b/discord-reports/resolve.sh new file mode 100755 index 0000000..02ff8ce --- /dev/null +++ b/discord-reports/resolve.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Close out reports that have been marked resolved locally, and optionally purge old closed local +# copies. See .claude/skills/discord-reports-resolve/SKILL.md. +# +# ./resolve.sh archive+lock the Discord thread for every inbox/*/meta.json with +# status "resolved", then flip that status to "closed" +# ./resolve.sh --cleanup also list local folders closed longer than DISCORD_REPORTS_CLEANUP_DAYS +# ./resolve.sh --cleanup --yes ...and actually delete them (Discord thread is never touched) +set -euo pipefail + +cd "$(dirname "$0")" +# shellcheck source=_env.sh +. ./_env.sh +# shellcheck source=_api.sh +. ./_api.sh + +do_cleanup=0 +confirm=0 +for arg in "$@"; do + case "$arg" in + --cleanup) do_cleanup=1 ;; + --yes) confirm=1 ;; + *) + echo "unknown argument: $arg (expected --cleanup and/or --yes)" >&2 + exit 1 + ;; + esac +done + +CLEANUP_DAYS=${DISCORD_REPORTS_CLEANUP_DAYS:-90} + +closed=0 +for dir in inbox/*/; do + [[ -f "$dir/meta.json" ]] || continue + [[ $(jq -r '.status' "$dir/meta.json") == "resolved" ]] || continue + + thread_id=$(jq -r '.thread_id' "$dir/meta.json") + if ! api PATCH "/channels/$thread_id" '{"archived":true,"locked":true}' >/dev/null; then + echo "failed to archive thread $thread_id ($dir), leaving it marked resolved" >&2 + continue + fi + + tmp=$(mktemp) + jq --arg t "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "closed" | .closed_at = $t' \ + "$dir/meta.json" >"$tmp" + mv "$tmp" "$dir/meta.json" + + echo "closed $thread_id -> $dir" + closed=$((closed + 1)) +done +echo "$closed thread(s) archived and locked" + +if [[ $do_cleanup -eq 1 ]]; then + purged=0 + now=$(date -u +%s) + for dir in inbox/*/; do + [[ -f "$dir/meta.json" ]] || continue + [[ $(jq -r '.status' "$dir/meta.json") == "closed" ]] || continue + closed_at=$(jq -r '.closed_at // empty' "$dir/meta.json") + [[ -n $closed_at ]] || continue + closed_ts=$(date -u -d "$closed_at" +%s 2>/dev/null) || continue + age_days=$(((now - closed_ts) / 86400)) + ((age_days >= CLEANUP_DAYS)) || continue + + if [[ $confirm -eq 1 ]]; then + echo "purging $dir (closed ${age_days}d ago; the Discord thread itself is untouched)" + rm -rf "$dir" + purged=$((purged + 1)) + else + echo "would purge $dir (closed ${age_days}d ago) — rerun with --cleanup --yes to delete" + fi + done + [[ $confirm -eq 1 ]] && echo "$purged local folder(s) purged" +fi