feat: implement logging and retry mechanism with pino and p-retry

This commit is contained in:
MythEclipse
2026-05-13 16:25:01 +07:00
parent 9497e721e0
commit 3ae28157a3
7 changed files with 144 additions and 65 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -15,18 +15,16 @@
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"crc-32": "^1.2.2",
"discord.js-selfbot-v13": "^3.7.1", "discord.js-selfbot-v13": "^3.7.1",
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"node-crc": "^4.0.0",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"sodium-native": "^4.3.2", "sodium-native": "^4.3.2",
"ws": "^8.20.1" "ws": "^8.20.1",
"pino": "^9.4.0",
"p-retry": "^6.2.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",

View File

@@ -7,6 +7,10 @@ import { config } from "./config";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { startRecording } from "./recorder"; import { startRecording } from "./recorder";
import { startWebserver } from "./webserver"; import { startWebserver } from "./webserver";
import { createChildLogger } from "./logger";
import { retryWithBackoff } from "./retry";
const logger = createChildLogger("bot");
// Validasi environment variables // Validasi environment variables
const token = process.env.DISCORD_TOKEN; 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 (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env");
if (!guildId) throw new Error("Missing GUILD_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(); const client = new Client();
client.on("ready", async () => { client.on("ready", async () => {
if (config.verbose) { if (config.verbose) {
console.log(`[bot] Logged in as ${client.user!.tag}`); logger.info({ user: client.user?.tag }, "Bot logged in");
} }
// Ambil guild // Ambil guild
const guild = client.guilds.cache.get(guildId!); const guild = client.guilds.cache.get(guildId!);
if (!guild) { if (!guild) {
console.error(`[bot] Guild not found: ${guildId}`); logger.error({ guildId }, "Guild not found");
process.exit(1); process.exit(1);
} }
@@ -38,24 +42,24 @@ client.on("ready", async () => {
(await guild.channels.fetch(voiceChannelId!).catch(() => null)); (await guild.channels.fetch(voiceChannelId!).catch(() => null));
if (!channel || channel.type !== "GUILD_VOICE") { if (!channel || channel.type !== "GUILD_VOICE") {
console.error( logger.error({ voiceChannelId }, "Voice channel not found or wrong type");
`[bot] Voice channel not found or wrong type: ${voiceChannelId}`,
);
process.exit(1); process.exit(1);
} }
if (config.verbose) { if (config.verbose) {
console.log( logger.info(
`[bot] Joining voice channel: #${channel.name} (${channel.id})`, { channelName: channel.name, channelId: channel.id },
"Joining voice channel",
); );
} }
await startRecording(client, channel as any); await startRecording(client, channel as any);
// Set up player connection // Set up player connection
const connection = getVoiceConnection(guildId!); const connection = getVoiceConnection(guildId!);
if (connection) { if (connection) {
discordPlayer.setConnection(connection); discordPlayer.setConnection(connection);
console.log("[bot] Player connected to voice channel"); logger.info("Player connected to voice channel");
} }
// Start Webserver // Start Webserver
@@ -63,13 +67,13 @@ client.on("ready", async () => {
}); });
client.on("error", (err) => { client.on("error", (err) => {
console.error("[bot] Client error:", err); logger.error({ error: err }, "Client error");
}); });
// Graceful shutdown // Graceful shutdown
process.on("SIGINT", () => { process.on("SIGINT", () => {
if (config.verbose) { if (config.verbose) {
console.log("\n[bot] Shutting down..."); logger.info("Shutting down gracefully...");
} }
client.destroy(); client.destroy();
process.exit(0); process.exit(0);
@@ -77,7 +81,7 @@ process.on("SIGINT", () => {
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
if (config.verbose) { if (config.verbose) {
console.log("[bot] Terminating..."); logger.info("Terminating...");
} }
client.destroy(); client.destroy();
process.exit(0); process.exit(0);

22
src/logger.ts Normal file
View File

@@ -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 });
};

View File

@@ -8,7 +8,6 @@ import {
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";
import prism from "prism-media";
import { config } from "./config"; import { config } from "./config";
import { PacketFilter } from "./packetFilter"; import { PacketFilter } from "./packetFilter";
import { subscribeToAudioStream } from "./recorder/audioStream"; import { subscribeToAudioStream } from "./recorder/audioStream";
@@ -19,6 +18,10 @@ import {
} from "./recorder/metadata"; } from "./recorder/metadata";
import { SegmentManager } from "./recorder/segment"; import { SegmentManager } from "./recorder/segment";
import type { PcmBroadcaster } from "./types"; import type { PcmBroadcaster } from "./types";
import { createChildLogger } from "./logger";
import { retryWithBackoff } from "./retry";
const logger = createChildLogger("recorder");
const recordingsDir = config.recordingsDir; const recordingsDir = config.recordingsDir;
@@ -43,32 +46,37 @@ export async function startRecording(
debug: true, debug: true,
}); });
if (config.verbose) { logger.info({ channelName: channel.name }, "Joining voice channel");
console.log(`[recorder] Joining voice channel: #${channel.name}`);
}
connection.on("debug", (msg) => { connection.on("debug", (msg) => {
if (config.verbose) { if (config.verbose) {
console.log(`[voice-debug] ${msg}`); logger.debug({ message: msg }, "Voice debug");
} }
}); });
connection.on("error", (err) => { 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 { try {
await entersState( await retryWithBackoff(
connection, () =>
VoiceConnectionStatus.Ready, entersState(
config.voiceConnectionTimeoutMs, connection,
VoiceConnectionStatus.Ready,
config.voiceConnectionTimeoutMs,
),
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
logger,
},
); );
if (config.verbose) { logger.info("Connected to voice channel. Recording started");
console.log("[recorder] Connected to voice channel. Recording started.");
}
} catch (err) { } catch (err) {
console.error("[recorder] Failed to connect:", err); logger.error({ error: err }, "Failed to connect to voice channel");
connection.destroy(); connection.destroy();
return; return;
} }
@@ -79,7 +87,7 @@ export async function startRecording(
// Dengarkan siapapun yang mulai bicara // Dengarkan siapapun yang mulai bicara
receiver.speaking.on("start", async (userId) => { receiver.speaking.on("start", async (userId) => {
const userMetadata = await collectUserMetadata(client, userId, channel); const userMetadata = await collectUserMetadata(client, userId, channel);
console.log(`${userMetadata.username} [voice activity]`); logger.info({ userId, username: userMetadata.username }, "Voice activity detected");
// Notify webserver // Notify webserver
broadcaster.updateActiveUser?.(userId, { broadcaster.updateActiveUser?.(userId, {
@@ -132,7 +140,7 @@ export async function startRecording(
let currentSegment = segmentManager.open(oggPacketStream); let currentSegment = segmentManager.open(oggPacketStream);
currentSegment.out.on("finish", () => { currentSegment.out.on("finish", () => {
if (config.verbose) { if (config.verbose) {
console.log(`[recorder] Saved: ${currentSegment.filename}`); logger.info({ filename: currentSegment.filename }, "Segment saved");
} }
const metadata = createSegmentMetadata( const metadata = createSegmentMetadata(
userMetadata, userMetadata,
@@ -146,14 +154,18 @@ export async function startRecording(
JSON.stringify(metadata, null, 2), JSON.stringify(metadata, null, 2),
); );
if (config.verbose) { if (config.verbose) {
console.log( logger.info(
`[recorder] Saved metadata: ${currentSegment.jsonFilename}`, { jsonFile: currentSegment.jsonFilename },
"Metadata saved",
); );
} }
}); });
currentSegment.out.on("error", (err) => { 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 // Feed Opus packets one-by-one
@@ -166,7 +178,7 @@ export async function startRecording(
decoder.write(chunk); decoder.write(chunk);
}, },
onEnd: () => { onEnd: () => {
const segment = segmentManager.close(oggPacketStream); segmentManager.close(oggPacketStream);
decoder.destroy(); decoder.destroy();
broadcaster.updateActiveUser?.(userId, { broadcaster.updateActiveUser?.(userId, {
username: userMetadata.username, username: userMetadata.username,
@@ -177,31 +189,32 @@ export async function startRecording(
onError: (error) => { onError: (error) => {
segmentManager.close(oggPacketStream); segmentManager.close(oggPacketStream);
decoder.destroy(); decoder.destroy();
console.error( logger.error(
`[recorder] Audio Stream error ${userId}:`, { userId, error: error.message },
error.message, "Audio stream error",
); );
}, },
}); });
packetFilterForOgg.on("error", (err) => { packetFilterForOgg.on("error", (err) => {
segmentManager.close(oggPacketStream); segmentManager.close(oggPacketStream);
console.error( logger.error(
`[recorder] PacketFilter(ogg) error ${userId}:`, { userId, error: err.message },
err.message, "PacketFilter error",
); );
}); });
} catch (e) { } 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 // Handle disconnect yang tidak disengaja
connection.on(VoiceConnectionStatus.Disconnected, async () => { connection.on(VoiceConnectionStatus.Disconnected, async () => {
if (config.verbose) { if (config.verbose) {
console.warn( logger.warn("Disconnected from voice channel. Reconnecting...");
"[recorder] Disconnected from voice channel. Reconnecting...",
);
} }
try { try {
await Promise.race([ await Promise.race([
@@ -218,14 +231,14 @@ export async function startRecording(
]); ]);
// Berhasil reconnect // Berhasil reconnect
} catch { } catch {
console.error("[recorder] Could not reconnect. Destroying connection."); logger.error("Could not reconnect. Destroying connection");
connection.destroy(); connection.destroy();
} }
}); });
connection.on(VoiceConnectionStatus.Destroyed, () => { connection.on(VoiceConnectionStatus.Destroyed, () => {
if (config.verbose) { 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) { if (connection) {
connection.destroy(); connection.destroy();
if (config.verbose) { if (config.verbose) {
console.log("[recorder] Recording stopped and disconnected."); logger.info("Recording stopped and disconnected");
} }
} else { } else {
console.warn("[recorder] No active connection to stop."); logger.warn("No active connection to stop");
} }
} }

42
src/retry.ts Normal file
View File

@@ -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<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
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",
);
}
},
});
}

View File

@@ -4,6 +4,9 @@ import path from "path";
import prism from "prism-media"; import prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { createChildLogger } from "./logger";
const logger = createChildLogger("webserver");
const activeUsers = new Map< const activeUsers = new Map<
string, string,
@@ -42,9 +45,7 @@ export function startWebserver(port: number = 3000) {
const wsPort = port + 1; const wsPort = port + 1;
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
console.log( logger.info({ wsPort }, "WebSocket server listening");
`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`,
);
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
@@ -124,8 +125,9 @@ export function startWebserver(port: number = 3000) {
setInterval(() => { setInterval(() => {
if (dbCount > 0) { if (dbCount > 0) {
const avg = dbAccum / dbCount; const avg = dbAccum / dbCount;
console.log( logger.info(
`[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`, { level: avg.toFixed(1), frames: dbCount },
"Audio level",
); );
dbAccum = 0; dbAccum = 0;
dbCount = 0; dbCount = 0;
@@ -140,8 +142,8 @@ export function startWebserver(port: number = 3000) {
if (pcmBuffer.length >= BYTES_PER_FRAME) { if (pcmBuffer.length >= BYTES_PER_FRAME) {
// Real audio available // Real audio available
frame = pcmBuffer.slice(0, BYTES_PER_FRAME); frame = pcmBuffer.subarray(0, BYTES_PER_FRAME);
pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME); pcmBuffer = pcmBuffer.subarray(BYTES_PER_FRAME);
// Track level for logging // Track level for logging
dbAccum += rmsDb(frame); dbAccum += rmsDb(frame);
@@ -150,7 +152,7 @@ export function startWebserver(port: number = 3000) {
if (playerPaused) { if (playerPaused) {
discordPlayer.unpause(); discordPlayer.unpause();
playerPaused = false; playerPaused = false;
console.log("[transmit] Transmitting — Discord indicator ON"); logger.info("Transmitting — Discord indicator ON");
} }
} else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) { } else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) {
// Buffer drained but audio was recent — pad silence to avoid OGG gap // 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 // No audio for a while — pause Discord indicator
discordPlayer.pause(); discordPlayer.pause();
playerPaused = true; playerPaused = true;
console.log("[transmit] Stopped — Discord indicator OFF"); logger.info("Stopped — Discord indicator OFF");
return; return;
} else { } else {
return; // already paused, nothing to do return; // already paused, nothing to do
@@ -173,7 +175,7 @@ export function startWebserver(port: number = 3000) {
}, 20); }, 20);
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
console.log("[webserver] New WebSocket connection on port " + wsPort); logger.info({ wsPort }, "New WebSocket connection");
wsClients.add(ws); wsClients.add(ws);
ws.send( ws.send(
@@ -208,8 +210,6 @@ export function startWebserver(port: number = 3000) {
}); });
server.listen(port, "0.0.0.0", () => { server.listen(port, "0.0.0.0", () => {
console.log( logger.info({ port }, "Web interface listening");
`[webserver] Web interface listening on http://0.0.0.0:${port}`,
);
}); });
} }