Files
stoat-mbot/src/config.ts
T
Leonid PershinandClaude Opus 5 a9b7ccdd16 Add music bot for self-hosted Stoat with web control panel
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>
2026-09-08 23:12:43 +03:00

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;