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:
Leonid Pershin
2026-09-08 23:12:43 +03:00
co-authored by Claude Opus 5
parent d9d0e9f6bf
commit a9b7ccdd16
43 changed files with 12436 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import fastifyStatic from "@fastify/static";
import websocket from "@fastify/websocket";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { z } from "zod";
import { signSession, verifyToken } from "../auth/tokens.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js";
import type { BotStoatContext } from "../bot/context.js";
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
import { UserFacingError, type Track } from "../types.js";
const log = logger.child({ mod: "api" });
const COOKIE_NAME = "mbot_session";
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
const SEARCH_CACHE_LIMIT = 5000;
/**
* The panel never sends whole track objects back to us — it sends ids that were
* produced by a server-side search. That keeps clients from pointing playback at
* arbitrary local paths or internal URLs.
*/
const searchCache = new Map<string, { track: Track; at: number }>();
function cacheTracks(tracks: Track[]): void {
const now = Date.now();
for (const track of tracks) searchCache.set(track.id, { track, at: now });
if (searchCache.size > SEARCH_CACHE_LIMIT) {
for (const [id, entry] of searchCache) {
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
}
}
}
function takeTracks(ids: string[]): Track[] {
const tracks: Track[] = [];
for (const id of ids) {
const entry = searchCache.get(id);
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
tracks.push(entry.track);
}
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
return tracks;
}
interface Session {
userId: string;
username: string;
}
declare module "fastify" {
interface FastifyRequest {
session?: Session;
}
}
export interface ApiServerOptions {
manager: MusicManager;
context: BotStoatContext;
}
export async function startApiServer({ manager, context }: ApiServerOptions) {
const app = Fastify({ logger: false, trustProxy: true });
await app.register(cookie);
await app.register(websocket);
const secureCookies = config.PUBLIC_URL.startsWith("https://");
function setSessionCookie(reply: FastifyReply, token: string): void {
reply.setCookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: secureCookies,
path: "/",
maxAge: config.SESSION_TTL_HOURS * 3600,
});
}
async function readSession(request: FastifyRequest): Promise<Session | null> {
const token = request.cookies[COOKIE_NAME];
if (!token) return null;
const claims = await verifyToken(token);
if (!claims || claims.typ !== "session") return null;
return { userId: claims.sub, username: claims.username };
}
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
const session = await readSession(request);
if (!session) {
await reply.code(401).send({ error: "Не авторизован" });
return null;
}
request.session = session;
return session;
}
/** Membership is re-checked on every request: roles change in Stoat, not here. */
async function requireServerAccess(
request: FastifyRequest,
reply: FastifyReply,
serverId: string,
): Promise<Session | null> {
const session = await requireSession(request, reply);
if (!session) return null;
if (!(await context.isMember(serverId, session.userId))) {
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
return null;
}
return session;
}
app.setErrorHandler((error, _request, reply) => {
if (error instanceof UserFacingError) {
void reply.code(400).send({ error: error.message });
return;
}
if ((error as { validation?: unknown }).validation) {
void reply.code(400).send({ error: "Некорректный запрос" });
return;
}
log.error({ err: error }, "request failed");
void reply.code(500).send({ error: "Внутренняя ошибка" });
});
// ------------------------------------------------------------------ auth ---
const loginSchema = z.object({
email: z.string().min(1).optional(),
password: z.string().min(1).optional(),
mfaTicket: z.string().optional(),
totpCode: z.string().optional(),
recoveryCode: z.string().optional(),
});
app.post("/api/auth/login", async (request, reply) => {
const body = loginSchema.parse(request.body ?? {});
const mfa = body.mfaTicket
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
: undefined;
if (!mfa && (!body.email || !body.password)) {
throw new UserFacingError("Укажите e-mail и пароль");
}
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
if (result.kind === "mfa") {
return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods });
}
// We only needed the session to prove who the user is; drop it immediately.
const profile = await fetchSelf(result.token).catch(() => null);
await revokeSession(result.token);
const username = profile?.display_name || profile?.username || "user";
const token = await signSession(result.userId, username);
setSessionCookie(reply, token);
return reply.send({ user: { id: result.userId, username } });
});
app.post("/api/auth/link", async (request, reply) => {
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
const claims = await verifyToken(body.token);
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
const token = await signSession(claims.sub, claims.username);
setSessionCookie(reply, token);
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
});
app.post("/api/auth/logout", async (_request, reply) => {
reply.clearCookie(COOKIE_NAME, { path: "/" });
return reply.send({ ok: true });
});
app.get("/api/me", async (request, reply) => {
const session = await requireSession(request, reply);
if (!session) return reply;
const servers = await context.listServersForUser(session.userId);
return reply.send({
user: { id: session.userId, username: session.username },
servers,
features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR) },
});
});
// --------------------------------------------------------------- player ---
const serverParams = z.object({ id: z.string().min(1) });
app.get("/api/servers/:id/state", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
return reply.send({
state: manager.snapshot(id),
voiceChannels: context.listVoiceChannels(id),
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
canControl: await context.canControl(id, session.userId),
});
});
app.get("/api/servers/:id/search", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const { q } = z.object({ q: z.string().min(1) }).parse(request.query);
const tracks = await manager.search(q, { id: session.userId, username: session.username });
cacheTracks(tracks);
return reply.send({ tracks });
});
const playSchema = z.object({
query: z.string().min(1).optional(),
trackIds: z.array(z.string()).optional(),
mode: z.enum(["append", "next", "now"]).default("append"),
voiceChannelId: z.string().nullable().optional(),
});
app.post("/api/servers/:id/play", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const body = playSchema.parse(request.body ?? {});
const requester = { id: session.userId, username: session.username };
const outcome = body.trackIds?.length
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
mode: body.mode,
voiceChannelId: body.voiceChannelId ?? null,
})
: await manager.play(id, requester, body.query ?? "", {
mode: body.mode,
voiceChannelId: body.voiceChannelId ?? null,
});
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
});
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
pause: (serverId, userId) => manager.pause(serverId, userId),
resume: (serverId, userId) => manager.resume(serverId, userId),
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
skip: (serverId, userId, body) =>
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
stop: (serverId, userId) => manager.stop(serverId, userId),
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
leave: (serverId, userId) => manager.leave(serverId, userId),
volume: (serverId, userId, body) =>
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
loop: (serverId, userId, body) =>
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
seek: (serverId, userId, body) =>
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
};
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { action } = z.object({ action: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const handler = actions[action];
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
const result = await handler(id, session.userId, request.body);
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
});
app.post("/api/servers/:id/join", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {});
await manager.assertControl(id, session.userId);
await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null });
return reply.send({ ok: true, state: manager.snapshot(id) });
});
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
await manager.remove(id, session.userId, trackId);
return reply.send({ ok: true, state: manager.snapshot(id) });
});
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
const { id } = serverParams.parse(request.params);
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
const session = await requireServerAccess(request, reply, id);
if (!session) return reply;
const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body);
await manager.move(id, session.userId, trackId, index);
return reply.send({ ok: true, state: manager.snapshot(id) });
});
// ----------------------------------------------------------- websockets ---
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
function broadcast(serverId: string, payload: unknown): void {
const listeners = subscribers.get(serverId);
if (!listeners?.size) return;
const message = JSON.stringify(payload);
for (const socket of listeners) {
try {
socket.send(message);
} catch {
listeners.delete(socket);
}
}
}
manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot }));
manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position }));
app.get("/ws", { websocket: true }, (socket, request) => {
void (async () => {
const session = await readSession(request);
const serverId = (request.query as { server?: string }).server;
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
socket.close(4001, "unauthorized");
return;
}
const listeners = subscribers.get(serverId) ?? new Set();
listeners.add(socket);
subscribers.set(serverId, listeners);
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
socket.on("close", () => {
listeners.delete(socket);
if (listeners.size === 0) subscribers.delete(serverId);
});
})();
});
// --------------------------------------------------------------- static ---
const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist");
if (existsSync(webRoot)) {
await app.register(fastifyStatic, { root: webRoot });
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api") || request.url.startsWith("/ws")) {
return reply.code(404).send({ error: "Not found" });
}
return reply.sendFile("index.html");
});
} else {
log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)");
}
await app.listen({ port: config.PORT, host: config.HOST });
log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening");
return app;
}