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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user