feat: implement web-driven voice connection with guild/channel selection and API integration

This commit is contained in:
MythEclipse
2026-05-13 18:23:20 +07:00
parent a5a794c590
commit 4dadcf3871
9 changed files with 937 additions and 72 deletions
@@ -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<VoiceChannelSummary[]> {
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<VoiceStatus> {
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<VoiceStatus> {
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<VoiceConnection | null>`, 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.
@@ -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.