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
|
node_modules
|
||||||
recordings
|
recordings
|
||||||
.env
|
.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": {
|
"scripts": {
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
|
"build": "tsc --outDir dist",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check --diagnostic-level=error .",
|
"lint": "biome check --diagnostic-level=error .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
@@ -14,25 +15,33 @@
|
|||||||
"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",
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"libsodium-wrappers": "^0.8.2",
|
"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",
|
"prism-media": "2.0.0-alpha.0",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"sodium-native": "^4.3.2",
|
"sodium-native": "^4.3.2",
|
||||||
"ws": "^8.20.1"
|
"ws": "^8.20.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"pino-pretty": "^10.3.1",
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,46 @@
|
|||||||
// Configuration for the bot
|
import { z } from "zod";
|
||||||
export interface AppConfig {
|
import { ConfigError } from "./errors";
|
||||||
verbose: boolean;
|
|
||||||
recordingsDir: string;
|
|
||||||
recordingSegmentMs: number;
|
|
||||||
decoderRotateMs: number;
|
|
||||||
decoderCooldownMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseBoolean(
|
const configSchema = z.object({
|
||||||
value: string | undefined,
|
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||||
fallback: boolean,
|
VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"),
|
||||||
): boolean {
|
GUILD_ID: z.string().min(1, "GUILD_ID is required"),
|
||||||
if (value === "true") return true;
|
VERBOSE: z
|
||||||
if (value === "false") return false;
|
.string()
|
||||||
return fallback;
|
.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(
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
value: string | undefined,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
return {
|
try {
|
||||||
verbose: parseBoolean(env.VERBOSE, false),
|
return configSchema.parse(env);
|
||||||
recordingsDir: env.RECORDINGS_DIR ?? "./recordings",
|
} catch (error) {
|
||||||
recordingSegmentMs: parsePositiveNumber(env.RECORDING_SEGMENT_MS, 5_000),
|
if (error instanceof z.ZodError) {
|
||||||
decoderRotateMs: parsePositiveNumber(env.DECODER_ROTATE_MS, 5_000),
|
const messages = error.issues
|
||||||
decoderCooldownMs: 30_000,
|
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||||
};
|
.join("\n");
|
||||||
|
throw new ConfigError(`Configuration validation failed:\n${messages}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = loadConfig();
|
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 "./mock-crc";
|
||||||
import "libsodium-wrappers";
|
import "libsodium-wrappers";
|
||||||
import "@snazzah/davey";
|
import "@snazzah/davey";
|
||||||
|
import "dotenv/config";
|
||||||
import { getVoiceConnection } from "@discordjs/voice";
|
import { getVoiceConnection } from "@discordjs/voice";
|
||||||
import { Client } from "discord.js-selfbot-v13";
|
import { Client } from "discord.js-selfbot-v13";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { createChildLogger } from "./logger";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
import { startRecording } from "./recorder";
|
import { startRecording, stopRecording } from "./recorder";
|
||||||
import { startWebserver } from "./webserver";
|
import { startWebserver } from "./webserver";
|
||||||
|
|
||||||
// Validasi environment variables
|
const logger = createChildLogger("bot");
|
||||||
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");
|
const token = config.DISCORD_TOKEN;
|
||||||
if (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env");
|
const voiceChannelId = config.VOICE_CHANNEL_ID;
|
||||||
if (!guildId) throw new Error("Missing GUILD_ID in .env");
|
const guildId = config.GUILD_ID;
|
||||||
|
|
||||||
// Inisialisasi selfbot client (gunakan checkUpdate: false supaya tidak ada prompt update)
|
// Inisialisasi selfbot client
|
||||||
const client = new 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 () => {
|
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,49 +89,53 @@ 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
|
||||||
startWebserver(3000);
|
startWebserver(config.WEBSERVER_PORT);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.on("error", (err) => {
|
||||||
console.error("[bot] Client error:", err);
|
logger.error({ error: err }, "Client error");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown handlers
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
if (config.verbose) {
|
gracefulShutdown("SIGINT");
|
||||||
console.log("\n[bot] Shutting down...");
|
|
||||||
}
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
if (config.verbose) {
|
gracefulShutdown("SIGTERM");
|
||||||
console.log("[bot] Terminating...");
|
});
|
||||||
}
|
|
||||||
client.destroy();
|
// Handle uncaught exceptions
|
||||||
process.exit(0);
|
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);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
415
src/recorder.ts
415
src/recorder.ts
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
EndBehaviorType,
|
EndBehaviorType,
|
||||||
entersState,
|
entersState,
|
||||||
@@ -6,14 +8,22 @@ 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 fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import prism from "prism-media";
|
|
||||||
import { pipeline } from "stream/promises";
|
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { createChildLogger } from "./logger";
|
||||||
import { PacketFilter } from "./packetFilter";
|
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
|
// Pastikan folder recordings ada
|
||||||
if (!fs.existsSync(recordingsDir)) {
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
@@ -36,71 +46,58 @@ 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(connection, VoiceConnectionStatus.Ready, 15_000);
|
await retryWithBackoff(
|
||||||
if (config.verbose) {
|
() =>
|
||||||
console.log("[recorder] Connected to voice channel. Recording started.");
|
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) {
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const receiver = connection.receiver;
|
const receiver = connection.receiver;
|
||||||
|
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
|
||||||
|
|
||||||
// Dengarkan siapapun yang mulai bicara
|
// Dengarkan siapapun yang mulai bicara
|
||||||
receiver.speaking.on("start", async (userId) => {
|
receiver.speaking.on("start", async (userId) => {
|
||||||
// Coba ambil data user dari cache atau fetch dari API
|
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||||
const user =
|
logger.info(
|
||||||
client.users.cache.get(userId) ||
|
{ userId, username: userMetadata.username },
|
||||||
(await client.users.fetch(userId).catch(() => null));
|
"Voice activity detected",
|
||||||
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]`);
|
|
||||||
|
|
||||||
// Notify webserver
|
// Notify webserver
|
||||||
if ((global as any).updateActiveUser) {
|
broadcaster.updateActiveUser?.(userId, {
|
||||||
(global as any).updateActiveUser(userId, {
|
username: userMetadata.username,
|
||||||
username,
|
avatar: userMetadata.avatarUrl,
|
||||||
avatar: avatarUrl,
|
speaking: true,
|
||||||
speaking: true,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||||
if (receiver.subscriptions.has(userId)) return;
|
if (receiver.subscriptions.has(userId)) return;
|
||||||
@@ -108,270 +105,136 @@ export async function startRecording(
|
|||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const sessionStartTime = timestamp;
|
const sessionStartTime = timestamp;
|
||||||
const sessionId = `${userId}-${sessionStartTime}`;
|
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);
|
const userDir = path.join(recordingsDir, userId);
|
||||||
if (!fs.existsSync(userDir)) {
|
if (!fs.existsSync(userDir)) {
|
||||||
fs.mkdirSync(userDir, { recursive: true });
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioStream = receiver.subscribe(userId, {
|
|
||||||
end: {
|
|
||||||
behavior: EndBehaviorType.AfterSilence,
|
|
||||||
duration: 3000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// --- OGG file recording with segment rotation ---
|
// --- OGG file recording with segment rotation ---
|
||||||
const packetFilterForOgg = new PacketFilter(8);
|
const packetFilterForOgg = new PacketFilter(
|
||||||
|
config.PACKET_FILTER_MIN_SIZE,
|
||||||
|
);
|
||||||
|
const audioStream = receiver.subscribe(userId, {
|
||||||
|
end: {
|
||||||
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
});
|
||||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||||
let segmentIndex = 0;
|
const segmentManager = new SegmentManager(
|
||||||
let currentSegment: {
|
userDir,
|
||||||
index: number;
|
config.RECORDING_SEGMENT_MS,
|
||||||
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),
|
|
||||||
);
|
|
||||||
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 ---
|
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
||||||
// OpusScript can crash on long/invalid streams; avoid taking down the process.
|
const decoder = new OpusDecoder({
|
||||||
const decoderConfig = {
|
cooldownMs: config.DECODER_COOLDOWN_MS,
|
||||||
frameSize: 960,
|
rotateMs: config.DECODER_ROTATE_MS,
|
||||||
channels: 2 as const,
|
onData: (pcm) => {
|
||||||
rate: 48000 as const,
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
};
|
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
||||||
const decoderCooldownMs = 30_000;
|
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||||
const decoderRotateMs = Number(process.env.DECODER_ROTATE_MS ?? 5_000);
|
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||||
let currentDecoder: prism.opus.Decoder | null = null;
|
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||||
let decoderDisabledUntil = 0;
|
}
|
||||||
let decoderCreatedAt = 0;
|
broadcaster.broadcastPcmToWeb(outBuf, userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handlePcm = (pcm: Buffer) => {
|
let currentSegment = segmentManager.open(oggPacketStream);
|
||||||
if (!(global as any).broadcastPcmToWeb) return;
|
currentSegment.out.on("finish", () => {
|
||||||
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
if (config.VERBOSE) {
|
||||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
|
||||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
|
||||||
}
|
}
|
||||||
(global as any).broadcastPcmToWeb(outBuf, userId);
|
const metadata = createSegmentMetadata(
|
||||||
};
|
userMetadata,
|
||||||
|
currentSegment,
|
||||||
const destroyDecoder = () => {
|
sessionId,
|
||||||
if (!currentDecoder) return;
|
sessionStartTime,
|
||||||
currentDecoder.removeAllListeners();
|
config.RECORDING_SEGMENT_MS,
|
||||||
currentDecoder.destroy();
|
);
|
||||||
currentDecoder = null;
|
fs.writeFileSync(
|
||||||
decoderCreatedAt = 0;
|
currentSegment.jsonFilename,
|
||||||
};
|
JSON.stringify(metadata, null, 2),
|
||||||
|
);
|
||||||
const createDecoder = () => {
|
if (config.VERBOSE) {
|
||||||
if (Date.now() < decoderDisabledUntil) return null;
|
logger.info(
|
||||||
try {
|
{ jsonFile: currentSegment.jsonFilename },
|
||||||
const d = new prism.opus.Decoder(decoderConfig);
|
"Metadata saved",
|
||||||
d.on("data", handlePcm);
|
|
||||||
d.on("error", (err) => {
|
|
||||||
console.warn("[recorder] Opus decoder error, cooling down:", err);
|
|
||||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
|
||||||
destroyDecoder();
|
|
||||||
});
|
|
||||||
decoderCreatedAt = Date.now();
|
|
||||||
return d;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"[recorder] Opus decoder init failed, cooling down:",
|
|
||||||
err,
|
|
||||||
);
|
);
|
||||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const rotateDecoderIfNeeded = () => {
|
currentSegment.out.on("error", (err) => {
|
||||||
if (!currentDecoder || decoderRotateMs <= 0) return;
|
logger.error({ userId, error: err.message }, "File write error");
|
||||||
if (Date.now() - decoderCreatedAt < decoderRotateMs) return;
|
});
|
||||||
destroyDecoder();
|
|
||||||
currentDecoder = createDecoder();
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureDecoder = () => {
|
|
||||||
if (!currentDecoder) {
|
|
||||||
currentDecoder = createDecoder();
|
|
||||||
}
|
|
||||||
return currentDecoder;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Feed Opus packets one-by-one
|
// Feed Opus packets one-by-one
|
||||||
let packetCount = 0;
|
subscribeToAudioStream(receiver, userId, {
|
||||||
audioStream.on("data", (chunk: Buffer) => {
|
onPacket: (chunk) => {
|
||||||
packetCount++;
|
if (chunk.length < 8) return;
|
||||||
if (packetCount <= 5) {
|
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||||
console.log(
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0, 4).toString("hex")}`,
|
decoder.rotateIfNeeded();
|
||||||
);
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
decoder.write(chunk);
|
decoder.write(chunk);
|
||||||
} catch (err) {
|
},
|
||||||
console.warn(
|
onEnd: () => {
|
||||||
"[recorder] Opus decoder write failed, cooling down:",
|
segmentManager.close(oggPacketStream);
|
||||||
err,
|
decoder.destroy();
|
||||||
);
|
broadcaster.updateActiveUser?.(userId, {
|
||||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
username: userMetadata.username,
|
||||||
destroyDecoder();
|
avatar: userMetadata.avatarUrl,
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
audioStream.on("end", () => {
|
|
||||||
closeSegment();
|
|
||||||
destroyDecoder();
|
|
||||||
if ((global as any).updateActiveUser) {
|
|
||||||
(global as any).updateActiveUser(userId, {
|
|
||||||
username,
|
|
||||||
avatar: avatarUrl,
|
|
||||||
speaking: false,
|
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) => {
|
packetFilterForOgg.on("error", (err) => {
|
||||||
closeSegment();
|
segmentManager.close(oggPacketStream);
|
||||||
console.error(
|
logger.error({ userId, error: err.message }, "PacketFilter error");
|
||||||
`[recorder] PacketFilter(ogg) error ${userId}:`,
|
|
||||||
err.message,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} 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([
|
||||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
entersState(
|
||||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
connection,
|
||||||
|
VoiceConnectionStatus.Signalling,
|
||||||
|
config.RECONNECT_TIMEOUT_MS,
|
||||||
|
),
|
||||||
|
entersState(
|
||||||
|
connection,
|
||||||
|
VoiceConnectionStatus.Connecting,
|
||||||
|
config.RECONNECT_TIMEOUT_MS,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
// 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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -383,10 +246,10 @@ export function stopRecording(guildId: string): void {
|
|||||||
const connection = getVoiceConnection(guildId);
|
const connection = getVoiceConnection(guildId);
|
||||||
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
export interface AudioStreamHandlers {
|
export interface AudioStreamHandlers {
|
||||||
onPacket: (chunk: Buffer) => void;
|
onPacket: (chunk: Buffer) => void;
|
||||||
@@ -14,7 +15,7 @@ export function subscribeToAudioStream(
|
|||||||
const audioStream = receiver.subscribe(userId, {
|
const audioStream = receiver.subscribe(userId, {
|
||||||
end: {
|
end: {
|
||||||
behavior: EndBehaviorType.AfterSilence,
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
duration: 3000,
|
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prism from "prism-media";
|
import prism from "prism-media";
|
||||||
|
import { config } from "../config";
|
||||||
|
|
||||||
export interface OpusDecoderOptions {
|
export interface OpusDecoderOptions {
|
||||||
cooldownMs: number;
|
cooldownMs: number;
|
||||||
@@ -22,7 +23,17 @@ export class OpusDecoder {
|
|||||||
this.onData = options.onData;
|
this.onData = options.onData;
|
||||||
this.createDecoderFn =
|
this.createDecoderFn =
|
||||||
options.createDecoder ??
|
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 {
|
rotateIfNeeded(): void {
|
||||||
@@ -38,7 +49,10 @@ export class OpusDecoder {
|
|||||||
try {
|
try {
|
||||||
decoder.write(chunk);
|
decoder.write(chunk);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[recorder] Opus decoder write failed, cooling down:", error);
|
console.warn(
|
||||||
|
"[recorder] Opus decoder write failed, cooling down:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
this.coolDown();
|
this.coolDown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||||
|
import { config } from "../config";
|
||||||
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
|
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
|
||||||
|
|
||||||
export async function collectUserMetadata(
|
export async function collectUserMetadata(
|
||||||
@@ -18,8 +19,11 @@ export async function collectUserMetadata(
|
|||||||
member?.roles.cache
|
member?.roles.cache
|
||||||
.filter((role) => role.id !== channel.guild.id)
|
.filter((role) => role.id !== channel.guild.id)
|
||||||
.sort((a, b) => b.position - a.position)
|
.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 {
|
return {
|
||||||
userId,
|
userId,
|
||||||
@@ -27,8 +31,19 @@ export async function collectUserMetadata(
|
|||||||
tag: user?.tag ?? "Unknown#0000",
|
tag: user?.tag ?? "Unknown#0000",
|
||||||
displayName: member?.displayName ?? username,
|
displayName: member?.displayName ?? username,
|
||||||
avatarUrl:
|
avatarUrl:
|
||||||
user?.displayAvatarURL({ format: "png", size: 64 }) ??
|
user?.displayAvatarURL({
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png",
|
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,
|
bot: user?.bot ?? false,
|
||||||
roles,
|
roles,
|
||||||
highestRole: roles[0] ?? null,
|
highestRole: roles[0] ?? null,
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export class SegmentManager {
|
|||||||
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
||||||
const index = this.segmentIndex++;
|
const index = this.segmentIndex++;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime);
|
const { filename, jsonFilename } = buildSegmentPaths(
|
||||||
|
this.userDir,
|
||||||
|
startTime,
|
||||||
|
);
|
||||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
||||||
pageSizeControl: { maxPackets: 10 },
|
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 express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import pinoHttp from "pino-http";
|
||||||
import prism from "prism-media";
|
import prism from "prism-media";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
import { createChildLogger, logger } from "./logger";
|
||||||
|
import { getMetrics, uptimeGauge } from "./metrics";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
|
|
||||||
|
const wsLogger = createChildLogger("webserver");
|
||||||
|
|
||||||
const activeUsers = new Map<
|
const activeUsers = new Map<
|
||||||
string,
|
string,
|
||||||
{ username: string; avatar: string; speaking: boolean }
|
{ username: string; avatar: string; speaking: boolean }
|
||||||
@@ -42,12 +48,34 @@ 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(
|
wsLogger.info({ wsPort }, "WebSocket server listening");
|
||||||
`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`,
|
|
||||||
);
|
// Security headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// HTTP request logging
|
||||||
|
app.use(pinoHttp({ logger }));
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
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
|
// Inbound: Discord PCM → tagged chunks → browser
|
||||||
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -124,9 +152,7 @@ export function startWebserver(port: number = 3000) {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (dbCount > 0) {
|
if (dbCount > 0) {
|
||||||
const avg = dbAccum / dbCount;
|
const avg = dbAccum / dbCount;
|
||||||
console.log(
|
wsLogger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level");
|
||||||
`[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`,
|
|
||||||
);
|
|
||||||
dbAccum = 0;
|
dbAccum = 0;
|
||||||
dbCount = 0;
|
dbCount = 0;
|
||||||
}
|
}
|
||||||
@@ -140,8 +166,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 +176,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");
|
wsLogger.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 +185,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");
|
wsLogger.info("Stopped — Discord indicator OFF");
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
return; // already paused, nothing to do
|
return; // already paused, nothing to do
|
||||||
@@ -173,7 +199,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);
|
wsLogger.info({ wsPort }, "New WebSocket connection");
|
||||||
wsClients.add(ws);
|
wsClients.add(ws);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -208,8 +234,6 @@ export function startWebserver(port: number = 3000) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
server.listen(port, "0.0.0.0", () => {
|
||||||
console.log(
|
wsLogger.info({ port }, "Web interface listening");
|
||||||
`[webserver] Web interface listening on http://0.0.0.0:${port}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user