import { readFileSync, existsSync } from "node:fs"; import { z } from "zod"; // Minimal .env loader so we don't need an extra dependency. function loadDotEnv(path = ".env"): void { if (!existsSync(path)) return; for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq === -1) continue; const key = line.slice(0, eq).trim(); let value = line.slice(eq + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (process.env[key] === undefined) process.env[key] = value; } } loadDotEnv(); const schema = z.object({ STOAT_API_URL: z.string().url(), STOAT_BOT_TOKEN: z.string().min(1), COMMAND_PREFIX: z.string().min(1).default("!"), /** LiveKit node name from Revolt.toml ([hosts.livekit]); self-hosted default is "worldwide". */ VOICE_NODE: z.string().min(1).default("worldwide"), /** Remove the invoking message after a command is recognised. Needs ManageMessages. */ DELETE_COMMAND_MESSAGES: z .enum(["true", "false"]) .default("true") .transform((value) => value === "true"), PORT: z.coerce.number().int().positive().default(3005), HOST: z.string().default("0.0.0.0"), PUBLIC_URL: z.string().url(), JWT_SECRET: z.string().min(16), SESSION_TTL_HOURS: z.coerce.number().positive().default(168), YTDLP_PATH: z.string().default("yt-dlp"), YTDLP_COOKIES: z.string().optional(), /** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */ YTDLP_EXTRACTOR_ARGS: z.string().optional(), LOCAL_MEDIA_DIR: z.string().optional(), DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60), MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500), /** Upper bound on how many tracks one pasted playlist may add. */ MAX_PLAYLIST_TRACKS: z.coerce.number().int().positive().default(100), SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10), /** Seconds to wait after the last human leaves the voice channel (0 — never leave). */ EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120), DJ_ROLE_NAME: z.string().default("DJ"), /** Only let people summon the bot into the voice channel they are sitting in. */ REQUIRE_LISTENER: z .enum(["true", "false"]) .default("true") .transform((value) => value === "true"), REQUIRE_DJ_ROLE: z .enum(["true", "false"]) .default("false") .transform((value) => value === "true"), LOG_LEVEL: z.string().default("info"), NODE_ENV: z.string().default("development"), }); const parsed = schema.safeParse(process.env); if (!parsed.success) { const issues = parsed.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) .join("\n"); console.error(`Invalid configuration, check your .env file:\n${issues}`); process.exit(1); } export const config = { ...parsed.data, STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""), PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""), isProduction: parsed.data.NODE_ENV === "production", }; export type Config = typeof config;