feat: implement web-driven voice connection with guild/channel selection and API integration
This commit is contained in:
+2
-2
@@ -3,8 +3,8 @@ import { ConfigError } from "./errors";
|
||||
|
||||
const configSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"),
|
||||
GUILD_ID: z.string().min(1, "GUILD_ID is required"),
|
||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||
GUILD_ID: z.string().min(1).optional(),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
+8
-62
@@ -2,22 +2,20 @@ import "./mock-crc";
|
||||
import "libsodium-wrappers";
|
||||
import "@snazzah/davey";
|
||||
import "dotenv/config";
|
||||
import { getVoiceConnection } from "@discordjs/voice";
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "./config";
|
||||
import { createChildLogger } from "./logger";
|
||||
import { discordPlayer } from "./player";
|
||||
import { startRecording, stopRecording } from "./recorder";
|
||||
import { VoiceController } from "./voiceController";
|
||||
import { startWebserver } from "./webserver";
|
||||
|
||||
const logger = createChildLogger("bot");
|
||||
|
||||
const token = config.DISCORD_TOKEN;
|
||||
const voiceChannelId = config.VOICE_CHANNEL_ID;
|
||||
const guildId = config.GUILD_ID;
|
||||
|
||||
// Inisialisasi selfbot client
|
||||
const client = new Client();
|
||||
const voiceController = new VoiceController(client);
|
||||
|
||||
// Track shutdown state
|
||||
let isShuttingDown = false;
|
||||
@@ -32,30 +30,15 @@ async function gracefulShutdown(signal: string) {
|
||||
logger.info({ signal }, "Graceful shutdown initiated");
|
||||
|
||||
try {
|
||||
// Step 1: Stop recording
|
||||
if (guildId) {
|
||||
logger.info("Stopping recording...");
|
||||
stopRecording(guildId);
|
||||
}
|
||||
// Step 1: Stop voice connection
|
||||
logger.info("Stopping voice connection...");
|
||||
await voiceController.disconnect();
|
||||
|
||||
// Step 2: Pause player
|
||||
logger.info("Pausing player...");
|
||||
discordPlayer.pause();
|
||||
|
||||
// Step 3: Destroy voice connection
|
||||
if (guildId) {
|
||||
const connection = getVoiceConnection(guildId);
|
||||
if (connection) {
|
||||
logger.info("Destroying voice connection...");
|
||||
try {
|
||||
connection.destroy();
|
||||
} catch (err) {
|
||||
logger.warn({ error: err }, "Error destroying voice connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Destroy client
|
||||
// Step 3: Destroy client
|
||||
logger.info("Destroying Discord client...");
|
||||
try {
|
||||
client.destroy();
|
||||
@@ -72,45 +55,8 @@ async function gracefulShutdown(signal: string) {
|
||||
}
|
||||
|
||||
client.on("ready", async () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||
}
|
||||
|
||||
// Ambil guild
|
||||
const guild = client.guilds.cache.get(guildId!);
|
||||
if (!guild) {
|
||||
logger.error({ guildId }, "Guild not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fetch channels jika belum ada di cache
|
||||
const channel =
|
||||
guild.channels.cache.get(voiceChannelId!) ??
|
||||
(await guild.channels.fetch(voiceChannelId!).catch(() => null));
|
||||
|
||||
if (!channel || channel.type !== "GUILD_VOICE") {
|
||||
logger.error({ voiceChannelId }, "Voice channel not found or wrong type");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ channelName: channel.name, channelId: channel.id },
|
||||
"Joining voice channel",
|
||||
);
|
||||
}
|
||||
|
||||
await startRecording(client, channel as any);
|
||||
|
||||
// Set up player connection
|
||||
const connection = getVoiceConnection(guildId!);
|
||||
if (connection) {
|
||||
discordPlayer.setConnection(connection);
|
||||
logger.info("Player connected to voice channel");
|
||||
}
|
||||
|
||||
// Start Webserver
|
||||
startWebserver(config.WEBSERVER_PORT);
|
||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||
startWebserver(config.WEBSERVER_PORT, client, voiceController);
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
|
||||
+6
-3
@@ -5,6 +5,7 @@ import {
|
||||
entersState,
|
||||
getVoiceConnection,
|
||||
joinVoiceChannel,
|
||||
type VoiceConnection,
|
||||
VoiceConnectionStatus,
|
||||
} from "@discordjs/voice";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
@@ -36,7 +37,7 @@ if (!fs.existsSync(recordingsDir)) {
|
||||
export async function startRecording(
|
||||
client: Client,
|
||||
channel: VoiceChannel,
|
||||
): Promise<void> {
|
||||
): Promise<VoiceConnection | null> {
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
@@ -78,7 +79,7 @@ export async function startRecording(
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
||||
connection.destroy();
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const receiver = connection.receiver;
|
||||
@@ -118,7 +119,7 @@ export async function startRecording(
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 3000,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||
@@ -237,6 +238,8 @@ export async function startRecording(
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
-1
@@ -1,3 +1,4 @@
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import http from "http";
|
||||
@@ -5,9 +6,11 @@ import path from "path";
|
||||
import pinoHttp from "pino-http";
|
||||
import prism from "prism-media";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { AppError } from "./errors";
|
||||
import { createChildLogger, logger } from "./logger";
|
||||
import { getMetrics, uptimeGauge } from "./metrics";
|
||||
import { discordPlayer } from "./player";
|
||||
import type { VoiceController } from "./voiceController";
|
||||
|
||||
const wsLogger = createChildLogger("webserver");
|
||||
|
||||
@@ -42,7 +45,11 @@ function rmsDb(pcm: Buffer): number {
|
||||
return 20 * Math.log10(Math.max(rms, 1e-10));
|
||||
}
|
||||
|
||||
export function startWebserver(port: number = 3000) {
|
||||
export function startWebserver(
|
||||
port: number = 3000,
|
||||
_client: Client,
|
||||
voiceController: VoiceController,
|
||||
) {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -55,6 +62,7 @@ export function startWebserver(port: number = 3000) {
|
||||
|
||||
// HTTP request logging
|
||||
app.use(pinoHttp({ logger }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use(express.static(path.join(__dirname, "../public")));
|
||||
|
||||
@@ -76,6 +84,51 @@ export function startWebserver(port: number = 3000) {
|
||||
res.send(await getMetrics());
|
||||
});
|
||||
|
||||
app.get("/api/status", (_req, res) => {
|
||||
res.json(voiceController.getStatus());
|
||||
});
|
||||
|
||||
app.get("/api/guilds", (_req, res) => {
|
||||
res.json(voiceController.listGuilds());
|
||||
});
|
||||
|
||||
app.get("/api/guilds/:guildId/voice-channels", async (req, res, next) => {
|
||||
try {
|
||||
res.json(await voiceController.listVoiceChannels(req.params.guildId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/connect", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
if (!guildId || !channelId) {
|
||||
throw new AppError(
|
||||
"guildId and channelId are required",
|
||||
"MISSING_CONNECT_FIELDS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
res.json(await voiceController.connect(guildId, channelId));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/disconnect", async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await voiceController.disconnect());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Inbound: Discord PCM → tagged chunks → browser
|
||||
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
||||
let hash = 0;
|
||||
@@ -233,6 +286,29 @@ export function startWebserver(port: number = 3000) {
|
||||
});
|
||||
});
|
||||
|
||||
app.use(
|
||||
(
|
||||
error: Error,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
if (error instanceof AppError) {
|
||||
res.status(error.statusCode).json({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
wsLogger.error({ error }, "Unhandled webserver error");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal server error",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
wsLogger.info({ port }, "Web interface listening");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user