diff --git a/docs/superpowers/plans/2026-05-13-web-interactive-voice-connect.md b/docs/superpowers/plans/2026-05-13-web-interactive-voice-connect.md new file mode 100644 index 0000000..8cf55d9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-web-interactive-voice-connect.md @@ -0,0 +1,440 @@ +# Web Interactive Voice Connect 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:** Replace startup auto-connect with web UI guild/channel selection and make voice connection cleanup/reconnect more stable. + +**Architecture:** Add a `VoiceController` module that owns active voice state, connect/disconnect, guild/channel listing, and player binding. `src/index.ts` only logs in and starts webserver after Discord ready. `src/webserver.ts` exposes JSON APIs used by dropdown controls in `public/index.html`. UI rendering uses DOM methods, not raw HTML injection. + +**Tech Stack:** TypeScript, discord.js-selfbot-v13, @discordjs/voice, Express, WebSocket, plain browser JavaScript, Bun, Vitest, Biome. + +--- + +## File Structure + +- Create `src/voiceController.ts`: active connection state, guild/channel listing, connect/disconnect. +- Modify `src/config.ts`: make `GUILD_ID` and `VOICE_CHANNEL_ID` optional. +- Modify `src/index.ts`: remove auto-connect; start webserver with Discord client and voice controller. +- Modify `src/recorder.ts`: return voice connection from `startRecording`; use configured silence duration; keep bounded reconnect behavior. +- Modify `src/webserver.ts`: add API routes and JSON error handling. +- Modify `public/index.html`: add connection panel and dropdown behavior. +- Modify `tests/config.test.ts`: assert optional guild/channel config. + +--- + +### Task 1: Config Optional Guild/Channel + +**Files:** +- Modify: `src/config.ts` +- Modify: `tests/config.test.ts` + +- [ ] **Step 1: Update config test** + +Add these assertions after `expect(config.NODE_ENV).toBe("test");` in `tests/config.test.ts`: + +```ts +expect(config.GUILD_ID).toBeUndefined(); +expect(config.VOICE_CHANNEL_ID).toBeUndefined(); +``` + +- [ ] **Step 2: Verify RED** + +Run: + +```bash +bun run test tests/config.test.ts +``` + +Expected: FAIL because `GUILD_ID` and `VOICE_CHANNEL_ID` are still required. + +- [ ] **Step 3: Make config optional** + +In `src/config.ts`, replace: + +```ts +VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"), +GUILD_ID: z.string().min(1, "GUILD_ID is required"), +``` + +With: + +```ts +VOICE_CHANNEL_ID: z.string().min(1).optional(), +GUILD_ID: z.string().min(1).optional(), +``` + +- [ ] **Step 4: Verify GREEN** + +Run: + +```bash +bun run test tests/config.test.ts +``` + +Expected: PASS. + +--- + +### Task 2: Voice Controller Module + +**Files:** +- Create: `src/voiceController.ts` +- Modify: `src/recorder.ts` + +- [ ] **Step 1: Create `src/voiceController.ts`** + +```ts +import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; +import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; +import { AppError } from "./errors"; +import { createChildLogger } from "./logger"; +import { discordPlayer } from "./player"; +import { startRecording, stopRecording } from "./recorder"; + +const logger = createChildLogger("voice-controller"); + +export interface VoiceStatus { + ready: boolean; + connected: boolean; + activeGuildId: string | null; + activeChannelId: string | null; + activeChannelName: string | null; +} + +export interface GuildSummary { + id: string; + name: string; +} + +export interface VoiceChannelSummary { + id: string; + name: string; +} + +export class VoiceController { + private activeGuildId: string | null = null; + private activeChannelId: string | null = null; + private activeChannelName: string | null = null; + private connecting = false; + + constructor(private readonly client: Client) {} + + getStatus(): VoiceStatus { + const connection = this.activeGuildId + ? getVoiceConnection(this.activeGuildId) + : undefined; + return { + ready: this.client.isReady(), + connected: Boolean(connection), + activeGuildId: this.activeGuildId, + activeChannelId: this.activeChannelId, + activeChannelName: this.activeChannelName, + }; + } + + listGuilds(): GuildSummary[] { + return this.client.guilds.cache + .map((guild) => ({ id: guild.id, name: guild.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async listVoiceChannels(guildId: string): Promise { + const guild = this.getGuild(guildId); + await guild.channels.fetch().catch(() => null); + return guild.channels.cache + .filter((channel) => channel.type === "GUILD_VOICE") + .map((channel) => ({ id: channel.id, name: channel.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async connect(guildId: string, channelId: string): Promise { + if (!this.client.isReady()) { + throw new AppError("Discord client is not ready", "CLIENT_NOT_READY", 409); + } + if (this.connecting) { + throw new AppError("Voice connection is already in progress", "CONNECT_IN_PROGRESS", 409); + } + + this.connecting = true; + try { + await this.disconnect(); + const guild = this.getGuild(guildId); + const channel = + guild.channels.cache.get(channelId) ?? + (await guild.channels.fetch(channelId).catch(() => null)); + + if (!channel) { + throw new AppError("Voice channel not found", "VOICE_CHANNEL_NOT_FOUND", 404); + } + if (channel.type !== "GUILD_VOICE") { + throw new AppError("Selected channel is not a voice channel", "INVALID_CHANNEL_TYPE", 400); + } + + const connection = await startRecording(this.client, channel as VoiceChannel); + if (!connection) { + throw new AppError("Failed to connect to voice channel", "VOICE_CONNECT_FAILED", 500); + } + + discordPlayer.setConnection(connection as VoiceConnection); + this.activeGuildId = guildId; + this.activeChannelId = channelId; + this.activeChannelName = channel.name; + logger.info({ guildId, channelId, channelName: channel.name }, "Voice connected"); + return this.getStatus(); + } finally { + this.connecting = false; + } + } + + async disconnect(): Promise { + if (this.activeGuildId) { + stopRecording(this.activeGuildId); + } + discordPlayer.pause(); + this.activeGuildId = null; + this.activeChannelId = null; + this.activeChannelName = null; + return this.getStatus(); + } + + private getGuild(guildId: string): Guild { + const guild = this.client.guilds.cache.get(guildId); + if (!guild) { + throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404); + } + return guild; + } +} +``` + +- [ ] **Step 2: Update recorder return type** + +In `src/recorder.ts`, import `type VoiceConnection` from `@discordjs/voice`, change `startRecording` return type to `Promise`, return `null` on connect failure, and return `connection` as final line of the function. + +- [ ] **Step 3: Use configured silence duration** + +In `src/recorder.ts`, replace hardcoded `duration: 3000` in `receiver.subscribe` with: + +```ts +duration: config.AUDIO_STREAM_SILENCE_DURATION_MS, +``` + +- [ ] **Step 4: Run typecheck** + +Run: + +```bash +bun run typecheck +``` + +Expected: PASS after later call sites updated. + +--- + +### Task 3: Startup Without Auto-Join + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Refactor startup** + +Update `src/index.ts` so it: + +```ts +import { VoiceController } from "./voiceController"; + +const client = new Client(); +const voiceController = new VoiceController(client); +``` + +Remove `voiceChannelId`, `guildId`, auto guild fetch, auto channel fetch, `startRecording`, and `getVoiceConnection` setup from `client.on("ready")`. + +Set ready handler to: + +```ts +client.on("ready", async () => { + logger.info({ user: client.user?.tag }, "Bot logged in"); + startWebserver(config.WEBSERVER_PORT, client, voiceController); +}); +``` + +- [ ] **Step 2: Refactor shutdown** + +In `gracefulShutdown`, replace guild-specific stop/destroy logic with: + +```ts +logger.info("Stopping voice connection..."); +await voiceController.disconnect(); +``` + +Keep player pause and client destroy. + +--- + +### Task 4: Webserver Voice APIs + +**Files:** +- Modify: `src/webserver.ts` + +- [ ] **Step 1: Update signature and imports** + +Add imports: + +```ts +import type { Client } from "discord.js-selfbot-v13"; +import { AppError } from "./errors"; +import type { VoiceController } from "./voiceController"; +``` + +Change function signature: + +```ts +export function startWebserver( + port: number = 3000, + _client: Client, + voiceController: VoiceController, +) { +``` + +- [ ] **Step 2: Enable JSON** + +Add after pino HTTP middleware: + +```ts +app.use(express.json()); +``` + +- [ ] **Step 3: Add API routes** + +Add after `/metrics`: + +```ts +app.get("/api/status", (_req, res) => { + res.json(voiceController.getStatus()); +}); + +app.get("/api/guilds", (_req, res) => { + res.json(voiceController.listGuilds()); +}); + +app.get("/api/guilds/:guildId/voice-channels", async (req, res, next) => { + try { + res.json(await voiceController.listVoiceChannels(req.params.guildId)); + } catch (error) { + next(error); + } +}); + +app.post("/api/connect", async (req, res, next) => { + try { + const { guildId, channelId } = req.body as { guildId?: string; channelId?: string }; + if (!guildId || !channelId) { + throw new AppError("guildId and channelId are required", "MISSING_CONNECT_FIELDS", 400); + } + res.json(await voiceController.connect(guildId, channelId)); + } catch (error) { + next(error); + } +}); + +app.post("/api/disconnect", async (_req, res, next) => { + try { + res.json(await voiceController.disconnect()); + } catch (error) { + next(error); + } +}); +``` + +- [ ] **Step 4: Add API error handler before `server.listen`** + +```ts +app.use( + ( + error: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ error: error.code, message: error.message }); + return; + } + wsLogger.error({ error }, "Unhandled webserver error"); + res.status(500).json({ error: "INTERNAL_SERVER_ERROR", message: "Internal server error" }); + }, +); +``` + +--- + +### Task 5: Frontend Dropdown UI + +**Files:** +- Modify: `public/index.html` + +- [ ] **Step 1: Add connection panel markup** + +Add a panel above transmit/listen controls with selects `guildSelect`, `channelSelect`, buttons `joinVoiceBtn`, `disconnectVoiceBtn`, and text `voiceStatusText`. + +- [ ] **Step 2: Add DOM-safe JS** + +Add helpers that use `document.createElement`, `textContent`, and `appendChild` for dropdown options. Do not use raw `innerHTML` with guild/channel names. + +Use this safe select renderer: + +```js +function renderSelect(select, items, placeholder) { + select.replaceChildren(); + const placeholderOption = document.createElement('option'); + placeholderOption.value = ''; + placeholderOption.textContent = placeholder; + select.appendChild(placeholderOption); + for (const item of items) { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.name; + select.appendChild(option); + } +} +``` + +- [ ] **Step 3: Wire API calls** + +Use `/api/guilds`, `/api/guilds/:guildId/voice-channels`, `/api/connect`, `/api/disconnect`, and `/api/status` to populate and update UI. + +--- + +### Task 6: Verification + +**Files:** +- Verify all modified files. + +- [ ] **Step 1: Automated verification** + +Run: + +```bash +bun run test && bun run typecheck && bun run lint && bun run build +``` + +Expected: PASS. + +- [ ] **Step 2: Manual smoke test** + +Run: + +```bash +bun run dev +``` + +Open `http://localhost:3000`. Confirm guild dropdown loads, channels load after guild selection, Join connects, Disconnect leaves, and mic/listen still work after joining. + +--- + +## Self-Review + +- Spec coverage: Covers optional config, no startup auto-connect, dropdown guild/channel UI, API endpoints, connect/disconnect, and safer cleanup. +- Placeholder scan: No TBD/TODO placeholders. +- Type consistency: `VoiceController` method names match API routes and frontend calls. +- Security: Dropdown rendering uses DOM methods instead of raw HTML for remote guild/channel names. diff --git a/docs/superpowers/specs/2026-05-13-web-interactive-voice-connect-design.md b/docs/superpowers/specs/2026-05-13-web-interactive-voice-connect-design.md new file mode 100644 index 0000000..99a15d4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-web-interactive-voice-connect-design.md @@ -0,0 +1,91 @@ +# Web Interactive Voice Connect Design + +## Goal + +Replace startup auto-connect with web-driven guild and voice channel selection. + +## Current Behavior + +The bot reads `GUILD_ID` and `VOICE_CHANNEL_ID` from config on startup. When Discord client emits `ready`, `src/index.ts` immediately fetches that guild/channel, joins the voice channel, starts recording, connects the player, then starts the webserver. + +## New Behavior + +The bot should login to Discord and start the webserver immediately. The web UI should let the user select a guild and voice channel from dropdowns, then connect or disconnect without restarting the bot. + +## API + +Add HTTP endpoints in `src/webserver.ts`, backed by the Discord client passed from `src/index.ts`. + +- `GET /api/status` + - returns `{ ready, connected, activeGuildId, activeChannelId, activeChannelName }` +- `GET /api/guilds` + - returns guilds available in `client.guilds.cache` + - shape: `{ id, name }[]` +- `GET /api/guilds/:guildId/voice-channels` + - fetches guild by id + - returns voice channels only + - shape: `{ id, name }[]` +- `POST /api/connect` + - body: `{ guildId, channelId }` + - stops existing recording/connection if connected + - validates guild exists and channel is `GUILD_VOICE` + - calls `startRecording(client, channel)` + - updates active connection state + - calls `discordPlayer.setConnection(getVoiceConnection(guildId))` +- `POST /api/disconnect` + - stops current recording if connected + - clears active connection state + - pauses player + +## Config + +`DISCORD_TOKEN` remains required. `GUILD_ID` and `VOICE_CHANNEL_ID` become optional because selection happens in the web UI. + +## Frontend + +Update `public/index.html` with a small connection panel above current audio controls: + +- Guild dropdown +- Channel dropdown +- Join Channel button +- Disconnect button +- Connection status text + +Flow: + +1. On page load, fetch `/api/status` and `/api/guilds`. +2. When guild changes, fetch `/api/guilds/:guildId/voice-channels`. +3. Join button sends selected guild/channel to `/api/connect`. +4. Disconnect button sends `/api/disconnect`. +5. Existing transmit/listen WebSocket behavior remains unchanged. + +## Error Handling + +API returns `400` for missing ids or invalid channel type, `404` for missing guild/channel, and `409` if Discord client is not ready. Frontend shows error text in the connection panel. + +## Testing + +Run: + +```bash +bun run test +bun run typecheck +bun run lint +bun run build +``` + +Manual browser smoke test: + +1. Start bot. +2. Open web UI. +3. Confirm guild dropdown loads. +4. Select guild, confirm voice channel dropdown loads. +5. Click Join Channel, confirm status changes and bot joins voice. +6. Click Disconnect, confirm bot leaves voice. + +## Self-Review + +- No placeholders. +- Scope is focused on web-driven voice connect only. +- Existing WebSocket audio path remains unchanged. +- Config change matches interactive selection requirement. diff --git a/public/index.html b/public/index.html index 1dab678..6f2750f 100644 --- a/public/index.html +++ b/public/index.html @@ -121,6 +121,40 @@ border-left-color: var(--success); background: rgba(67, 181, 129, 0.1); } + + .connection-panel { + gap: 0.75rem; + text-align: left; + } + + .field-group { + display: flex; + flex-direction: column; + gap: 0.35rem; + } + + .field-group label { + font-size: 0.8rem; + color: var(--text-muted); + } + + .field-group select { + padding: 0.65rem; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.12); + background: #2f3136; + color: var(--text); + } + + .button-row { + display: flex; + gap: 0.5rem; + } + + .voice-status { + font-size: 0.85rem; + color: var(--text-muted); + } @@ -128,6 +162,22 @@

