Compare commits
10 Commits
dbe593c6fd
...
f9a4b4a92d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9a4b4a92d | ||
|
|
978c2c468d | ||
|
|
673a06376c | ||
|
|
3ae28157a3 | ||
|
|
9497e721e0 | ||
|
|
7a5ac2e34a | ||
|
|
48cc83f624 | ||
|
|
b4302585c8 | ||
|
|
80081a6f62 | ||
|
|
f655daa0c7 |
35
.env.example
Normal file
35
.env.example
Normal file
@@ -0,0 +1,35 @@
|
||||
# Discord Bot Configuration
|
||||
DISCORD_TOKEN=your_bot_token_here
|
||||
VOICE_CHANNEL_ID=your_voice_channel_id_here
|
||||
GUILD_ID=your_guild_id_here
|
||||
|
||||
# Recording Configuration
|
||||
RECORDINGS_DIR=./recordings
|
||||
RECORDING_SEGMENT_MS=5000
|
||||
VERBOSE=false
|
||||
|
||||
# Decoder Configuration
|
||||
DECODER_ROTATE_MS=5000
|
||||
DECODER_COOLDOWN_MS=30000
|
||||
|
||||
# Audio Configuration
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000
|
||||
PACKET_FILTER_MIN_SIZE=8
|
||||
OPUS_FRAME_SIZE=960
|
||||
AUDIO_SAMPLE_RATE=48000
|
||||
AUDIO_CHANNELS=2
|
||||
AVATAR_SIZE=64
|
||||
|
||||
# Webserver Configuration
|
||||
WEBSERVER_PORT=3000
|
||||
|
||||
# Connection Configuration
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||
RECONNECT_TIMEOUT_MS=5000
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
node_modules
|
||||
recordings
|
||||
.env
|
||||
|
||||
dist/
|
||||
|
||||
1532
package-lock.json
generated
1532
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"build": "tsc --outDir dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"format": "biome format --write .",
|
||||
@@ -14,25 +15,33 @@
|
||||
"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",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"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",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"helmet": "^8.1.0",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"node-crc": "^4.0.0",
|
||||
"p-retry": "^6.2.0",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"prism-media": "2.0.0-alpha.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"sodium-native": "^4.3.2",
|
||||
"ws": "^8.20.1"
|
||||
"ws": "^8.20.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/ws": "^8.18.1",
|
||||
"pino-pretty": "^10.3.1",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
// Configuration for the bot
|
||||
export interface AppConfig {
|
||||
verbose: boolean;
|
||||
recordingsDir: string;
|
||||
recordingSegmentMs: number;
|
||||
decoderRotateMs: number;
|
||||
decoderCooldownMs: 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<typeof configSchema>;
|
||||
|
||||
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: 30_000,
|
||||
};
|
||||
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();
|
||||
|
||||
42
src/errors.ts
Normal file
42
src/errors.ts
Normal file
@@ -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<string, string[]>,
|
||||
) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
119
src/index.ts
119
src/index.ts
@@ -1,34 +1,85 @@
|
||||
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 } from "./recorder";
|
||||
import { startRecording, stopRecording } from "./recorder";
|
||||
import { startWebserver } from "./webserver";
|
||||
|
||||
// Validasi environment variables
|
||||
const token = process.env.DISCORD_TOKEN;
|
||||
const voiceChannelId = process.env.VOICE_CHANNEL_ID;
|
||||
const guildId = process.env.GUILD_ID;
|
||||
const logger = createChildLogger("bot");
|
||||
|
||||
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 (gunakan checkUpdate: false supaya tidak ada prompt update)
|
||||
// Inisialisasi selfbot client
|
||||
const client = new Client();
|
||||
|
||||
// Track shutdown state
|
||||
let isShuttingDown = false;
|
||||
|
||||
async function gracefulShutdown(signal: string) {
|
||||
if (isShuttingDown) {
|
||||
logger.warn(`Already shutting down, ignoring ${signal}`);
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
logger.info({ signal }, "Graceful shutdown initiated");
|
||||
|
||||
try {
|
||||
// Step 1: Stop recording
|
||||
if (guildId) {
|
||||
logger.info("Stopping recording...");
|
||||
stopRecording(guildId);
|
||||
}
|
||||
|
||||
// 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
|
||||
logger.info("Destroying Discord client...");
|
||||
try {
|
||||
client.destroy();
|
||||
} catch (err) {
|
||||
logger.warn({ error: err }, "Error destroying client");
|
||||
}
|
||||
|
||||
logger.info("Graceful shutdown completed");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Error during graceful shutdown");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
client.on("ready", async () => {
|
||||
if (config.verbose) {
|
||||
console.log(`[bot] Logged in as ${client.user!.tag}`);
|
||||
if (config.VERBOSE) {
|
||||
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,49 +89,53 @@ 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})`,
|
||||
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);
|
||||
console.log("[bot] Player connected to voice channel");
|
||||
logger.info("Player connected to voice channel");
|
||||
}
|
||||
|
||||
// Start Webserver
|
||||
startWebserver(3000);
|
||||
startWebserver(config.WEBSERVER_PORT);
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
console.error("[bot] Client error:", err);
|
||||
logger.error({ error: err }, "Client error");
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
// Graceful shutdown handlers
|
||||
process.on("SIGINT", () => {
|
||||
if (config.verbose) {
|
||||
console.log("\n[bot] Shutting down...");
|
||||
}
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
gracefulShutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
if (config.verbose) {
|
||||
console.log("[bot] Terminating...");
|
||||
}
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
gracefulShutdown("SIGTERM");
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.error({ error: err }, "Uncaught exception");
|
||||
gracefulShutdown("uncaughtException");
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
logger.error({ reason, promise }, "Unhandled rejection");
|
||||
gracefulShutdown("unhandledRejection");
|
||||
});
|
||||
|
||||
client.login(token);
|
||||
|
||||
22
src/logger.ts
Normal file
22
src/logger.ts
Normal 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 });
|
||||
};
|
||||
78
src/metrics.ts
Normal file
78
src/metrics.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Counter, Gauge, Histogram, register } from "prom-client";
|
||||
|
||||
// Audio metrics
|
||||
export const audioLevelGauge = new Gauge({
|
||||
name: "audio_level_db",
|
||||
help: "Current audio level in dB",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
export const recordingDurationCounter = new Counter({
|
||||
name: "recording_duration_seconds_total",
|
||||
help: "Total recording duration in seconds",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
export const activeRecordingsGauge = new Gauge({
|
||||
name: "active_recordings",
|
||||
help: "Number of active recordings",
|
||||
});
|
||||
|
||||
export const recordedSegmentsCounter = new Counter({
|
||||
name: "recorded_segments_total",
|
||||
help: "Total number of recorded segments",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
// Connection metrics
|
||||
export const voiceConnectionsGauge = new Gauge({
|
||||
name: "voice_connections_active",
|
||||
help: "Number of active voice connections",
|
||||
});
|
||||
|
||||
export const connectionErrorsCounter = new Counter({
|
||||
name: "connection_errors_total",
|
||||
help: "Total number of connection errors",
|
||||
labelNames: ["error_type"],
|
||||
});
|
||||
|
||||
export const reconnectAttemptsCounter = new Counter({
|
||||
name: "reconnect_attempts_total",
|
||||
help: "Total number of reconnection attempts",
|
||||
});
|
||||
|
||||
// WebSocket metrics
|
||||
export const wsClientsGauge = new Gauge({
|
||||
name: "websocket_clients_connected",
|
||||
help: "Number of connected WebSocket clients",
|
||||
});
|
||||
|
||||
export const wsMessagesCounter = new Counter({
|
||||
name: "websocket_messages_total",
|
||||
help: "Total WebSocket messages sent",
|
||||
labelNames: ["message_type"],
|
||||
});
|
||||
|
||||
// HTTP metrics
|
||||
export const httpRequestDurationHistogram = new Histogram({
|
||||
name: "http_request_duration_seconds",
|
||||
help: "HTTP request duration in seconds",
|
||||
labelNames: ["method", "route", "status"],
|
||||
buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 5],
|
||||
});
|
||||
|
||||
export const httpRequestsCounter = new Counter({
|
||||
name: "http_requests_total",
|
||||
help: "Total HTTP requests",
|
||||
labelNames: ["method", "route", "status"],
|
||||
});
|
||||
|
||||
// System metrics
|
||||
export const uptimeGauge = new Gauge({
|
||||
name: "process_uptime_seconds",
|
||||
help: "Process uptime in seconds",
|
||||
});
|
||||
|
||||
export async function getMetrics(): Promise<string> {
|
||||
return register.metrics();
|
||||
}
|
||||
36
src/middleware.ts
Normal file
36
src/middleware.ts
Normal file
@@ -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",
|
||||
});
|
||||
}
|
||||
230
src/muxer-queue.ts
Normal file
230
src/muxer-queue.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { createChildLogger } from "./logger";
|
||||
|
||||
const logger = createChildLogger("muxer-queue");
|
||||
|
||||
export interface MuxerJobData {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
recordingsDir: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
interface StoredJob {
|
||||
id: string;
|
||||
data: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const dbPath = path.join(process.cwd(), ".muxer-queue.db");
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
function initializeDatabase(): Database.Database {
|
||||
const database = new Database(dbPath);
|
||||
database.pragma("journal_mode = WAL");
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS muxer_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
maxAttempts INTEGER NOT NULL DEFAULT 3,
|
||||
createdAt INTEGER NOT NULL,
|
||||
updatedAt INTEGER NOT NULL,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_status ON muxer_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_createdAt ON muxer_jobs(createdAt);
|
||||
`);
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function getDatabase(): Database.Database {
|
||||
if (!db) {
|
||||
db = initializeDatabase();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
||||
try {
|
||||
const database = getDatabase();
|
||||
const jobId = `${data.userId}-${data.sessionId}`;
|
||||
const now = Date.now();
|
||||
|
||||
const stmt = database.prepare(`
|
||||
INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(jobId, JSON.stringify(data), "pending", 0, 3, now, now);
|
||||
|
||||
logger.info(
|
||||
{ jobId, userId: data.userId, sessionId: data.sessionId },
|
||||
"Muxer job enqueued",
|
||||
);
|
||||
|
||||
return jobId;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
userId: data.userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to enqueue muxer job",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingJobs(): Promise<StoredJob[]> {
|
||||
const database = getDatabase();
|
||||
const stmt = database.prepare(`
|
||||
SELECT id, data, status, attempts, maxAttempts, createdAt, updatedAt, error
|
||||
FROM muxer_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY createdAt ASC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
const rows = stmt.all() as Array<{
|
||||
id: string;
|
||||
data: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: row.status as "pending" | "processing" | "completed" | "failed",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateJobStatus(
|
||||
jobId: string,
|
||||
status: "processing" | "completed" | "failed",
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const database = getDatabase();
|
||||
const now = Date.now();
|
||||
|
||||
if (status === "failed") {
|
||||
const stmt = database.prepare(`
|
||||
UPDATE muxer_jobs
|
||||
SET status = ?, attempts = attempts + 1, updatedAt = ?, error = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(status, now, error || null, jobId);
|
||||
} else {
|
||||
const stmt = database.prepare(`
|
||||
UPDATE muxer_jobs
|
||||
SET status = ?, updatedAt = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(status, now, jobId);
|
||||
}
|
||||
|
||||
logger.info({ jobId, status, error }, "Job status updated");
|
||||
}
|
||||
|
||||
export async function retryFailedJob(jobId: string): Promise<boolean> {
|
||||
const database = getDatabase();
|
||||
|
||||
const job = database
|
||||
.prepare("SELECT * FROM muxer_jobs WHERE id = ?")
|
||||
.get(jobId) as StoredJob | undefined;
|
||||
|
||||
if (!job) {
|
||||
logger.warn({ jobId }, "Job not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (job.attempts >= job.maxAttempts) {
|
||||
logger.warn(
|
||||
{ jobId, attempts: job.attempts, maxAttempts: job.maxAttempts },
|
||||
"Max retry attempts reached",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stmt = database.prepare(`
|
||||
UPDATE muxer_jobs
|
||||
SET status = 'pending', updatedAt = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(Date.now(), jobId);
|
||||
logger.info({ jobId, attempt: job.attempts + 1 }, "Job retried");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cleanupCompletedJobs(
|
||||
olderThanMs: number = 24 * 60 * 60 * 1000,
|
||||
): Promise<number> {
|
||||
const database = getDatabase();
|
||||
const cutoffTime = Date.now() - olderThanMs;
|
||||
|
||||
const stmt = database.prepare(`
|
||||
DELETE FROM muxer_jobs
|
||||
WHERE status = 'completed' AND updatedAt < ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(cutoffTime);
|
||||
logger.info({ deletedCount: result.changes }, "Cleaned up completed jobs");
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
export async function getJobStats(): Promise<{
|
||||
pending: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}> {
|
||||
const database = getDatabase();
|
||||
|
||||
const stats = database
|
||||
.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
||||
FROM muxer_jobs
|
||||
`)
|
||||
.get() as {
|
||||
pending: number | null;
|
||||
processing: number | null;
|
||||
completed: number | null;
|
||||
failed: number | null;
|
||||
};
|
||||
|
||||
return {
|
||||
pending: stats.pending || 0,
|
||||
processing: stats.processing || 0,
|
||||
completed: stats.completed || 0,
|
||||
failed: stats.failed || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeQueue(): Promise<void> {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
logger.info("Muxer queue closed");
|
||||
}
|
||||
}
|
||||
393
src/recorder.ts
393
src/recorder.ts
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
EndBehaviorType,
|
||||
entersState,
|
||||
@@ -6,14 +8,22 @@ import {
|
||||
VoiceConnectionStatus,
|
||||
} from "@discordjs/voice";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import prism from "prism-media";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { config } from "./config";
|
||||
import { createChildLogger } from "./logger";
|
||||
import { PacketFilter } from "./packetFilter";
|
||||
import { subscribeToAudioStream } from "./recorder/audioStream";
|
||||
import { OpusDecoder } from "./recorder/decoder";
|
||||
import {
|
||||
collectUserMetadata,
|
||||
createSegmentMetadata,
|
||||
} from "./recorder/metadata";
|
||||
import { SegmentManager } from "./recorder/segment";
|
||||
import { retryWithBackoff } from "./retry";
|
||||
import type { PcmBroadcaster } from "./types";
|
||||
|
||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||
const logger = createChildLogger("recorder");
|
||||
|
||||
const recordingsDir = config.RECORDINGS_DIR;
|
||||
|
||||
// Pastikan folder recordings ada
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
@@ -36,71 +46,58 @@ 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}`);
|
||||
if (config.VERBOSE) {
|
||||
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, 15_000);
|
||||
if (config.verbose) {
|
||||
console.log("[recorder] Connected to voice channel. Recording started.");
|
||||
}
|
||||
await retryWithBackoff(
|
||||
() =>
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Ready,
|
||||
config.VOICE_CONNECTION_TIMEOUT_MS,
|
||||
),
|
||||
{
|
||||
retries: 3,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
const receiver = connection.receiver;
|
||||
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
|
||||
|
||||
// Dengarkan siapapun yang mulai bicara
|
||||
receiver.speaking.on("start", async (userId) => {
|
||||
// Coba ambil data user dari cache atau fetch dari API
|
||||
const user =
|
||||
client.users.cache.get(userId) ||
|
||||
(await client.users.fetch(userId).catch(() => null));
|
||||
const member =
|
||||
channel.guild.members.cache.get(userId) ||
|
||||
(await channel.guild.members.fetch(userId).catch(() => null));
|
||||
const username = user?.username ?? "Unknown User";
|
||||
const avatarUrl =
|
||||
user?.displayAvatarURL({ format: "png", size: 64 }) ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
const displayName = member?.displayName ?? username;
|
||||
const roles =
|
||||
member?.roles.cache
|
||||
.filter((role) => role.id !== channel.guild.id)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
position: role.position,
|
||||
})) ?? [];
|
||||
const highestRole = roles.length > 0 ? roles[0] : null;
|
||||
const joinedTimestamp = member?.joinedTimestamp ?? null;
|
||||
|
||||
// Tampilkan format "nama user [voice activity]"
|
||||
console.log(`${username} [voice activity]`);
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
logger.info(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
);
|
||||
|
||||
// Notify webserver
|
||||
if ((global as any).updateActiveUser) {
|
||||
(global as any).updateActiveUser(userId, {
|
||||
username,
|
||||
avatar: avatarUrl,
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
@@ -108,270 +105,136 @@ export async function startRecording(
|
||||
const timestamp = Date.now();
|
||||
const sessionStartTime = timestamp;
|
||||
const sessionId = `${userId}-${sessionStartTime}`;
|
||||
const recordingSegmentMsRaw = Number(
|
||||
process.env.RECORDING_SEGMENT_MS ?? 5_000,
|
||||
);
|
||||
const recordingSegmentMs =
|
||||
Number.isFinite(recordingSegmentMsRaw) && recordingSegmentMsRaw > 0
|
||||
? recordingSegmentMsRaw
|
||||
: 0;
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
if (!fs.existsSync(userDir)) {
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// --- OGG file recording with segment rotation ---
|
||||
const packetFilterForOgg = new PacketFilter(
|
||||
config.PACKET_FILTER_MIN_SIZE,
|
||||
);
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 3000,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// --- OGG file recording with segment rotation ---
|
||||
const packetFilterForOgg = new PacketFilter(8);
|
||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||
let segmentIndex = 0;
|
||||
let currentSegment: {
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number | null;
|
||||
filename: string;
|
||||
jsonFilename: string;
|
||||
oggStream: any;
|
||||
out: fs.WriteStream;
|
||||
} | null = null;
|
||||
|
||||
const openSegment = () => {
|
||||
const index = segmentIndex++;
|
||||
const startTime = Date.now();
|
||||
const segmentFilename = path.join(userDir, `${startTime}.ogg`);
|
||||
const segmentJsonFilename = path.join(userDir, `${startTime}.json`);
|
||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||
opusHead: new prism.opus.OpusHead({
|
||||
channelCount: 2,
|
||||
sampleRate: 48000,
|
||||
}),
|
||||
pageSizeControl: { maxPackets: 10 },
|
||||
crc: true,
|
||||
});
|
||||
const out = fs.createWriteStream(segmentFilename);
|
||||
oggPacketStream.pipe(oggStream).pipe(out);
|
||||
|
||||
const segment = {
|
||||
index,
|
||||
startTime,
|
||||
endTime: null as number | null,
|
||||
filename: segmentFilename,
|
||||
jsonFilename: segmentJsonFilename,
|
||||
oggStream,
|
||||
out,
|
||||
};
|
||||
|
||||
out.on("finish", () => {
|
||||
if (config.verbose) {
|
||||
console.log(`[recorder] Saved: ${segment.filename}`);
|
||||
}
|
||||
const endTime = segment.endTime ?? Date.now();
|
||||
|
||||
const eventMetadata = {
|
||||
userId,
|
||||
username,
|
||||
tag: user?.tag ?? "Unknown#0000",
|
||||
displayName,
|
||||
avatarUrl,
|
||||
bot: user?.bot ?? false,
|
||||
roles,
|
||||
highestRole,
|
||||
joinedTimestamp,
|
||||
sessionId,
|
||||
sessionStartTime,
|
||||
segmentIndex: segment.index,
|
||||
segmentMs: recordingSegmentMs,
|
||||
startTime: segment.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - segment.startTime,
|
||||
filename: path.basename(segment.filename),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
segment.jsonFilename,
|
||||
JSON.stringify(eventMetadata, null, 2),
|
||||
const segmentManager = new SegmentManager(
|
||||
userDir,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
if (config.verbose) {
|
||||
console.log(`[recorder] Saved metadata: ${segment.jsonFilename}`);
|
||||
}
|
||||
});
|
||||
|
||||
out.on("error", (err) => {
|
||||
console.error(`[recorder] File write error ${userId}:`, err.message);
|
||||
});
|
||||
|
||||
return segment;
|
||||
};
|
||||
|
||||
const closeSegment = () => {
|
||||
if (!currentSegment) return;
|
||||
currentSegment.endTime = Date.now();
|
||||
oggPacketStream.unpipe(currentSegment.oggStream);
|
||||
currentSegment.oggStream.end();
|
||||
currentSegment = null;
|
||||
};
|
||||
|
||||
const rotateSegmentIfNeeded = () => {
|
||||
if (!currentSegment) return;
|
||||
if (recordingSegmentMs <= 0) return;
|
||||
if (Date.now() - currentSegment.startTime < recordingSegmentMs) return;
|
||||
closeSegment();
|
||||
currentSegment = openSegment();
|
||||
};
|
||||
|
||||
currentSegment = openSegment();
|
||||
|
||||
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
||||
// OpusScript can crash on long/invalid streams; avoid taking down the process.
|
||||
const decoderConfig = {
|
||||
frameSize: 960,
|
||||
channels: 2 as const,
|
||||
rate: 48000 as const,
|
||||
};
|
||||
const decoderCooldownMs = 30_000;
|
||||
const decoderRotateMs = Number(process.env.DECODER_ROTATE_MS ?? 5_000);
|
||||
let currentDecoder: prism.opus.Decoder | null = null;
|
||||
let decoderDisabledUntil = 0;
|
||||
let decoderCreatedAt = 0;
|
||||
|
||||
const handlePcm = (pcm: Buffer) => {
|
||||
if (!(global as any).broadcastPcmToWeb) return;
|
||||
const decoder = new OpusDecoder({
|
||||
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)
|
||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||
}
|
||||
(global as any).broadcastPcmToWeb(outBuf, userId);
|
||||
};
|
||||
|
||||
const destroyDecoder = () => {
|
||||
if (!currentDecoder) return;
|
||||
currentDecoder.removeAllListeners();
|
||||
currentDecoder.destroy();
|
||||
currentDecoder = null;
|
||||
decoderCreatedAt = 0;
|
||||
};
|
||||
|
||||
const createDecoder = () => {
|
||||
if (Date.now() < decoderDisabledUntil) return null;
|
||||
try {
|
||||
const d = new prism.opus.Decoder(decoderConfig);
|
||||
d.on("data", handlePcm);
|
||||
d.on("error", (err) => {
|
||||
console.warn("[recorder] Opus decoder error, cooling down:", err);
|
||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||
destroyDecoder();
|
||||
broadcaster.broadcastPcmToWeb(outBuf, userId);
|
||||
},
|
||||
});
|
||||
decoderCreatedAt = Date.now();
|
||||
return d;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[recorder] Opus decoder init failed, cooling down:",
|
||||
err,
|
||||
|
||||
let currentSegment = segmentManager.open(oggPacketStream);
|
||||
currentSegment.out.on("finish", () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
}
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
sessionId,
|
||||
sessionStartTime,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
currentSegment.jsonFilename,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
);
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ jsonFile: currentSegment.jsonFilename },
|
||||
"Metadata saved",
|
||||
);
|
||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const rotateDecoderIfNeeded = () => {
|
||||
if (!currentDecoder || decoderRotateMs <= 0) return;
|
||||
if (Date.now() - decoderCreatedAt < decoderRotateMs) return;
|
||||
destroyDecoder();
|
||||
currentDecoder = createDecoder();
|
||||
};
|
||||
|
||||
const ensureDecoder = () => {
|
||||
if (!currentDecoder) {
|
||||
currentDecoder = createDecoder();
|
||||
}
|
||||
return currentDecoder;
|
||||
};
|
||||
currentSegment.out.on("error", (err) => {
|
||||
logger.error({ userId, error: err.message }, "File write error");
|
||||
});
|
||||
|
||||
// Feed Opus packets one-by-one
|
||||
let packetCount = 0;
|
||||
audioStream.on("data", (chunk: Buffer) => {
|
||||
packetCount++;
|
||||
if (packetCount <= 5) {
|
||||
console.log(
|
||||
`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0, 4).toString("hex")}`,
|
||||
);
|
||||
}
|
||||
if (chunk.length < 8) return; // skip tiny control/DTX packets
|
||||
rotateSegmentIfNeeded();
|
||||
if (!(global as any).broadcastPcmToWeb) return;
|
||||
rotateDecoderIfNeeded();
|
||||
const decoder = ensureDecoder();
|
||||
if (!decoder) return;
|
||||
try {
|
||||
subscribeToAudioStream(receiver, userId, {
|
||||
onPacket: (chunk) => {
|
||||
if (chunk.length < 8) return;
|
||||
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||
if (!broadcaster.broadcastPcmToWeb) return;
|
||||
decoder.rotateIfNeeded();
|
||||
decoder.write(chunk);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[recorder] Opus decoder write failed, cooling down:",
|
||||
err,
|
||||
);
|
||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||
destroyDecoder();
|
||||
}
|
||||
});
|
||||
|
||||
audioStream.on("end", () => {
|
||||
closeSegment();
|
||||
destroyDecoder();
|
||||
if ((global as any).updateActiveUser) {
|
||||
(global as any).updateActiveUser(userId, {
|
||||
username,
|
||||
avatar: avatarUrl,
|
||||
},
|
||||
onEnd: () => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
logger.error({ userId, error: error.message }, "Audio stream error");
|
||||
},
|
||||
});
|
||||
|
||||
audioStream.on("error", (err) => {
|
||||
closeSegment();
|
||||
destroyDecoder();
|
||||
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
|
||||
});
|
||||
packetFilterForOgg.on("error", (err) => {
|
||||
closeSegment();
|
||||
console.error(
|
||||
`[recorder] PacketFilter(ogg) error ${userId}:`,
|
||||
err.message,
|
||||
);
|
||||
segmentManager.close(oggPacketStream);
|
||||
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...",
|
||||
);
|
||||
if (config.VERBOSE) {
|
||||
logger.warn("Disconnected from voice channel. Reconnecting...");
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Signalling,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Connecting,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
]);
|
||||
// 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.");
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -383,10 +246,10 @@ export function stopRecording(guildId: string): void {
|
||||
const connection = getVoiceConnection(guildId);
|
||||
if (connection) {
|
||||
connection.destroy();
|
||||
if (config.verbose) {
|
||||
console.log("[recorder] Recording stopped and disconnected.");
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Recording stopped and disconnected");
|
||||
}
|
||||
} else {
|
||||
console.warn("[recorder] No active connection to stop.");
|
||||
logger.warn("No active connection to stop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
||||
import { config } from "../config";
|
||||
|
||||
export interface AudioStreamHandlers {
|
||||
onPacket: (chunk: Buffer) => void;
|
||||
@@ -14,7 +15,7 @@ export function subscribeToAudioStream(
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: 3000,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import prism from "prism-media";
|
||||
import { config } from "../config";
|
||||
|
||||
export interface OpusDecoderOptions {
|
||||
cooldownMs: number;
|
||||
@@ -22,7 +23,17 @@ export class OpusDecoder {
|
||||
this.onData = options.onData;
|
||||
this.createDecoderFn =
|
||||
options.createDecoder ??
|
||||
(() => new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }));
|
||||
(() =>
|
||||
new prism.opus.Decoder({
|
||||
frameSize: config.OPUS_FRAME_SIZE,
|
||||
channels: config.AUDIO_CHANNELS as 1 | 2,
|
||||
rate: config.AUDIO_SAMPLE_RATE as
|
||||
| 8000
|
||||
| 12000
|
||||
| 16000
|
||||
| 24000
|
||||
| 48000,
|
||||
}));
|
||||
}
|
||||
|
||||
rotateIfNeeded(): void {
|
||||
@@ -38,7 +49,10 @@ export class OpusDecoder {
|
||||
try {
|
||||
decoder.write(chunk);
|
||||
} catch (error) {
|
||||
console.warn("[recorder] Opus decoder write failed, cooling down:", error);
|
||||
console.warn(
|
||||
"[recorder] Opus decoder write failed, cooling down:",
|
||||
error,
|
||||
);
|
||||
this.coolDown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config";
|
||||
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
|
||||
|
||||
export async function collectUserMetadata(
|
||||
@@ -18,8 +19,11 @@ export async function collectUserMetadata(
|
||||
member?.roles.cache
|
||||
.filter((role) => role.id !== channel.guild.id)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((role) => ({ id: role.id, name: role.name, position: role.position })) ??
|
||||
[];
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
position: role.position,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
userId,
|
||||
@@ -27,8 +31,19 @@ export async function collectUserMetadata(
|
||||
tag: user?.tag ?? "Unknown#0000",
|
||||
displayName: member?.displayName ?? username,
|
||||
avatarUrl:
|
||||
user?.displayAvatarURL({ format: "png", size: 64 }) ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png",
|
||||
user?.displayAvatarURL({
|
||||
format: "png",
|
||||
size: config.AVATAR_SIZE as
|
||||
| 16
|
||||
| 32
|
||||
| 64
|
||||
| 128
|
||||
| 256
|
||||
| 512
|
||||
| 1024
|
||||
| 2048
|
||||
| 4096,
|
||||
}) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||
bot: user?.bot ?? false,
|
||||
roles,
|
||||
highestRole: roles[0] ?? null,
|
||||
|
||||
@@ -33,7 +33,10 @@ export class SegmentManager {
|
||||
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
||||
const index = this.segmentIndex++;
|
||||
const startTime = Date.now();
|
||||
const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime);
|
||||
const { filename, jsonFilename } = buildSegmentPaths(
|
||||
this.userDir,
|
||||
startTime,
|
||||
);
|
||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
||||
pageSizeControl: { maxPackets: 10 },
|
||||
|
||||
42
src/retry.ts
Normal file
42
src/retry.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
38
src/validation.ts
Normal file
38
src/validation.ts
Normal file
@@ -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<UserStateUpdate | null> {
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
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 { getMetrics, uptimeGauge } from "./metrics";
|
||||
import { discordPlayer } from "./player";
|
||||
|
||||
const wsLogger = createChildLogger("webserver");
|
||||
|
||||
const activeUsers = new Map<
|
||||
string,
|
||||
{ username: string; avatar: string; speaking: boolean }
|
||||
@@ -42,12 +48,34 @@ 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}`,
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
// Metrics endpoint
|
||||
app.get("/metrics", async (_req, res) => {
|
||||
res.set("Content-Type", "text/plain");
|
||||
uptimeGauge.set(process.uptime());
|
||||
res.send(await getMetrics());
|
||||
});
|
||||
|
||||
// Inbound: Discord PCM → tagged chunks → browser
|
||||
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
||||
let hash = 0;
|
||||
@@ -124,9 +152,7 @@ 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)`,
|
||||
);
|
||||
wsLogger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level");
|
||||
dbAccum = 0;
|
||||
dbCount = 0;
|
||||
}
|
||||
@@ -140,8 +166,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 +176,7 @@ export function startWebserver(port: number = 3000) {
|
||||
if (playerPaused) {
|
||||
discordPlayer.unpause();
|
||||
playerPaused = false;
|
||||
console.log("[transmit] 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
|
||||
@@ -159,7 +185,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");
|
||||
wsLogger.info("Stopped — Discord indicator OFF");
|
||||
return;
|
||||
} else {
|
||||
return; // already paused, nothing to do
|
||||
@@ -173,7 +199,7 @@ export function startWebserver(port: number = 3000) {
|
||||
}, 20);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
console.log("[webserver] New WebSocket connection on port " + wsPort);
|
||||
wsLogger.info({ wsPort }, "New WebSocket connection");
|
||||
wsClients.add(ws);
|
||||
|
||||
ws.send(
|
||||
@@ -208,8 +234,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}`,
|
||||
);
|
||||
wsLogger.info({ port }, "Web interface listening");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user