diff --git a/bun.lockb b/bun.lockb index 3cca575..784d3c2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 75d5681..01a59e0 100644 --- a/package.json +++ b/package.json @@ -15,18 +15,16 @@ "dependencies": { "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.1", - "@ffmpeg-installer/ffmpeg": "^1.1.0", "@snazzah/davey": "^0.1.10", - "crc-32": "^1.2.2", "discord.js-selfbot-v13": "^3.7.1", "express": "^5.2.1", - "ffmpeg-static": "^5.3.0", "fluent-ffmpeg": "^2.1.3", "libsodium-wrappers": "^0.8.2", - "node-crc": "^4.0.0", "prism-media": "2.0.0-alpha.0", "sodium-native": "^4.3.2", - "ws": "^8.20.1" + "ws": "^8.20.1", + "pino": "^9.4.0", + "p-retry": "^6.2.0" }, "devDependencies": { "@biomejs/biome": "latest", diff --git a/src/index.ts b/src/index.ts index de79fa7..d5a0ec8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,10 @@ import { config } from "./config"; import { discordPlayer } from "./player"; import { startRecording } from "./recorder"; import { startWebserver } from "./webserver"; +import { createChildLogger } from "./logger"; +import { retryWithBackoff } from "./retry"; + +const logger = createChildLogger("bot"); // Validasi environment variables const token = process.env.DISCORD_TOKEN; @@ -17,18 +21,18 @@ 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"); -// Inisialisasi selfbot client (gunakan checkUpdate: false supaya tidak ada prompt update) +// Inisialisasi selfbot client const client = new Client(); client.on("ready", async () => { if (config.verbose) { - console.log(`[bot] Logged in as ${client.user!.tag}`); + logger.info({ user: client.user?.tag }, "Bot logged in"); } // Ambil guild const guild = client.guilds.cache.get(guildId!); if (!guild) { - console.error(`[bot] Guild not found: ${guildId}`); + logger.error({ guildId }, "Guild not found"); process.exit(1); } @@ -38,24 +42,24 @@ client.on("ready", async () => { (await guild.channels.fetch(voiceChannelId!).catch(() => null)); if (!channel || channel.type !== "GUILD_VOICE") { - console.error( - `[bot] Voice channel not found or wrong type: ${voiceChannelId}`, - ); + logger.error({ voiceChannelId }, "Voice channel not found or wrong type"); process.exit(1); } if (config.verbose) { - console.log( - `[bot] Joining voice channel: #${channel.name} (${channel.id})`, + 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); - console.log("[bot] Player connected to voice channel"); + logger.info("Player connected to voice channel"); } // Start Webserver @@ -63,13 +67,13 @@ client.on("ready", async () => { }); client.on("error", (err) => { - console.error("[bot] Client error:", err); + logger.error({ error: err }, "Client error"); }); // Graceful shutdown process.on("SIGINT", () => { if (config.verbose) { - console.log("\n[bot] Shutting down..."); + logger.info("Shutting down gracefully..."); } client.destroy(); process.exit(0); @@ -77,7 +81,7 @@ process.on("SIGINT", () => { process.on("SIGTERM", () => { if (config.verbose) { - console.log("[bot] Terminating..."); + logger.info("Terminating..."); } client.destroy(); process.exit(0); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..ea65876 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,22 @@ +import pino from "pino"; + +const isDev = process.env.NODE_ENV !== "production"; + +export const logger = pino({ + level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"), + transport: isDev + ? { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }, + } + : undefined, + timestamp: pino.stdTimeFunctions.isoTime, +}); + +export const createChildLogger = (context: string) => { + return logger.child({ context }); +}; diff --git a/src/recorder.ts b/src/recorder.ts index 8d064e7..a208991 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -8,7 +8,6 @@ import { VoiceConnectionStatus, } from "@discordjs/voice"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; -import prism from "prism-media"; import { config } from "./config"; import { PacketFilter } from "./packetFilter"; import { subscribeToAudioStream } from "./recorder/audioStream"; @@ -19,6 +18,10 @@ import { } from "./recorder/metadata"; import { SegmentManager } from "./recorder/segment"; import type { PcmBroadcaster } from "./types"; +import { createChildLogger } from "./logger"; +import { retryWithBackoff } from "./retry"; + +const logger = createChildLogger("recorder"); const recordingsDir = config.recordingsDir; @@ -43,32 +46,37 @@ export async function startRecording( debug: true, }); - if (config.verbose) { - console.log(`[recorder] Joining voice channel: #${channel.name}`); - } + logger.info({ channelName: channel.name }, "Joining voice channel"); connection.on("debug", (msg) => { if (config.verbose) { - console.log(`[voice-debug] ${msg}`); + logger.debug({ message: msg }, "Voice debug"); } }); connection.on("error", (err) => { - console.error(`[voice-error]`, err); + logger.error({ error: err }, "Voice connection error"); }); - // Tunggu sampai benar-benar terhubung + // Tunggu sampai benar-benar terhubung dengan retry logic try { - await entersState( - connection, - VoiceConnectionStatus.Ready, - config.voiceConnectionTimeoutMs, + await retryWithBackoff( + () => + entersState( + connection, + VoiceConnectionStatus.Ready, + config.voiceConnectionTimeoutMs, + ), + { + retries: 3, + minTimeout: 1000, + maxTimeout: 5000, + logger, + }, ); - if (config.verbose) { - console.log("[recorder] Connected to voice channel. Recording started."); - } + logger.info("Connected to voice channel. Recording started"); } catch (err) { - console.error("[recorder] Failed to connect:", err); + logger.error({ error: err }, "Failed to connect to voice channel"); connection.destroy(); return; } @@ -79,7 +87,7 @@ export async function startRecording( // Dengarkan siapapun yang mulai bicara receiver.speaking.on("start", async (userId) => { const userMetadata = await collectUserMetadata(client, userId, channel); - console.log(`${userMetadata.username} [voice activity]`); + logger.info({ userId, username: userMetadata.username }, "Voice activity detected"); // Notify webserver broadcaster.updateActiveUser?.(userId, { @@ -132,7 +140,7 @@ export async function startRecording( let currentSegment = segmentManager.open(oggPacketStream); currentSegment.out.on("finish", () => { if (config.verbose) { - console.log(`[recorder] Saved: ${currentSegment.filename}`); + logger.info({ filename: currentSegment.filename }, "Segment saved"); } const metadata = createSegmentMetadata( userMetadata, @@ -146,14 +154,18 @@ export async function startRecording( JSON.stringify(metadata, null, 2), ); if (config.verbose) { - console.log( - `[recorder] Saved metadata: ${currentSegment.jsonFilename}`, + logger.info( + { jsonFile: currentSegment.jsonFilename }, + "Metadata saved", ); } }); currentSegment.out.on("error", (err) => { - console.error(`[recorder] File write error ${userId}:`, err.message); + logger.error( + { userId, error: err.message }, + "File write error", + ); }); // Feed Opus packets one-by-one @@ -166,7 +178,7 @@ export async function startRecording( decoder.write(chunk); }, onEnd: () => { - const segment = segmentManager.close(oggPacketStream); + segmentManager.close(oggPacketStream); decoder.destroy(); broadcaster.updateActiveUser?.(userId, { username: userMetadata.username, @@ -177,31 +189,32 @@ export async function startRecording( onError: (error) => { segmentManager.close(oggPacketStream); decoder.destroy(); - console.error( - `[recorder] Audio Stream error ${userId}:`, - error.message, + logger.error( + { userId, error: error.message }, + "Audio stream error", ); }, }); packetFilterForOgg.on("error", (err) => { segmentManager.close(oggPacketStream); - console.error( - `[recorder] PacketFilter(ogg) error ${userId}:`, - err.message, + logger.error( + { userId, error: err.message }, + "PacketFilter error", ); }); } catch (e) { - console.error(`[recorder] Failed to create stream for ${userId}:`, e); + logger.error( + { userId, error: e instanceof Error ? e.message : String(e) }, + "Failed to create stream", + ); } }); // Handle disconnect yang tidak disengaja connection.on(VoiceConnectionStatus.Disconnected, async () => { if (config.verbose) { - console.warn( - "[recorder] Disconnected from voice channel. Reconnecting...", - ); + logger.warn("Disconnected from voice channel. Reconnecting..."); } try { await Promise.race([ @@ -218,14 +231,14 @@ export async function startRecording( ]); // Berhasil reconnect } catch { - console.error("[recorder] Could not reconnect. Destroying connection."); + logger.error("Could not reconnect. Destroying connection"); connection.destroy(); } }); connection.on(VoiceConnectionStatus.Destroyed, () => { if (config.verbose) { - console.log("[recorder] Voice connection destroyed."); + logger.info("Voice connection destroyed"); } }); } @@ -238,9 +251,9 @@ export function stopRecording(guildId: string): void { if (connection) { connection.destroy(); if (config.verbose) { - console.log("[recorder] Recording stopped and disconnected."); + logger.info("Recording stopped and disconnected"); } } else { - console.warn("[recorder] No active connection to stop."); + logger.warn("No active connection to stop"); } } diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..4934b67 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,42 @@ +import pRetry from "p-retry"; +import type { Logger } from "pino"; + +export interface RetryOptions { + retries?: number; + minTimeout?: number; + maxTimeout?: number; + factor?: number; + logger?: Logger; +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + retries = 3, + minTimeout = 1000, + maxTimeout = 30000, + factor = 2, + logger, + } = options; + + return pRetry(fn, { + retries, + minTimeout, + maxTimeout, + factor, + onFailedAttempt: (error) => { + if (logger) { + logger.warn( + { + attempt: error.attemptNumber, + retriesLeft: error.retriesLeft, + error: error.message, + }, + "Retry attempt", + ); + } + }, + }); +} diff --git a/src/webserver.ts b/src/webserver.ts index 558fc40..f28b1e5 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -4,6 +4,9 @@ import path from "path"; import prism from "prism-media"; import { WebSocketServer } from "ws"; import { discordPlayer } from "./player"; +import { createChildLogger } from "./logger"; + +const logger = createChildLogger("webserver"); const activeUsers = new Map< string, @@ -42,9 +45,7 @@ export function startWebserver(port: number = 3000) { const wsPort = port + 1; const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); - console.log( - `[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`, - ); + logger.info({ wsPort }, "WebSocket server listening"); app.use(express.static(path.join(__dirname, "../public"))); @@ -124,8 +125,9 @@ export function startWebserver(port: number = 3000) { setInterval(() => { if (dbCount > 0) { const avg = dbAccum / dbCount; - console.log( - `[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`, + logger.info( + { level: avg.toFixed(1), frames: dbCount }, + "Audio level", ); dbAccum = 0; dbCount = 0; @@ -140,8 +142,8 @@ export function startWebserver(port: number = 3000) { if (pcmBuffer.length >= BYTES_PER_FRAME) { // Real audio available - frame = pcmBuffer.slice(0, BYTES_PER_FRAME); - pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME); + frame = pcmBuffer.subarray(0, BYTES_PER_FRAME); + pcmBuffer = pcmBuffer.subarray(BYTES_PER_FRAME); // Track level for logging dbAccum += rmsDb(frame); @@ -150,7 +152,7 @@ export function startWebserver(port: number = 3000) { if (playerPaused) { discordPlayer.unpause(); playerPaused = false; - console.log("[transmit] Transmitting — Discord indicator ON"); + logger.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 @@ -159,7 +161,7 @@ export function startWebserver(port: number = 3000) { // No audio for a while — pause Discord indicator discordPlayer.pause(); playerPaused = true; - console.log("[transmit] Stopped — Discord indicator OFF"); + logger.info("Stopped — Discord indicator OFF"); return; } else { return; // already paused, nothing to do @@ -173,7 +175,7 @@ export function startWebserver(port: number = 3000) { }, 20); wss.on("connection", (ws) => { - console.log("[webserver] New WebSocket connection on port " + wsPort); + logger.info({ wsPort }, "New WebSocket connection"); wsClients.add(ws); ws.send( @@ -208,8 +210,6 @@ export function startWebserver(port: number = 3000) { }); server.listen(port, "0.0.0.0", () => { - console.log( - `[webserver] Web interface listening on http://0.0.0.0:${port}`, - ); + logger.info({ port }, "Web interface listening"); }); }