From 98665e929edcaefef13861fa4af02608bf52a20c Mon Sep 17 00:00:00 2001 From: Devansh Awatramani <80251412+Devansh-awat@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:36:48 +0530 Subject: [PATCH 1/3] expose threadrip to api --- AGENTS.md | 2 ++ README.md | 20 +++++++++++++++++ lib/web/api.js | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 760c2de..1024f30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,8 @@ Keys are scoped to one channel and one owner, stored only as a SHA-256 hash, and `canManage` on every request, so demoting an owner disables their keys without an explicit revocation. Deletions reuse `logDelete`/`publicLogDelete` and are attributed to the key's owner. Per-key throughput is capped at 60 requests per minute in memory, and batches at 50 messages. +`POST /api/v1/threads/delete` takes a `thread_ts` and runs `purge()`, the same path as the +`destroy_thread` shortcut. Two properties are load-bearing. `LOG_CHANNEL` is mandatory for the endpoint: `logDelete` treats an unset channel as a successful no-op, so without this guard the API would delete messages with no diff --git a/README.md b/README.md index ca4f920..3710ab7 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,26 @@ curl -X POST https://prometheus.hackclub.com/api/v1/messages/delete \ A `200` means the request was accepted, not that every message was deleted, so check `deleted` and `failed`, where each failure carries the Slack error (`message_not_found`, `cant_delete_message`). +To delete a whole thread (root message plus every reply): + +```bash +curl -X POST https://prometheus.hackclub.com/api/v1/threads/delete \ + -H "Authorization: Bearer $PROMETHEUS_KEY" \ + -H "Content-Type: application/json" \ + -d '{"thread_ts":"1699999999.123456","reason":"spam thread"}' +``` + +```json +{ "ok": true, "channel": "C0123ABCD", "thread_ts": "1699999999.123456" } +``` + +| Field | Required | Notes | +| ----------- | -------- | ----------------------------------------------- | +| `thread_ts` | Yes | Timestamp of the thread's root message | +| `reason` | Yes | Up to 500 characters, recorded in the audit log | + +Deletion runs to completion server-side and keeps paging until the thread is empty; a `502 purge_failed` means it could not finish. + Other statuses: `400` malformed request, `401` missing or invalid key, `403` no permission, `413` request body over 64 KB, `429` over the rate limit of 60 requests per minute. `GET /api/v1/key` just returns a key's metadata, which is handy for checking the status of a key. diff --git a/lib/web/api.js b/lib/web/api.js index fdebe26..d2a2f63 100644 --- a/lib/web/api.js +++ b/lib/web/api.js @@ -3,6 +3,7 @@ import { findChannelApiKeyByHash, isChannelApiKeyActive, touchChannelApiKey } fr import { logDelete, notifyDeletion } from "../logger.js"; import { canManage } from "../perms.js"; import { publicLogDelete } from "../public-logger.js"; +import { purge } from "../purge.js"; import { RateLimiter } from "../ratelimiter.js"; import { hashApiKey } from "./apiKeys.js"; @@ -264,6 +265,66 @@ export function createApiRouter({ client, botClient = client }) { return c.json({ ok: failed.length === 0, channel, deleted, failed }); }); + api.post("/threads/delete", async (c) => { + const apiKey = c.get("apiKey"); + + if (!AUDIT_CONFIGURED) { + return fail( + c, + 503, + "audit_unavailable", + "Deletion is disabled because LOG_CHANNEL is not configured.", + ); + } + + const raw = await readBoundedBody(c); + if (raw === TOO_LARGE) { + return fail(c, 413, "body_too_large", `Keep the request body under 65,536 bytes.`); + } + + let body; + try { + body = JSON.parse(raw); + } catch { + return fail(c, 400, "invalid_json", "Send a JSON body."); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return fail(c, 400, "invalid_body", "Send a JSON object."); + } + + const channel = apiKey.channel_id; + + let threadTs; + try { + [threadTs] = messageTimestamps(body.thread_ts); + deletionReason(body.reason); + } catch (error) { + return fail(c, 400, "invalid_request", error.message); + } + + if (!(await stillAuthorized(client, apiKey))) { + return fail( + c, + 403, + "authorization_revoked", + "That API key can no longer manage this channel.", + ); + } + + await touchChannelApiKey(apiKey.id); + + try { + await purge(client, logger, channel, threadTs, apiKey.user_id); + } catch (error) { + const slackError = error.data?.error || "purge_failed"; + logger.error(`[api] thread purge failed for ${channel}/${threadTs}: ${slackError}`); + return fail(c, 502, "purge_failed", "The thread could not be fully deleted."); + } + + logger.info(`[api] key ${apiKey.key_prefix} purged thread ${threadTs} in ${channel}`); + return c.json({ ok: true, channel, thread_ts: threadTs }); + }); + api.notFound((c) => fail(c, 404, "unknown_endpoint", "No such API endpoint.")); api.onError((error, c) => { From 6fe798d2f21a42e73716e61b317c26a8af83bd57 Mon Sep 17 00:00:00 2001 From: Echo Date: Sat, 29 Aug 2026 08:27:49 -0400 Subject: [PATCH 2/3] add api tag + single thread --- lib/purge.js | 4 +++- lib/web/api.js | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/purge.js b/lib/purge.js index bcd5e47..6a26f12 100644 --- a/lib/purge.js +++ b/lib/purge.js @@ -4,7 +4,7 @@ import { publicLogThread } from "./public-logger.js"; const rateLimiter = new RateLimiter(1000, 5); -export async function purge(client, logger, channel, threadTs, deletedBy) { +export async function purge(client, logger, channel, threadTs, deletedBy, { api = false } = {}) { const result = await client.conversations.replies({ channel: channel, ts: threadTs, @@ -20,6 +20,7 @@ export async function purge(client, logger, channel, threadTs, deletedBy) { threadTs, messages, deletedBy, + api, }), publicLogThread(client, { channel, @@ -54,6 +55,7 @@ export async function purge(client, logger, channel, threadTs, deletedBy) { threadTs, messages: remaining, deletedBy, + api, }); await rateLimiter.deleteBatch(client, logger, channel, remaining, 5, 2000); } diff --git a/lib/web/api.js b/lib/web/api.js index d2a2f63..b86b56c 100644 --- a/lib/web/api.js +++ b/lib/web/api.js @@ -89,6 +89,13 @@ function messageTimestamps(value) { return timestamps; } +function threadTimestamp(value) { + if (typeof value !== "string" || !/^\d{10}\.\d{6}$/.test(value)) { + throw new Error(`${describe(value)} is not a Slack thread ts, like 1699999999.123456.`); + } + return value; +} + function deletionReason(value) { if (value !== undefined && typeof value !== "string") { throw new Error("Reason must be a string."); @@ -296,7 +303,7 @@ export function createApiRouter({ client, botClient = client }) { let threadTs; try { - [threadTs] = messageTimestamps(body.thread_ts); + threadTs = threadTimestamp(body.thread_ts); deletionReason(body.reason); } catch (error) { return fail(c, 400, "invalid_request", error.message); @@ -314,7 +321,7 @@ export function createApiRouter({ client, botClient = client }) { await touchChannelApiKey(apiKey.id); try { - await purge(client, logger, channel, threadTs, apiKey.user_id); + await purge(client, logger, channel, threadTs, apiKey.user_id, { api: true }); } catch (error) { const slackError = error.data?.error || "purge_failed"; logger.error(`[api] thread purge failed for ${channel}/${threadTs}: ${slackError}`); From 9e8cf3d8f283f09204797288c46ab4e5806ec920 Mon Sep 17 00:00:00 2001 From: Echo Date: Sat, 29 Aug 2026 08:31:16 -0400 Subject: [PATCH 3/3] log reason --- lib/logger.js | 40 ++++++++++++++++++++++++--------- lib/purge.js | 11 ++++++++- lib/shortcuts/destroy_thread.js | 2 +- lib/web/api.js | 5 +++-- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/lib/logger.js b/lib/logger.js index c98400e..4381d67 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -158,7 +158,11 @@ export async function logKeyDisclosure(client, { owner, keyName, keyPrefix, sour }); } -export async function logThread(client, logger, { channel, threadTs, messages, deletedBy, api }) { +export async function logThread( + client, + logger, + { channel, threadTs, messages, deletedBy, reason, api }, +) { if (!LOG_CHANNEL) return; // ding dong didnt set it up let cdnUrl = null; @@ -212,19 +216,33 @@ export async function logThread(client, logger, { channel, threadTs, messages, d } } - const cdnLine = cdnUrl ? `\n*Archive:* ${cdnUrl}` : "\n_CDN upload skipped or failed_"; + const cdnLine = cdnUrl ? `*Archive:* ${cdnUrl}` : "_CDN upload skipped or failed_"; + + const blocks = [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:fire: <@${deletedBy}> destroyed a thread in <#${channel}> with ${messages.length} messages.${apiTag(api)}`, // todo replace with better emoji + }, + }, + ]; + + if (reason) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*Reason:* ${reason}` }, + }); + } + + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: cdnLine }, + }); await getLogClient(client).chat.postMessage({ channel: LOG_CHANNEL, text: `Thread deleted in <#${channel}>`, - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `:fire: <@${deletedBy}> destroyed a thread in <#${channel}> with ${messages.length} messages.${apiTag(api)}${cdnLine}`, // todo replace with better emoji - }, - }, - ], + blocks, }); } diff --git a/lib/purge.js b/lib/purge.js index 6a26f12..bccad45 100644 --- a/lib/purge.js +++ b/lib/purge.js @@ -4,7 +4,14 @@ import { publicLogThread } from "./public-logger.js"; const rateLimiter = new RateLimiter(1000, 5); -export async function purge(client, logger, channel, threadTs, deletedBy, { api = false } = {}) { +export async function purge( + client, + logger, + channel, + threadTs, + deletedBy, + { api = false, reason = "" } = {}, +) { const result = await client.conversations.replies({ channel: channel, ts: threadTs, @@ -20,6 +27,7 @@ export async function purge(client, logger, channel, threadTs, deletedBy, { api threadTs, messages, deletedBy, + reason, api, }), publicLogThread(client, { @@ -55,6 +63,7 @@ export async function purge(client, logger, channel, threadTs, deletedBy, { api threadTs, messages: remaining, deletedBy, + reason, api, }); await rateLimiter.deleteBatch(client, logger, channel, remaining, 5, 2000); diff --git a/lib/shortcuts/destroy_thread.js b/lib/shortcuts/destroy_thread.js index e7ffd8d..6e07145 100644 --- a/lib/shortcuts/destroy_thread.js +++ b/lib/shortcuts/destroy_thread.js @@ -154,7 +154,7 @@ export default { await hideThread(channel, threadTs); logger.info(`destroy_thread: thread ${threadTs} hidden in ${channel} by ${uid}`); } else { - await purge(context.userClient, logger, channel, threadTs, uid); + await purge(context.userClient, logger, channel, threadTs, uid, { reason }); } destroySucceeded = true; } finally { diff --git a/lib/web/api.js b/lib/web/api.js index b86b56c..b6a3563 100644 --- a/lib/web/api.js +++ b/lib/web/api.js @@ -302,9 +302,10 @@ export function createApiRouter({ client, botClient = client }) { const channel = apiKey.channel_id; let threadTs; + let reason; try { threadTs = threadTimestamp(body.thread_ts); - deletionReason(body.reason); + reason = deletionReason(body.reason); } catch (error) { return fail(c, 400, "invalid_request", error.message); } @@ -321,7 +322,7 @@ export function createApiRouter({ client, botClient = client }) { await touchChannelApiKey(apiKey.id); try { - await purge(client, logger, channel, threadTs, apiKey.user_id, { api: true }); + await purge(client, logger, channel, threadTs, apiKey.user_id, { api: true, reason }); } catch (error) { const slackError = error.data?.error || "purge_failed"; logger.error(`[api] thread purge failed for ${channel}/${threadTs}: ${slackError}`);