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

View File

@@ -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.

View File

@@ -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.

View File

@@ -121,6 +121,40 @@
border-left-color: var(--success); border-left-color: var(--success);
background: rgba(67, 181, 129, 0.1); 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);
}
</style> </style>
</head> </head>
<body> <body>
@@ -128,6 +162,22 @@
<h1 style="margin-bottom: 0.5rem;">Discord Gateway v4</h1> <h1 style="margin-bottom: 0.5rem;">Discord Gateway v4</h1>
<p style="color: var(--text-muted); font-size: 0.8rem; margin-bottom: 2rem;">Optimized 24kHz Mono Bridge</p> <p style="color: var(--text-muted); font-size: 0.8rem; margin-bottom: 2rem;">Optimized 24kHz Mono Bridge</p>
<div class="status-card connection-panel">
<div class="field-group">
<label for="guildSelect">Guild</label>
<select id="guildSelect"></select>
</div>
<div class="field-group">
<label for="channelSelect">Voice Channel</label>
<select id="channelSelect"></select>
</div>
<div class="button-row">
<button id="joinVoiceBtn" class="btn btn-success">Join Channel</button>
<button id="disconnectVoiceBtn" class="btn btn-danger">Disconnect</button>
</div>
<div id="voiceStatusText" class="voice-status">Not connected</div>
</div>
<div class="status-card"> <div class="status-card">
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<div id="statusIndicator" class="status-indicator"></div> <div id="statusIndicator" class="status-indicator"></div>
@@ -156,6 +206,11 @@
const listenStatus = document.getElementById('listenStatus'); const listenStatus = document.getElementById('listenStatus');
const userList = document.getElementById('userList'); const userList = document.getElementById('userList');
const visualizer = document.getElementById('visualizer'); 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++) { for (let i = 0; i < 32; i++) {
const bar = document.createElement('div'); const bar = document.createElement('div');
@@ -179,6 +234,62 @@
const NOISE_GATE_HOLD_FRAMES = 3; const NOISE_GATE_HOLD_FRAMES = 3;
let noiseGateHold = 0; 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() { function initWebSocket() {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -253,6 +364,45 @@
userTimelines.set(userIdHash, userNextStartTime); 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 () => { toggleBtn.onclick = async () => {
if (isStreaming) { if (isStreaming) {
stopStreaming(); stopStreaming();
@@ -350,6 +500,8 @@
} }
}; };
loadGuilds().catch((error) => setVoiceStatus(error.message, true));
refreshVoiceStatus().catch((error) => setVoiceStatus(error.message, true));
initWebSocket(); initWebSocket();
</script> </script>
</body> </body>

View File

@@ -3,8 +3,8 @@ import { ConfigError } from "./errors";
const configSchema = z.object({ const configSchema = z.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"), VOICE_CHANNEL_ID: z.string().min(1).optional(),
GUILD_ID: z.string().min(1, "GUILD_ID is required"), GUILD_ID: z.string().min(1).optional(),
VERBOSE: z VERBOSE: z
.string() .string()
.optional() .optional()

View File

@@ -2,22 +2,20 @@ import "./mock-crc";
import "libsodium-wrappers"; import "libsodium-wrappers";
import "@snazzah/davey"; import "@snazzah/davey";
import "dotenv/config"; import "dotenv/config";
import { getVoiceConnection } from "@discordjs/voice";
import { Client } from "discord.js-selfbot-v13"; import { Client } from "discord.js-selfbot-v13";
import { config } from "./config"; import { config } from "./config";
import { createChildLogger } from "./logger"; import { createChildLogger } from "./logger";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { startRecording, stopRecording } from "./recorder"; import { VoiceController } from "./voiceController";
import { startWebserver } from "./webserver"; import { startWebserver } from "./webserver";
const logger = createChildLogger("bot"); const logger = createChildLogger("bot");
const token = config.DISCORD_TOKEN; const token = config.DISCORD_TOKEN;
const voiceChannelId = config.VOICE_CHANNEL_ID;
const guildId = config.GUILD_ID;
// Inisialisasi selfbot client // Inisialisasi selfbot client
const client = new Client(); const client = new Client();
const voiceController = new VoiceController(client);
// Track shutdown state // Track shutdown state
let isShuttingDown = false; let isShuttingDown = false;
@@ -32,30 +30,15 @@ async function gracefulShutdown(signal: string) {
logger.info({ signal }, "Graceful shutdown initiated"); logger.info({ signal }, "Graceful shutdown initiated");
try { try {
// Step 1: Stop recording // Step 1: Stop voice connection
if (guildId) { logger.info("Stopping voice connection...");
logger.info("Stopping recording..."); await voiceController.disconnect();
stopRecording(guildId);
}
// Step 2: Pause player // Step 2: Pause player
logger.info("Pausing player..."); logger.info("Pausing player...");
discordPlayer.pause(); discordPlayer.pause();
// Step 3: Destroy voice connection // Step 3: Destroy client
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
logger.info("Destroying Discord client..."); logger.info("Destroying Discord client...");
try { try {
client.destroy(); client.destroy();
@@ -72,45 +55,8 @@ async function gracefulShutdown(signal: string) {
} }
client.on("ready", async () => { client.on("ready", async () => {
if (config.VERBOSE) {
logger.info({ user: client.user?.tag }, "Bot logged in"); logger.info({ user: client.user?.tag }, "Bot logged in");
} startWebserver(config.WEBSERVER_PORT, client, voiceController);
// 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);
}); });
client.on("error", (err) => { client.on("error", (err) => {

View File

@@ -5,6 +5,7 @@ import {
entersState, entersState,
getVoiceConnection, getVoiceConnection,
joinVoiceChannel, joinVoiceChannel,
type VoiceConnection,
VoiceConnectionStatus, VoiceConnectionStatus,
} from "@discordjs/voice"; } from "@discordjs/voice";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
@@ -36,7 +37,7 @@ if (!fs.existsSync(recordingsDir)) {
export async function startRecording( export async function startRecording(
client: Client, client: Client,
channel: VoiceChannel, channel: VoiceChannel,
): Promise<void> { ): Promise<VoiceConnection | null> {
const connection = joinVoiceChannel({ const connection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
@@ -78,7 +79,7 @@ export async function startRecording(
} catch (err) { } catch (err) {
logger.error({ error: err }, "Failed to connect to voice channel"); logger.error({ error: err }, "Failed to connect to voice channel");
connection.destroy(); connection.destroy();
return; return null;
} }
const receiver = connection.receiver; const receiver = connection.receiver;
@@ -118,7 +119,7 @@ export async function startRecording(
const audioStream = receiver.subscribe(userId, { const audioStream = receiver.subscribe(userId, {
end: { end: {
behavior: EndBehaviorType.AfterSilence, behavior: EndBehaviorType.AfterSilence,
duration: 3000, duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
}, },
}); });
const oggPacketStream = audioStream.pipe(packetFilterForOgg); const oggPacketStream = audioStream.pipe(packetFilterForOgg);
@@ -237,6 +238,8 @@ export async function startRecording(
logger.info("Voice connection destroyed"); logger.info("Voice connection destroyed");
} }
}); });
return connection;
} }
/** /**

159
src/voiceController.ts Normal file
View File

@@ -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<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;
}
}

View File

@@ -1,3 +1,4 @@
import type { Client } from "discord.js-selfbot-v13";
import express from "express"; import express from "express";
import helmet from "helmet"; import helmet from "helmet";
import http from "http"; import http from "http";
@@ -5,9 +6,11 @@ import path from "path";
import pinoHttp from "pino-http"; import pinoHttp from "pino-http";
import prism from "prism-media"; import prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger"; import { createChildLogger, logger } from "./logger";
import { getMetrics, uptimeGauge } from "./metrics"; import { getMetrics, uptimeGauge } from "./metrics";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import type { VoiceController } from "./voiceController";
const wsLogger = createChildLogger("webserver"); const wsLogger = createChildLogger("webserver");
@@ -42,7 +45,11 @@ function rmsDb(pcm: Buffer): number {
return 20 * Math.log10(Math.max(rms, 1e-10)); 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 app = express();
const server = http.createServer(app); const server = http.createServer(app);
@@ -55,6 +62,7 @@ export function startWebserver(port: number = 3000) {
// HTTP request logging // HTTP request logging
app.use(pinoHttp({ logger })); app.use(pinoHttp({ logger }));
app.use(express.json());
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
@@ -76,6 +84,51 @@ export function startWebserver(port: number = 3000) {
res.send(await getMetrics()); 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 // Inbound: Discord PCM → tagged chunks → browser
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
let hash = 0; 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", () => { server.listen(port, "0.0.0.0", () => {
wsLogger.info({ port }, "Web interface listening"); wsLogger.info({ port }, "Web interface listening");
}); });

View File

@@ -13,8 +13,6 @@ describe("loadConfig", () => {
process.env = { process.env = {
...originalEnv, ...originalEnv,
DISCORD_TOKEN: "token", DISCORD_TOKEN: "token",
VOICE_CHANNEL_ID: "voice-channel",
GUILD_ID: "guild",
VERBOSE: "true", VERBOSE: "true",
WEBSERVER_PORT: "4000", WEBSERVER_PORT: "4000",
NODE_ENV: "test", NODE_ENV: "test",
@@ -24,8 +22,8 @@ describe("loadConfig", () => {
const config = loadConfig(process.env); const config = loadConfig(process.env);
expect(config.DISCORD_TOKEN).toBe("token"); expect(config.DISCORD_TOKEN).toBe("token");
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel"); expect(config.GUILD_ID).toBeUndefined();
expect(config.GUILD_ID).toBe("guild"); expect(config.VOICE_CHANNEL_ID).toBeUndefined();
expect(config.VERBOSE).toBe(true); expect(config.VERBOSE).toBe(true);
expect(config.WEBSERVER_PORT).toBe(4000); expect(config.WEBSERVER_PORT).toBe(4000);
expect(config.RECORDINGS_DIR).toBe("./recordings"); expect(config.RECORDINGS_DIR).toBe("./recordings");