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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user