Discord Gateway v4

Optimized 24kHz Mono Bridge

+
+
+ + +
+
+ + +
+
+ + +
+
Not connected
+
+
@@ -156,6 +206,11 @@ const listenStatus = document.getElementById('listenStatus'); const userList = document.getElementById('userList'); const visualizer = document.getElementById('visualizer'); + const guildSelect = document.getElementById('guildSelect'); + const channelSelect = document.getElementById('channelSelect'); + const joinVoiceBtn = document.getElementById('joinVoiceBtn'); + const disconnectVoiceBtn = document.getElementById('disconnectVoiceBtn'); + const voiceStatusText = document.getElementById('voiceStatusText'); for (let i = 0; i < 32; i++) { const bar = document.createElement('div'); @@ -179,6 +234,62 @@ const NOISE_GATE_HOLD_FRAMES = 3; let noiseGateHold = 0; + async function apiRequest(url, options = {}) { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(error.message || response.statusText); + } + return response.json(); + } + + function setVoiceStatus(message, isError = false) { + voiceStatusText.innerText = message; + voiceStatusText.style.color = isError ? '#f04747' : 'var(--text-muted)'; + } + + function renderSelect(select, items, placeholder) { + select.replaceChildren(); + const placeholderOption = document.createElement('option'); + placeholderOption.value = ''; + placeholderOption.textContent = placeholder; + select.appendChild(placeholderOption); + for (const item of items) { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = item.name; + select.appendChild(option); + } + } + + async function loadGuilds() { + const guilds = await apiRequest('/api/guilds'); + renderSelect(guildSelect, guilds, 'Select guild'); + } + + async function loadVoiceChannels(guildId) { + if (!guildId) { + renderSelect(channelSelect, [], 'Select voice channel'); + return; + } + const channels = await apiRequest(`/api/guilds/${guildId}/voice-channels`); + renderSelect(channelSelect, channels, 'Select voice channel'); + } + + async function refreshVoiceStatus() { + const status = await apiRequest('/api/status'); + if (status.connected) { + setVoiceStatus(`Connected to ${status.activeChannelName || status.activeChannelId}`); + } else if (status.ready) { + setVoiceStatus('Discord ready. Select a voice channel.'); + } else { + setVoiceStatus('Discord client is not ready yet.'); + } + } + function initWebSocket() { if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; @@ -253,6 +364,45 @@ userTimelines.set(userIdHash, userNextStartTime); } + guildSelect.onchange = async () => { + try { + await loadVoiceChannels(guildSelect.value); + } catch (error) { + setVoiceStatus(error.message, true); + } + }; + + joinVoiceBtn.onclick = async () => { + try { + if (!guildSelect.value || !channelSelect.value) { + setVoiceStatus('Select guild and voice channel first.', true); + return; + } + joinVoiceBtn.disabled = true; + const status = await apiRequest('/api/connect', { + method: 'POST', + body: JSON.stringify({ guildId: guildSelect.value, channelId: channelSelect.value }), + }); + setVoiceStatus(`Connected to ${status.activeChannelName || status.activeChannelId}`); + } catch (error) { + setVoiceStatus(error.message, true); + } finally { + joinVoiceBtn.disabled = false; + } + }; + + disconnectVoiceBtn.onclick = async () => { + try { + disconnectVoiceBtn.disabled = true; + await apiRequest('/api/disconnect', { method: 'POST' }); + setVoiceStatus('Disconnected.'); + } catch (error) { + setVoiceStatus(error.message, true); + } finally { + disconnectVoiceBtn.disabled = false; + } + }; + toggleBtn.onclick = async () => { if (isStreaming) { stopStreaming(); @@ -350,6 +500,8 @@ } }; + loadGuilds().catch((error) => setVoiceStatus(error.message, true)); + refreshVoiceStatus().catch((error) => setVoiceStatus(error.message, true)); initWebSocket(); diff --git a/src/config.ts b/src/config.ts index ce708b3..bad524c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,8 +3,8 @@ import { ConfigError } from "./errors"; const configSchema = z.object({ DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), - VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"), - GUILD_ID: z.string().min(1, "GUILD_ID is required"), + VOICE_CHANNEL_ID: z.string().min(1).optional(), + GUILD_ID: z.string().min(1).optional(), VERBOSE: z .string() .optional() diff --git a/src/index.ts b/src/index.ts index 72637ff..db538b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,22 +2,20 @@ import "./mock-crc"; import "libsodium-wrappers"; import "@snazzah/davey"; import "dotenv/config"; -import { getVoiceConnection } from "@discordjs/voice"; import { Client } from "discord.js-selfbot-v13"; import { config } from "./config"; import { createChildLogger } from "./logger"; import { discordPlayer } from "./player"; -import { startRecording, stopRecording } from "./recorder"; +import { VoiceController } from "./voiceController"; import { startWebserver } from "./webserver"; const logger = createChildLogger("bot"); const token = config.DISCORD_TOKEN; -const voiceChannelId = config.VOICE_CHANNEL_ID; -const guildId = config.GUILD_ID; // Inisialisasi selfbot client const client = new Client(); +const voiceController = new VoiceController(client); // Track shutdown state let isShuttingDown = false; @@ -32,30 +30,15 @@ async function gracefulShutdown(signal: string) { logger.info({ signal }, "Graceful shutdown initiated"); try { - // Step 1: Stop recording - if (guildId) { - logger.info("Stopping recording..."); - stopRecording(guildId); - } + // Step 1: Stop voice connection + logger.info("Stopping voice connection..."); + await voiceController.disconnect(); // Step 2: Pause player logger.info("Pausing player..."); discordPlayer.pause(); - // Step 3: Destroy voice connection - if (guildId) { - const connection = getVoiceConnection(guildId); - if (connection) { - logger.info("Destroying voice connection..."); - try { - connection.destroy(); - } catch (err) { - logger.warn({ error: err }, "Error destroying voice connection"); - } - } - } - - // Step 4: Destroy client + // Step 3: Destroy client logger.info("Destroying Discord client..."); try { client.destroy(); @@ -72,45 +55,8 @@ async function gracefulShutdown(signal: string) { } client.on("ready", async () => { - if (config.VERBOSE) { - logger.info({ user: client.user?.tag }, "Bot logged in"); - } - - // Ambil guild - const guild = client.guilds.cache.get(guildId!); - if (!guild) { - logger.error({ guildId }, "Guild not found"); - process.exit(1); - } - - // Fetch channels jika belum ada di cache - const channel = - guild.channels.cache.get(voiceChannelId!) ?? - (await guild.channels.fetch(voiceChannelId!).catch(() => null)); - - if (!channel || channel.type !== "GUILD_VOICE") { - logger.error({ voiceChannelId }, "Voice channel not found or wrong type"); - process.exit(1); - } - - if (config.VERBOSE) { - logger.info( - { channelName: channel.name, channelId: channel.id }, - "Joining voice channel", - ); - } - - await startRecording(client, channel as any); - - // Set up player connection - const connection = getVoiceConnection(guildId!); - if (connection) { - discordPlayer.setConnection(connection); - logger.info("Player connected to voice channel"); - } - - // Start Webserver - startWebserver(config.WEBSERVER_PORT); + logger.info({ user: client.user?.tag }, "Bot logged in"); + startWebserver(config.WEBSERVER_PORT, client, voiceController); }); client.on("error", (err) => { diff --git a/src/recorder.ts b/src/recorder.ts index a3487f7..8c9b0ef 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -5,6 +5,7 @@ import { entersState, getVoiceConnection, joinVoiceChannel, + type VoiceConnection, VoiceConnectionStatus, } from "@discordjs/voice"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; @@ -36,7 +37,7 @@ if (!fs.existsSync(recordingsDir)) { export async function startRecording( client: Client, channel: VoiceChannel, -): Promise { +): Promise { const connection = joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, @@ -78,7 +79,7 @@ export async function startRecording( } catch (err) { logger.error({ error: err }, "Failed to connect to voice channel"); connection.destroy(); - return; + return null; } const receiver = connection.receiver; @@ -118,7 +119,7 @@ export async function startRecording( const audioStream = receiver.subscribe(userId, { end: { behavior: EndBehaviorType.AfterSilence, - duration: 3000, + duration: config.AUDIO_STREAM_SILENCE_DURATION_MS, }, }); const oggPacketStream = audioStream.pipe(packetFilterForOgg); @@ -237,6 +238,8 @@ export async function startRecording( logger.info("Voice connection destroyed"); } }); + + return connection; } /** diff --git a/src/voiceController.ts b/src/voiceController.ts new file mode 100644 index 0000000..7f3def1 --- /dev/null +++ b/src/voiceController.ts @@ -0,0 +1,159 @@ +import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; +import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; +import { AppError } from "./errors"; +import { createChildLogger } from "./logger"; +import { discordPlayer } from "./player"; +import { startRecording, stopRecording } from "./recorder"; + +const logger = createChildLogger("voice-controller"); + +export interface VoiceStatus { + ready: boolean; + connected: boolean; + activeGuildId: string | null; + activeChannelId: string | null; + activeChannelName: string | null; +} + +export interface GuildSummary { + id: string; + name: string; +} + +export interface VoiceChannelSummary { + id: string; + name: string; +} + +export class VoiceController { + private activeGuildId: string | null = null; + private activeChannelId: string | null = null; + private activeChannelName: string | null = null; + private connecting = false; + + constructor(private readonly client: Client) {} + + getStatus(): VoiceStatus { + const connection = this.activeGuildId + ? getVoiceConnection(this.activeGuildId) + : undefined; + + return { + ready: this.client.isReady(), + connected: Boolean(connection), + activeGuildId: this.activeGuildId, + activeChannelId: this.activeChannelId, + activeChannelName: this.activeChannelName, + }; + } + + listGuilds(): GuildSummary[] { + return this.client.guilds.cache + .map((guild) => ({ id: guild.id, name: guild.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async listVoiceChannels(guildId: string): Promise { + const guild = this.getGuild(guildId); + await guild.channels.fetch().catch(() => null); + + return guild.channels.cache + .filter((channel) => channel.type === "GUILD_VOICE") + .map((channel) => ({ id: channel.id, name: channel.name })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + async connect(guildId: string, channelId: string): Promise { + if (!this.client.isReady()) { + throw new AppError( + "Discord client is not ready", + "CLIENT_NOT_READY", + 409, + ); + } + + if (this.connecting) { + throw new AppError( + "Voice connection is already in progress", + "CONNECT_IN_PROGRESS", + 409, + ); + } + + this.connecting = true; + + try { + await this.disconnect(); + + const guild = this.getGuild(guildId); + const channel = + guild.channels.cache.get(channelId) ?? + (await guild.channels.fetch(channelId).catch(() => null)); + + if (!channel) { + throw new AppError( + "Voice channel not found", + "VOICE_CHANNEL_NOT_FOUND", + 404, + ); + } + + if (channel.type !== "GUILD_VOICE") { + throw new AppError( + "Selected channel is not a voice channel", + "INVALID_CHANNEL_TYPE", + 400, + ); + } + + const connection = await startRecording( + this.client, + channel as VoiceChannel, + ); + if (!connection) { + throw new AppError( + "Failed to connect to voice channel", + "VOICE_CONNECT_FAILED", + 500, + ); + } + + discordPlayer.setConnection(connection as VoiceConnection); + this.activeGuildId = guildId; + this.activeChannelId = channelId; + this.activeChannelName = channel.name; + + logger.info( + { guildId, channelId, channelName: channel.name }, + "Voice connected", + ); + + return this.getStatus(); + } finally { + this.connecting = false; + } + } + + async disconnect(): Promise { + if (this.activeGuildId) { + stopRecording(this.activeGuildId); + } + + discordPlayer.pause(); + this.activeGuildId = null; + this.activeChannelId = null; + this.activeChannelName = null; + + return this.getStatus(); + } + + private getGuild(guildId: string): Guild { + const guild = this.client.guilds.cache.get(guildId); + + if (!guild) { + throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404); + } + + return guild; + } +} diff --git a/src/webserver.ts b/src/webserver.ts index a060acb..98f12c9 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -1,3 +1,4 @@ +import type { Client } from "discord.js-selfbot-v13"; import express from "express"; import helmet from "helmet"; import http from "http"; @@ -5,9 +6,11 @@ import path from "path"; import pinoHttp from "pino-http"; import prism from "prism-media"; import { WebSocketServer } from "ws"; +import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { getMetrics, uptimeGauge } from "./metrics"; import { discordPlayer } from "./player"; +import type { VoiceController } from "./voiceController"; const wsLogger = createChildLogger("webserver"); @@ -42,7 +45,11 @@ function rmsDb(pcm: Buffer): number { return 20 * Math.log10(Math.max(rms, 1e-10)); } -export function startWebserver(port: number = 3000) { +export function startWebserver( + port: number = 3000, + _client: Client, + voiceController: VoiceController, +) { const app = express(); const server = http.createServer(app); @@ -55,6 +62,7 @@ export function startWebserver(port: number = 3000) { // HTTP request logging app.use(pinoHttp({ logger })); + app.use(express.json()); app.use(express.static(path.join(__dirname, "../public"))); @@ -76,6 +84,51 @@ export function startWebserver(port: number = 3000) { res.send(await getMetrics()); }); + app.get("/api/status", (_req, res) => { + res.json(voiceController.getStatus()); + }); + + app.get("/api/guilds", (_req, res) => { + res.json(voiceController.listGuilds()); + }); + + app.get("/api/guilds/:guildId/voice-channels", async (req, res, next) => { + try { + res.json(await voiceController.listVoiceChannels(req.params.guildId)); + } catch (error) { + next(error); + } + }); + + app.post("/api/connect", async (req, res, next) => { + try { + const { guildId, channelId } = req.body as { + guildId?: string; + channelId?: string; + }; + + if (!guildId || !channelId) { + throw new AppError( + "guildId and channelId are required", + "MISSING_CONNECT_FIELDS", + 400, + ); + } + + res.json(await voiceController.connect(guildId, channelId)); + } catch (error) { + next(error); + } + }); + + app.post("/api/disconnect", async (_req, res, next) => { + try { + res.json(await voiceController.disconnect()); + } catch (error) { + next(error); + } + }); + // Inbound: Discord PCM → tagged chunks → browser (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { let hash = 0; @@ -233,6 +286,29 @@ export function startWebserver(port: number = 3000) { }); }); + app.use( + ( + error: Error, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + error: error.code, + message: error.message, + }); + return; + } + + wsLogger.error({ error }, "Unhandled webserver error"); + res.status(500).json({ + error: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); + }, + ); + server.listen(port, "0.0.0.0", () => { wsLogger.info({ port }, "Web interface listening"); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 9eb73e6..e899b05 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,8 +13,6 @@ describe("loadConfig", () => { process.env = { ...originalEnv, DISCORD_TOKEN: "token", - VOICE_CHANNEL_ID: "voice-channel", - GUILD_ID: "guild", VERBOSE: "true", WEBSERVER_PORT: "4000", NODE_ENV: "test", @@ -24,8 +22,8 @@ describe("loadConfig", () => { const config = loadConfig(process.env); expect(config.DISCORD_TOKEN).toBe("token"); - expect(config.VOICE_CHANNEL_ID).toBe("voice-channel"); - expect(config.GUILD_ID).toBe("guild"); + expect(config.GUILD_ID).toBeUndefined(); + expect(config.VOICE_CHANNEL_ID).toBeUndefined(); expect(config.VERBOSE).toBe(true); expect(config.WEBSERVER_PORT).toBe(4000); expect(config.RECORDINGS_DIR).toBe("./recordings");