Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
73 lines
2.3 KiB
TypeScript
73 lines
2.3 KiB
TypeScript
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("!"),
|
|
|
|
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(),
|
|
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),
|
|
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
|
IDLE_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(300),
|
|
DJ_ROLE_NAME: z.string().default("DJ"),
|
|
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;
|