feat: enhance configuration management and add error handling
This commit is contained in:
@@ -26,3 +26,8 @@ WEBSERVER_PORT=3000
|
|||||||
# Connection Configuration
|
# Connection Configuration
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000
|
VOICE_CONNECTION_TIMEOUT_MS=15000
|
||||||
RECONNECT_TIMEOUT_MS=5000
|
RECONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -16,15 +16,21 @@
|
|||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.1",
|
"@discordjs/voice": "^0.19.1",
|
||||||
"@snazzah/davey": "^0.1.10",
|
"@snazzah/davey": "^0.1.10",
|
||||||
|
"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",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"libsodium-wrappers": "^0.8.2",
|
"libsodium-wrappers": "^0.8.2",
|
||||||
|
"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",
|
||||||
"sodium-native": "^4.3.2",
|
"sodium-native": "^4.3.2",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.20.1",
|
||||||
"pino": "^9.4.0",
|
"zod": "^4.4.3"
|
||||||
"p-retry": "^6.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
|
|||||||
@@ -1,61 +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;
|
|
||||||
webserverPort: number;
|
|
||||||
voiceConnectionTimeoutMs: number;
|
|
||||||
reconnectTimeoutMs: number;
|
|
||||||
audioStreamSilenceDurationMs: number;
|
|
||||||
packetFilterMinSize: number;
|
|
||||||
opusFrameSize: number;
|
|
||||||
audioSampleRate: number;
|
|
||||||
audioChannels: number;
|
|
||||||
avatarSize: 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: parsePositiveNumber(env.DECODER_COOLDOWN_MS, 30_000),
|
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||||
webserverPort: parsePositiveNumber(env.WEBSERVER_PORT, 3000),
|
.join("\n");
|
||||||
voiceConnectionTimeoutMs: parsePositiveNumber(
|
throw new ConfigError(`Configuration validation failed:\n${messages}`);
|
||||||
env.VOICE_CONNECTION_TIMEOUT_MS,
|
}
|
||||||
15_000,
|
throw error;
|
||||||
),
|
}
|
||||||
reconnectTimeoutMs: parsePositiveNumber(env.RECONNECT_TIMEOUT_MS, 5_000),
|
|
||||||
audioStreamSilenceDurationMs: parsePositiveNumber(
|
|
||||||
env.AUDIO_STREAM_SILENCE_DURATION_MS,
|
|
||||||
3000,
|
|
||||||
),
|
|
||||||
packetFilterMinSize: parsePositiveNumber(env.PACKET_FILTER_MIN_SIZE, 8),
|
|
||||||
opusFrameSize: parsePositiveNumber(env.OPUS_FRAME_SIZE, 960),
|
|
||||||
audioSampleRate: parsePositiveNumber(env.AUDIO_SAMPLE_RATE, 48000),
|
|
||||||
audioChannels: parsePositiveNumber(env.AUDIO_CHANNELS, 2),
|
|
||||||
avatarSize: parsePositiveNumber(env.AVATAR_SIZE, 64),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/index.ts
21
src/index.ts
@@ -1,24 +1,21 @@
|
|||||||
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 { AppError } from "./errors";
|
||||||
|
import { createChildLogger } from "./logger";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
import { startRecording, stopRecording } from "./recorder";
|
import { startRecording, stopRecording } from "./recorder";
|
||||||
import { startWebserver } from "./webserver";
|
import { startWebserver } from "./webserver";
|
||||||
import { createChildLogger } from "./logger";
|
|
||||||
|
|
||||||
const logger = createChildLogger("bot");
|
const logger = createChildLogger("bot");
|
||||||
|
|
||||||
// Validasi environment variables
|
const token = config.DISCORD_TOKEN;
|
||||||
const token = process.env.DISCORD_TOKEN;
|
const voiceChannelId = config.VOICE_CHANNEL_ID;
|
||||||
const voiceChannelId = process.env.VOICE_CHANNEL_ID;
|
const guildId = config.GUILD_ID;
|
||||||
const guildId = process.env.GUILD_ID;
|
|
||||||
|
|
||||||
if (!token) throw new Error("Missing DISCORD_TOKEN in .env");
|
|
||||||
if (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env");
|
|
||||||
if (!guildId) throw new Error("Missing GUILD_ID in .env");
|
|
||||||
|
|
||||||
// Inisialisasi selfbot client
|
// Inisialisasi selfbot client
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
@@ -76,7 +73,7 @@ async function gracefulShutdown(signal: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +94,7 @@ client.on("ready", async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ channelName: channel.name, channelId: channel.id },
|
{ channelName: channel.name, channelId: channel.id },
|
||||||
"Joining voice channel",
|
"Joining voice channel",
|
||||||
@@ -114,7 +111,7 @@ client.on("ready", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start Webserver
|
// Start Webserver
|
||||||
startWebserver(config.webserverPort);
|
startWebserver(config.WEBSERVER_PORT);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.on("error", (err) => {
|
||||||
|
|||||||
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} 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 { 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 { subscribeToAudioStream } from "./recorder/audioStream";
|
||||||
import { OpusDecoder } from "./recorder/decoder";
|
import { OpusDecoder } from "./recorder/decoder";
|
||||||
@@ -17,13 +18,12 @@ import {
|
|||||||
createSegmentMetadata,
|
createSegmentMetadata,
|
||||||
} from "./recorder/metadata";
|
} from "./recorder/metadata";
|
||||||
import { SegmentManager } from "./recorder/segment";
|
import { SegmentManager } from "./recorder/segment";
|
||||||
import type { PcmBroadcaster } from "./types";
|
|
||||||
import { createChildLogger } from "./logger";
|
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
|
import type { PcmBroadcaster } from "./types";
|
||||||
|
|
||||||
const logger = createChildLogger("recorder");
|
const logger = createChildLogger("recorder");
|
||||||
|
|
||||||
const recordingsDir = config.recordingsDir;
|
const recordingsDir = config.RECORDINGS_DIR;
|
||||||
|
|
||||||
// Pastikan folder recordings ada
|
// Pastikan folder recordings ada
|
||||||
if (!fs.existsSync(recordingsDir)) {
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
@@ -49,7 +49,7 @@ export async function startRecording(
|
|||||||
logger.info({ channelName: channel.name }, "Joining voice channel");
|
logger.info({ channelName: channel.name }, "Joining voice channel");
|
||||||
|
|
||||||
connection.on("debug", (msg) => {
|
connection.on("debug", (msg) => {
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.debug({ message: msg }, "Voice debug");
|
logger.debug({ message: msg }, "Voice debug");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -65,7 +65,7 @@ export async function startRecording(
|
|||||||
entersState(
|
entersState(
|
||||||
connection,
|
connection,
|
||||||
VoiceConnectionStatus.Ready,
|
VoiceConnectionStatus.Ready,
|
||||||
config.voiceConnectionTimeoutMs,
|
config.VOICE_CONNECTION_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@@ -87,7 +87,10 @@ export async function startRecording(
|
|||||||
// Dengarkan siapapun yang mulai bicara
|
// Dengarkan siapapun yang mulai bicara
|
||||||
receiver.speaking.on("start", async (userId) => {
|
receiver.speaking.on("start", async (userId) => {
|
||||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||||
logger.info({ userId, username: userMetadata.username }, "Voice activity detected");
|
logger.info(
|
||||||
|
{ userId, username: userMetadata.username },
|
||||||
|
"Voice activity detected",
|
||||||
|
);
|
||||||
|
|
||||||
// Notify webserver
|
// Notify webserver
|
||||||
broadcaster.updateActiveUser?.(userId, {
|
broadcaster.updateActiveUser?.(userId, {
|
||||||
@@ -109,7 +112,9 @@ export async function startRecording(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// --- OGG file recording with segment rotation ---
|
// --- OGG file recording with segment rotation ---
|
||||||
const packetFilterForOgg = new PacketFilter(config.packetFilterMinSize);
|
const packetFilterForOgg = new PacketFilter(
|
||||||
|
config.PACKET_FILTER_MIN_SIZE,
|
||||||
|
);
|
||||||
const audioStream = receiver.subscribe(userId, {
|
const audioStream = receiver.subscribe(userId, {
|
||||||
end: {
|
end: {
|
||||||
behavior: EndBehaviorType.AfterSilence,
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
@@ -119,13 +124,13 @@ export async function startRecording(
|
|||||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||||
const segmentManager = new SegmentManager(
|
const segmentManager = new SegmentManager(
|
||||||
userDir,
|
userDir,
|
||||||
config.recordingSegmentMs,
|
config.RECORDING_SEGMENT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
||||||
const decoder = new OpusDecoder({
|
const decoder = new OpusDecoder({
|
||||||
cooldownMs: config.decoderCooldownMs,
|
cooldownMs: config.DECODER_COOLDOWN_MS,
|
||||||
rotateMs: config.decoderRotateMs,
|
rotateMs: config.DECODER_ROTATE_MS,
|
||||||
onData: (pcm) => {
|
onData: (pcm) => {
|
||||||
if (!broadcaster.broadcastPcmToWeb) return;
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
||||||
@@ -139,7 +144,7 @@ export async function startRecording(
|
|||||||
|
|
||||||
let currentSegment = segmentManager.open(oggPacketStream);
|
let currentSegment = segmentManager.open(oggPacketStream);
|
||||||
currentSegment.out.on("finish", () => {
|
currentSegment.out.on("finish", () => {
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||||
}
|
}
|
||||||
const metadata = createSegmentMetadata(
|
const metadata = createSegmentMetadata(
|
||||||
@@ -147,13 +152,13 @@ export async function startRecording(
|
|||||||
currentSegment,
|
currentSegment,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionStartTime,
|
sessionStartTime,
|
||||||
config.recordingSegmentMs,
|
config.RECORDING_SEGMENT_MS,
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
currentSegment.jsonFilename,
|
currentSegment.jsonFilename,
|
||||||
JSON.stringify(metadata, null, 2),
|
JSON.stringify(metadata, null, 2),
|
||||||
);
|
);
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jsonFile: currentSegment.jsonFilename },
|
{ jsonFile: currentSegment.jsonFilename },
|
||||||
"Metadata saved",
|
"Metadata saved",
|
||||||
@@ -162,10 +167,7 @@ export async function startRecording(
|
|||||||
});
|
});
|
||||||
|
|
||||||
currentSegment.out.on("error", (err) => {
|
currentSegment.out.on("error", (err) => {
|
||||||
logger.error(
|
logger.error({ userId, error: err.message }, "File write error");
|
||||||
{ userId, error: err.message },
|
|
||||||
"File write error",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Feed Opus packets one-by-one
|
// Feed Opus packets one-by-one
|
||||||
@@ -189,19 +191,13 @@ export async function startRecording(
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
segmentManager.close(oggPacketStream);
|
segmentManager.close(oggPacketStream);
|
||||||
decoder.destroy();
|
decoder.destroy();
|
||||||
logger.error(
|
logger.error({ userId, error: error.message }, "Audio stream error");
|
||||||
{ userId, error: error.message },
|
|
||||||
"Audio stream error",
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
packetFilterForOgg.on("error", (err) => {
|
packetFilterForOgg.on("error", (err) => {
|
||||||
segmentManager.close(oggPacketStream);
|
segmentManager.close(oggPacketStream);
|
||||||
logger.error(
|
logger.error({ userId, error: err.message }, "PacketFilter error");
|
||||||
{ userId, error: err.message },
|
|
||||||
"PacketFilter error",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -213,7 +209,7 @@ export async function startRecording(
|
|||||||
|
|
||||||
// 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) {
|
||||||
logger.warn("Disconnected from voice channel. Reconnecting...");
|
logger.warn("Disconnected from voice channel. Reconnecting...");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -221,12 +217,12 @@ export async function startRecording(
|
|||||||
entersState(
|
entersState(
|
||||||
connection,
|
connection,
|
||||||
VoiceConnectionStatus.Signalling,
|
VoiceConnectionStatus.Signalling,
|
||||||
config.reconnectTimeoutMs,
|
config.RECONNECT_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
entersState(
|
entersState(
|
||||||
connection,
|
connection,
|
||||||
VoiceConnectionStatus.Connecting,
|
VoiceConnectionStatus.Connecting,
|
||||||
config.reconnectTimeoutMs,
|
config.RECONNECT_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
// Berhasil reconnect
|
// Berhasil reconnect
|
||||||
@@ -237,7 +233,7 @@ export async function startRecording(
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||||
if (config.verbose) {
|
if (config.VERBOSE) {
|
||||||
logger.info("Voice connection destroyed");
|
logger.info("Voice connection destroyed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -250,7 +246,7 @@ 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) {
|
||||||
logger.info("Recording stopped and disconnected");
|
logger.info("Recording stopped and disconnected");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,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: config.audioStreamSilenceDurationMs,
|
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ export class OpusDecoder {
|
|||||||
options.createDecoder ??
|
options.createDecoder ??
|
||||||
(() =>
|
(() =>
|
||||||
new prism.opus.Decoder({
|
new prism.opus.Decoder({
|
||||||
frameSize: config.opusFrameSize,
|
frameSize: config.OPUS_FRAME_SIZE,
|
||||||
channels: config.audioChannels as 1 | 2,
|
channels: config.AUDIO_CHANNELS as 1 | 2,
|
||||||
rate: config.audioSampleRate as 8000 | 12000 | 16000 | 24000 | 48000,
|
rate: config.AUDIO_SAMPLE_RATE as
|
||||||
|
| 8000
|
||||||
|
| 12000
|
||||||
|
| 16000
|
||||||
|
| 24000
|
||||||
|
| 48000,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function collectUserMetadata(
|
|||||||
avatarUrl:
|
avatarUrl:
|
||||||
user?.displayAvatarURL({
|
user?.displayAvatarURL({
|
||||||
format: "png",
|
format: "png",
|
||||||
size: config.avatarSize as
|
size: config.AVATAR_SIZE as
|
||||||
| 16
|
| 16
|
||||||
| 32
|
| 32
|
||||||
| 64
|
| 64
|
||||||
|
|||||||
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,12 +1,14 @@
|
|||||||
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 { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
import { createChildLogger } from "./logger";
|
|
||||||
|
|
||||||
const logger = createChildLogger("webserver");
|
const wsLogger = createChildLogger("webserver");
|
||||||
|
|
||||||
const activeUsers = new Map<
|
const activeUsers = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -45,10 +47,27 @@ 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" });
|
||||||
logger.info({ wsPort }, "WebSocket server listening");
|
wsLogger.info({ wsPort }, "WebSocket server listening");
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// HTTP request logging
|
||||||
|
app.use(pinoHttp({ logger }));
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
activeUsers: activeUsers.size,
|
||||||
|
wsClients: wsClients.size,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Inbound: Discord PCM → tagged chunks → browser
|
// 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;
|
||||||
@@ -125,10 +144,7 @@ export function startWebserver(port: number = 3000) {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (dbCount > 0) {
|
if (dbCount > 0) {
|
||||||
const avg = dbAccum / dbCount;
|
const avg = dbAccum / dbCount;
|
||||||
logger.info(
|
wsLogger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level");
|
||||||
{ level: avg.toFixed(1), frames: dbCount },
|
|
||||||
"Audio level",
|
|
||||||
);
|
|
||||||
dbAccum = 0;
|
dbAccum = 0;
|
||||||
dbCount = 0;
|
dbCount = 0;
|
||||||
}
|
}
|
||||||
@@ -152,7 +168,7 @@ export function startWebserver(port: number = 3000) {
|
|||||||
if (playerPaused) {
|
if (playerPaused) {
|
||||||
discordPlayer.unpause();
|
discordPlayer.unpause();
|
||||||
playerPaused = false;
|
playerPaused = false;
|
||||||
logger.info("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
|
||||||
@@ -161,7 +177,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;
|
||||||
logger.info("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
|
||||||
@@ -175,7 +191,7 @@ export function startWebserver(port: number = 3000) {
|
|||||||
}, 20);
|
}, 20);
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws) => {
|
||||||
logger.info({ wsPort }, "New WebSocket connection");
|
wsLogger.info({ wsPort }, "New WebSocket connection");
|
||||||
wsClients.add(ws);
|
wsClients.add(ws);
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -210,6 +226,6 @@ export function startWebserver(port: number = 3000) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
server.listen(port, "0.0.0.0", () => {
|
||||||
logger.info({ port }, "Web interface listening");
|
wsLogger.info({ port }, "Web interface listening");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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