feat: implement web-driven voice connection with guild/channel selection and API integration
This commit is contained in:
@@ -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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
68
src/index.ts
68
src/index.ts
@@ -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) => {
|
||||||
|
|||||||
@@ -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
159
src/voiceController.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user