From 95cb8b837aa2fe958f7f03b230d2305248f9a6c6 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 21:22:05 +0700 Subject: [PATCH] feat(thread-discovery): implement separate endpoint for fetching threads and update channel loading logic --- .../2026-05-13-separate-thread-discovery.md | 38 ++++++++++++++++++ public/index.html | 16 +++++++- src/voiceController.ts | 39 ++++++++++++------- src/webserver.ts | 8 ++++ 4 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-13-separate-thread-discovery.md diff --git a/docs/superpowers/plans/2026-05-13-separate-thread-discovery.md b/docs/superpowers/plans/2026-05-13-separate-thread-discovery.md new file mode 100644 index 0000000..e53d138 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-separate-thread-discovery.md @@ -0,0 +1,38 @@ +# Separate Thread Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep channel listing fast and move expensive active/archived thread discovery to a separate endpoint loaded asynchronously by the homepage. + +**Architecture:** `VoiceController` exposes cache-only channels and network-backed threads separately. `webserver.ts` adds `/api/guilds/:guildId/threads`. `public/index.html` loads channels first, then appends thread options after thread endpoint returns. + +**Tech Stack:** TypeScript, discord.js-selfbot-v13, Express, vanilla JS. + +--- + +### Task 1: Add thread discovery method + +**Files:** +- Modify: `src/voiceController.ts` + +- [ ] Add `listThreads(guildId)` that fetches active and archived threads per parent text channel. +- [ ] Keep `listWatchableChannels` cache-only. +- [ ] Verify typecheck. + +### Task 2: Add thread API endpoint + +**Files:** +- Modify: `src/webserver.ts` + +- [ ] Add `GET /api/guilds/:guildId/threads`. +- [ ] Return thread summaries. +- [ ] Verify typecheck. + +### Task 3: Update homepage dropdown loading + +**Files:** +- Modify: `public/index.html` + +- [ ] `loadChannels` fetches `/channels` first and renders immediately. +- [ ] Then fetches `/threads` async and appends thread options. +- [ ] Verify typecheck/tests. diff --git a/public/index.html b/public/index.html index 1e73613..a4eb9f9 100644 --- a/public/index.html +++ b/public/index.html @@ -625,7 +625,21 @@ apiRequest(`/api/guilds/${guildId}/channels`), ]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); - renderOptions(el.channelFilter, watchChannels, 'Select channel or thread'); + renderOptions(el.channelFilter, watchChannels, 'Select channel'); + apiRequest(`/api/guilds/${guildId}/threads`) + .then((threads) => appendOptions(el.channelFilter, threads)) + .catch((error) => showError(`Thread discovery failed: ${error.message}`)); + } + + function appendOptions(select, items) { + const existing = new Set([...select.options].map((option) => option.value)); + for (const item of items) { + if (existing.has(item.id)) continue; + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.name; + select.appendChild(option); + } } async function refreshStatus() { diff --git a/src/voiceController.ts b/src/voiceController.ts index 878c8a2..f17fdbc 100644 --- a/src/voiceController.ts +++ b/src/voiceController.ts @@ -73,33 +73,42 @@ export class VoiceController { const guild = this.getGuild(guildId); await guild.channels.fetch().catch(() => null); - const channels = [...guild.channels.cache.values()].filter((channel) => - ["GUILD_TEXT", "GUILD_PUBLIC_THREAD", "GUILD_PRIVATE_THREAD"].includes( - channel.type, - ), - ); + return guild.channels.cache + .filter((channel) => channel.type === "GUILD_TEXT") + .map((channel) => ({ + id: channel.id, + name: channel.name, + type: channel.type, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + async listThreads(guildId: string): Promise { + const guild = this.getGuild(guildId); + await guild.channels.fetch().catch(() => null); + + const threads: ChannelSummary[] = []; for (const channel of guild.channels.cache.values()) { const threadParent = channel as typeof channel & { threads?: { fetch: (options: { archived: boolean; limit: number }) => Promise }; }; if (!threadParent.threads?.fetch) continue; + for (const archived of [false, true]) { const fetched = await threadParent.threads.fetch({ archived, limit: 100 }).catch(() => null); if (!fetched?.threads) continue; - channels.push(...fetched.threads.values()); + + for (const thread of fetched.threads.values()) { + threads.push({ + id: thread.id, + name: `${channel.name} / ${thread.name}`, + type: thread.type, + }); + } } } - return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values()) - .map((channel) => { - const parentName = channel.isThread?.() ? channel.parent?.name : null; - return { - id: channel.id, - name: parentName ? `${parentName} / ${channel.name}` : channel.name, - type: channel.type, - }; - }) + return Array.from(new Map(threads.map((thread) => [thread.id, thread])).values()) .sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/webserver.ts b/src/webserver.ts index 2e3c067..19565c0 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -114,6 +114,14 @@ export function startWebserver( } }); + app.get("/api/guilds/:guildId/threads", async (req, res, next) => { + try { + res.json(await voiceController.listThreads(req.params.guildId)); + } catch (error) { + next(error); + } + }); + app.post("/api/connect", async (req, res, next) => { try { const { guildId, channelId } = req.body as {