From 978c2c468db288f83feb5b4e3c68cc42dad20c26 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 16:57:07 +0700 Subject: [PATCH] feat: enhance configuration management and add error handling --- .env.example | 5 ++ bun.lockb | Bin 143236 -> 148440 bytes package.json | 10 +++- src/config.ts | 93 +++++++++++++++--------------------- src/errors.ts | 42 ++++++++++++++++ src/index.ts | 21 ++++---- src/middleware.ts | 36 ++++++++++++++ src/recorder.ts | 56 ++++++++++------------ src/recorder/audioStream.ts | 2 +- src/recorder/decoder.ts | 11 +++-- src/recorder/metadata.ts | 2 +- src/validation.ts | 38 +++++++++++++++ src/webserver.ts | 38 ++++++++++----- tsconfig.json | 4 +- 14 files changed, 243 insertions(+), 115 deletions(-) create mode 100644 src/errors.ts create mode 100644 src/middleware.ts create mode 100644 src/validation.ts 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 b547594a81e42a7fbb693839fd88e088a25faaa0..0b3702247da4e1479517d7dc2e836092073d827e 100755 GIT binary patch delta 28716 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 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"]