Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .claude/skills/discord-reports-ingest/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<thread_id>_<slug>/`:

| 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.
44 changes: 44 additions & 0 deletions .claude/skills/discord-reports-resolve/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/<id>_<slug>/` 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.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
25 changes: 25 additions & 0 deletions discord-reports/.env.example
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions discord-reports/README.md
Original file line number Diff line number Diff line change
@@ -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. <https://discord.com/developers/applications> → **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/<thread_id>_<slug>/
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.
26 changes: 26 additions & 0 deletions discord-reports/_api.sh
Original file line number Diff line number Diff line change
@@ -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"
}
38 changes: 38 additions & 0 deletions discord-reports/_env.sh
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions discord-reports/ingest.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading