diff --git a/.env.example b/.env.example index 1e84968..0b5d9f5 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,8 @@ WEBSERVER_PORT=3000 # Connection Configuration VOICE_CONNECTION_TIMEOUT_MS=15000 RECONNECT_TIMEOUT_MS=5000 + +# Logging Configuration +LOG_LEVEL=info +NODE_ENV=development + diff --git a/bun.lockb b/bun.lockb index b547594..0b37022 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 56e65e4..2b1d408 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,21 @@ "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.1", "@snazzah/davey": "^0.1.10", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", "discord.js-selfbot-v13": "^3.7.1", + "dotenv": "^17.4.2", "express": "^5.2.1", "fluent-ffmpeg": "^2.1.3", + "helmet": "^8.1.0", "libsodium-wrappers": "^0.8.2", + "p-retry": "^6.2.0", + "pino": "^9.4.0", + "pino-http": "^11.0.0", "prism-media": "2.0.0-alpha.0", "sodium-native": "^4.3.2", "ws": "^8.20.1", - "pino": "^9.4.0", - "p-retry": "^6.2.0" + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "latest", diff --git a/src/config.ts b/src/config.ts index 09e51ea..00e93bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,61 +1,46 @@ -// Configuration for the bot -export interface AppConfig { - verbose: boolean; - recordingsDir: string; - recordingSegmentMs: number; - decoderRotateMs: number; - decoderCooldownMs: number; - webserverPort: number; - voiceConnectionTimeoutMs: number; - reconnectTimeoutMs: number; - audioStreamSilenceDurationMs: number; - packetFilterMinSize: number; - opusFrameSize: number; - audioSampleRate: number; - audioChannels: number; - avatarSize: number; -} +import { z } from "zod"; +import { ConfigError } from "./errors"; -export function parseBoolean( - value: string | undefined, - fallback: boolean, -): boolean { - if (value === "true") return true; - if (value === "false") return false; - return fallback; -} +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"), + VERBOSE: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + RECORDINGS_DIR: z.string().default("./recordings"), + RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), + DECODER_ROTATE_MS: z.coerce.number().positive().default(5000), + DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), + WEBSERVER_PORT: z.coerce.number().positive().default(3000), + VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), + RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), + AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000), + PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), + OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), + AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), + AUDIO_CHANNELS: z.coerce.number().positive().default(2), + AVATAR_SIZE: z.coerce.number().positive().default(64), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + NODE_ENV: z.enum(["development", "production"]).default("development"), +}); -export function parsePositiveNumber( - value: string | undefined, - fallback: number, -): number { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} +export type AppConfig = z.infer; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { - return { - verbose: parseBoolean(env.VERBOSE, false), - recordingsDir: env.RECORDINGS_DIR ?? "./recordings", - recordingSegmentMs: parsePositiveNumber(env.RECORDING_SEGMENT_MS, 5_000), - decoderRotateMs: parsePositiveNumber(env.DECODER_ROTATE_MS, 5_000), - decoderCooldownMs: parsePositiveNumber(env.DECODER_COOLDOWN_MS, 30_000), - webserverPort: parsePositiveNumber(env.WEBSERVER_PORT, 3000), - voiceConnectionTimeoutMs: parsePositiveNumber( - env.VOICE_CONNECTION_TIMEOUT_MS, - 15_000, - ), - reconnectTimeoutMs: parsePositiveNumber(env.RECONNECT_TIMEOUT_MS, 5_000), - audioStreamSilenceDurationMs: parsePositiveNumber( - env.AUDIO_STREAM_SILENCE_DURATION_MS, - 3000, - ), - packetFilterMinSize: parsePositiveNumber(env.PACKET_FILTER_MIN_SIZE, 8), - opusFrameSize: parsePositiveNumber(env.OPUS_FRAME_SIZE, 960), - audioSampleRate: parsePositiveNumber(env.AUDIO_SAMPLE_RATE, 48000), - audioChannels: parsePositiveNumber(env.AUDIO_CHANNELS, 2), - avatarSize: parsePositiveNumber(env.AVATAR_SIZE, 64), - }; + try { + return configSchema.parse(env); + } catch (error) { + if (error instanceof z.ZodError) { + const messages = error.issues + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join("\n"); + throw new ConfigError(`Configuration validation failed:\n${messages}`); + } + throw error; + } } export const config = loadConfig(); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..1b26a4d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,42 @@ +export class AppError extends Error { + constructor( + message: string, + public code: string, + public statusCode: number = 500, + ) { + super(message); + this.name = "AppError"; + Error.captureStackTrace(this, this.constructor); + } +} + +export class ConfigError extends AppError { + constructor(message: string) { + super(message, "CONFIG_ERROR", 500); + this.name = "ConfigError"; + } +} + +export class AudioError extends AppError { + constructor(message: string) { + super(message, "AUDIO_ERROR", 500); + this.name = "AudioError"; + } +} + +export class VoiceConnectionError extends AppError { + constructor(message: string) { + super(message, "VOICE_CONNECTION_ERROR", 500); + this.name = "VoiceConnectionError"; + } +} + +export class ValidationError extends AppError { + constructor( + message: string, + public details?: Record, + ) { + super(message, "VALIDATION_ERROR", 400); + this.name = "ValidationError"; + } +} diff --git a/src/index.ts b/src/index.ts index 0e11340..9762825 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,21 @@ 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 { AppError } from "./errors"; +import { createChildLogger } from "./logger"; import { discordPlayer } from "./player"; import { startRecording, stopRecording } from "./recorder"; import { startWebserver } from "./webserver"; -import { createChildLogger } from "./logger"; const logger = createChildLogger("bot"); -// Validasi environment variables -const token = process.env.DISCORD_TOKEN; -const voiceChannelId = process.env.VOICE_CHANNEL_ID; -const guildId = process.env.GUILD_ID; - -if (!token) throw new Error("Missing DISCORD_TOKEN in .env"); -if (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env"); -if (!guildId) throw new Error("Missing GUILD_ID in .env"); +const token = config.DISCORD_TOKEN; +const voiceChannelId = config.VOICE_CHANNEL_ID; +const guildId = config.GUILD_ID; // Inisialisasi selfbot client const client = new Client(); @@ -76,7 +73,7 @@ async function gracefulShutdown(signal: string) { } client.on("ready", async () => { - if (config.verbose) { + if (config.VERBOSE) { logger.info({ user: client.user?.tag }, "Bot logged in"); } @@ -97,7 +94,7 @@ client.on("ready", async () => { process.exit(1); } - if (config.verbose) { + if (config.VERBOSE) { logger.info( { channelName: channel.name, channelId: channel.id }, "Joining voice channel", @@ -114,7 +111,7 @@ client.on("ready", async () => { } // Start Webserver - startWebserver(config.webserverPort); + startWebserver(config.WEBSERVER_PORT); }); client.on("error", (err) => { diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..cff7cf7 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,36 @@ +import { NextFunction, Request, Response } from "express"; +import { AppError } from "./errors"; +import { createChildLogger } from "./logger"; + +const logger = createChildLogger("middleware"); + +export function errorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction, +) { + if (err instanceof AppError) { + logger.error( + { code: err.code, statusCode: err.statusCode, message: err.message }, + "Application error", + ); + return res.status(err.statusCode).json({ + error: err.code, + message: err.message, + }); + } + + logger.error({ error: err.message, stack: err.stack }, "Unexpected error"); + res.status(500).json({ + error: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + }); +} + +export function notFoundHandler(_req: Request, res: Response) { + res.status(404).json({ + error: "NOT_FOUND", + message: "Endpoint not found", + }); +} diff --git a/src/recorder.ts b/src/recorder.ts index a208991..a3487f7 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -9,6 +9,7 @@ import { } from "@discordjs/voice"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import { config } from "./config"; +import { createChildLogger } from "./logger"; import { PacketFilter } from "./packetFilter"; import { subscribeToAudioStream } from "./recorder/audioStream"; import { OpusDecoder } from "./recorder/decoder"; @@ -17,13 +18,12 @@ import { createSegmentMetadata, } from "./recorder/metadata"; import { SegmentManager } from "./recorder/segment"; -import type { PcmBroadcaster } from "./types"; -import { createChildLogger } from "./logger"; import { retryWithBackoff } from "./retry"; +import type { PcmBroadcaster } from "./types"; const logger = createChildLogger("recorder"); -const recordingsDir = config.recordingsDir; +const recordingsDir = config.RECORDINGS_DIR; // Pastikan folder recordings ada if (!fs.existsSync(recordingsDir)) { @@ -49,7 +49,7 @@ export async function startRecording( logger.info({ channelName: channel.name }, "Joining voice channel"); connection.on("debug", (msg) => { - if (config.verbose) { + if (config.VERBOSE) { logger.debug({ message: msg }, "Voice debug"); } }); @@ -65,7 +65,7 @@ export async function startRecording( entersState( connection, VoiceConnectionStatus.Ready, - config.voiceConnectionTimeoutMs, + config.VOICE_CONNECTION_TIMEOUT_MS, ), { retries: 3, @@ -87,7 +87,10 @@ export async function startRecording( // Dengarkan siapapun yang mulai bicara receiver.speaking.on("start", async (userId) => { const userMetadata = await collectUserMetadata(client, userId, channel); - logger.info({ userId, username: userMetadata.username }, "Voice activity detected"); + logger.info( + { userId, username: userMetadata.username }, + "Voice activity detected", + ); // Notify webserver broadcaster.updateActiveUser?.(userId, { @@ -109,7 +112,9 @@ export async function startRecording( try { // --- OGG file recording with segment rotation --- - const packetFilterForOgg = new PacketFilter(config.packetFilterMinSize); + const packetFilterForOgg = new PacketFilter( + config.PACKET_FILTER_MIN_SIZE, + ); const audioStream = receiver.subscribe(userId, { end: { behavior: EndBehaviorType.AfterSilence, @@ -119,13 +124,13 @@ export async function startRecording( const oggPacketStream = audioStream.pipe(packetFilterForOgg); const segmentManager = new SegmentManager( userDir, - config.recordingSegmentMs, + config.RECORDING_SEGMENT_MS, ); // --- Web broadcast: prism decoder with safe restart and cooldown --- const decoder = new OpusDecoder({ - cooldownMs: config.decoderCooldownMs, - rotateMs: config.decoderRotateMs, + cooldownMs: config.DECODER_COOLDOWN_MS, + rotateMs: config.DECODER_ROTATE_MS, onData: (pcm) => { if (!broadcaster.broadcastPcmToWeb) return; // Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample) @@ -139,7 +144,7 @@ export async function startRecording( let currentSegment = segmentManager.open(oggPacketStream); currentSegment.out.on("finish", () => { - if (config.verbose) { + if (config.VERBOSE) { logger.info({ filename: currentSegment.filename }, "Segment saved"); } const metadata = createSegmentMetadata( @@ -147,13 +152,13 @@ export async function startRecording( currentSegment, sessionId, sessionStartTime, - config.recordingSegmentMs, + config.RECORDING_SEGMENT_MS, ); fs.writeFileSync( currentSegment.jsonFilename, JSON.stringify(metadata, null, 2), ); - if (config.verbose) { + if (config.VERBOSE) { logger.info( { jsonFile: currentSegment.jsonFilename }, "Metadata saved", @@ -162,10 +167,7 @@ export async function startRecording( }); currentSegment.out.on("error", (err) => { - logger.error( - { userId, error: err.message }, - "File write error", - ); + logger.error({ userId, error: err.message }, "File write error"); }); // Feed Opus packets one-by-one @@ -189,19 +191,13 @@ export async function startRecording( onError: (error) => { segmentManager.close(oggPacketStream); decoder.destroy(); - logger.error( - { userId, error: error.message }, - "Audio stream error", - ); + logger.error({ userId, error: error.message }, "Audio stream error"); }, }); packetFilterForOgg.on("error", (err) => { segmentManager.close(oggPacketStream); - logger.error( - { userId, error: err.message }, - "PacketFilter error", - ); + logger.error({ userId, error: err.message }, "PacketFilter error"); }); } catch (e) { logger.error( @@ -213,7 +209,7 @@ export async function startRecording( // Handle disconnect yang tidak disengaja connection.on(VoiceConnectionStatus.Disconnected, async () => { - if (config.verbose) { + if (config.VERBOSE) { logger.warn("Disconnected from voice channel. Reconnecting..."); } try { @@ -221,12 +217,12 @@ export async function startRecording( entersState( connection, VoiceConnectionStatus.Signalling, - config.reconnectTimeoutMs, + config.RECONNECT_TIMEOUT_MS, ), entersState( connection, VoiceConnectionStatus.Connecting, - config.reconnectTimeoutMs, + config.RECONNECT_TIMEOUT_MS, ), ]); // Berhasil reconnect @@ -237,7 +233,7 @@ export async function startRecording( }); connection.on(VoiceConnectionStatus.Destroyed, () => { - if (config.verbose) { + if (config.VERBOSE) { logger.info("Voice connection destroyed"); } }); @@ -250,7 +246,7 @@ export function stopRecording(guildId: string): void { const connection = getVoiceConnection(guildId); if (connection) { connection.destroy(); - if (config.verbose) { + if (config.VERBOSE) { logger.info("Recording stopped and disconnected"); } } else { diff --git a/src/recorder/audioStream.ts b/src/recorder/audioStream.ts index 2d3ef96..4bb6f81 100644 --- a/src/recorder/audioStream.ts +++ b/src/recorder/audioStream.ts @@ -15,7 +15,7 @@ export function subscribeToAudioStream( const audioStream = receiver.subscribe(userId, { end: { behavior: EndBehaviorType.AfterSilence, - duration: config.audioStreamSilenceDurationMs, + duration: config.AUDIO_STREAM_SILENCE_DURATION_MS, }, }); diff --git a/src/recorder/decoder.ts b/src/recorder/decoder.ts index dfdf58f..029bfe6 100644 --- a/src/recorder/decoder.ts +++ b/src/recorder/decoder.ts @@ -25,9 +25,14 @@ export class OpusDecoder { options.createDecoder ?? (() => new prism.opus.Decoder({ - frameSize: config.opusFrameSize, - channels: config.audioChannels as 1 | 2, - rate: config.audioSampleRate as 8000 | 12000 | 16000 | 24000 | 48000, + frameSize: config.OPUS_FRAME_SIZE, + channels: config.AUDIO_CHANNELS as 1 | 2, + rate: config.AUDIO_SAMPLE_RATE as + | 8000 + | 12000 + | 16000 + | 24000 + | 48000, })); } diff --git a/src/recorder/metadata.ts b/src/recorder/metadata.ts index 56c6d96..0d6e5a7 100644 --- a/src/recorder/metadata.ts +++ b/src/recorder/metadata.ts @@ -33,7 +33,7 @@ export async function collectUserMetadata( avatarUrl: user?.displayAvatarURL({ format: "png", - size: config.avatarSize as + size: config.AVATAR_SIZE as | 16 | 32 | 64 diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..3fb2bd6 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,38 @@ +import { plainToClass } from "class-transformer"; +import { IsBoolean, IsString, validate } from "class-validator"; + +export class UserStateUpdate { + @IsString() + userId!: string; + + @IsString() + username!: string; + + @IsString() + avatar!: string; + + @IsBoolean() + speaking!: boolean; +} + +export class AudioMessage { + data!: Buffer; + userId!: string; +} + +export async function validateUserStateUpdate( + data: unknown, +): Promise { + if (typeof data !== "object" || data === null) { + return null; + } + + const obj = plainToClass(UserStateUpdate, data); + const errors = await validate(obj); + + if (errors.length > 0) { + return null; + } + + return obj; +} diff --git a/src/webserver.ts b/src/webserver.ts index f28b1e5..d2dc86f 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -1,12 +1,14 @@ import express from "express"; +import helmet from "helmet"; import http from "http"; import path from "path"; +import pinoHttp from "pino-http"; import prism from "prism-media"; import { WebSocketServer } from "ws"; +import { createChildLogger, logger } from "./logger"; import { discordPlayer } from "./player"; -import { createChildLogger } from "./logger"; -const logger = createChildLogger("webserver"); +const wsLogger = createChildLogger("webserver"); const activeUsers = new Map< string, @@ -45,10 +47,27 @@ export function startWebserver(port: number = 3000) { const wsPort = port + 1; const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); - logger.info({ wsPort }, "WebSocket server listening"); + wsLogger.info({ wsPort }, "WebSocket server listening"); + + // Security headers + app.use(helmet()); + + // HTTP request logging + app.use(pinoHttp({ logger })); app.use(express.static(path.join(__dirname, "../public"))); + // Health check endpoint + app.get("/health", (_req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + activeUsers: activeUsers.size, + wsClients: wsClients.size, + }); + }); + // Inbound: Discord PCM → tagged chunks → browser (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { let hash = 0; @@ -125,10 +144,7 @@ export function startWebserver(port: number = 3000) { setInterval(() => { if (dbCount > 0) { const avg = dbAccum / dbCount; - logger.info( - { level: avg.toFixed(1), frames: dbCount }, - "Audio level", - ); + wsLogger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level"); dbAccum = 0; dbCount = 0; } @@ -152,7 +168,7 @@ export function startWebserver(port: number = 3000) { if (playerPaused) { discordPlayer.unpause(); playerPaused = false; - logger.info("Transmitting — Discord indicator ON"); + wsLogger.info("Transmitting — Discord indicator ON"); } } else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) { // Buffer drained but audio was recent — pad silence to avoid OGG gap @@ -161,7 +177,7 @@ export function startWebserver(port: number = 3000) { // No audio for a while — pause Discord indicator discordPlayer.pause(); playerPaused = true; - logger.info("Stopped — Discord indicator OFF"); + wsLogger.info("Stopped — Discord indicator OFF"); return; } else { return; // already paused, nothing to do @@ -175,7 +191,7 @@ export function startWebserver(port: number = 3000) { }, 20); wss.on("connection", (ws) => { - logger.info({ wsPort }, "New WebSocket connection"); + wsLogger.info({ wsPort }, "New WebSocket connection"); wsClients.add(ws); ws.send( @@ -210,6 +226,6 @@ export function startWebserver(port: number = 3000) { }); server.listen(port, "0.0.0.0", () => { - logger.info({ port }, "Web interface listening"); + wsLogger.info({ port }, "Web interface listening"); }); } diff --git a/tsconfig.json b/tsconfig.json index 5967026..7aa50e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,9 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]