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
+366
View File
@@ -0,0 +1,366 @@
import { signPanelLink } from "../auth/tokens.js";
import { config } from "../config.js";
import type { MusicManager } from "../core/manager.js";
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
export interface CommandContext {
manager: MusicManager;
serverId: string;
channelId: string;
actor: Requester;
/** Raw text after the command name. */
rest: string;
args: string[];
reply(content: string): Promise<void>;
}
export interface Command {
name: string;
aliases: string[];
usage: string;
description: string;
run(ctx: CommandContext): Promise<void>;
}
const SEARCH_TTL_MS = 5 * 60_000;
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
tracks,
expiresAt: Date.now() + SEARCH_TTL_MS,
});
}
function recallSearch(ctx: CommandContext): Track[] | null {
const key = `${ctx.channelId}:${ctx.actor.id}`;
const entry = searchSessions.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
searchSessions.delete(key);
return null;
}
return entry.tracks;
}
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
mode,
textChannelId: ctx.channelId,
});
if (outcome.playlist) {
await ctx.reply(
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
);
return;
}
const track = outcome.tracks[0];
if (!track) return;
if (outcome.startedNow || mode === "now") {
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
} else {
await ctx.reply(` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
}
}
export const commands: Command[] = [
{
name: "play",
aliases: ["p", "играй"],
usage: "play <ссылка или название>",
description: "Добавить трек или плейлист в очередь",
run: (ctx) => playCommand(ctx, "append"),
},
{
name: "playnext",
aliases: ["pn", "next"],
usage: "playnext <ссылка или название>",
description: "Поставить трек следующим в очереди",
run: (ctx) => playCommand(ctx, "next"),
},
{
name: "playnow",
aliases: ["now"],
usage: "playnow <ссылка или название>",
description: "Включить трек немедленно",
run: (ctx) => playCommand(ctx, "now"),
},
{
name: "search",
aliases: ["s", "найди"],
usage: "search <запрос>",
description: "Найти треки и выбрать нужный командой pick",
async run(ctx) {
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
if (tracks.length === 0) {
await ctx.reply("Ничего не найдено.");
return;
}
rememberSearch(ctx, tracks);
const lines = tracks.map((track, index) => trackLine(track, index + 1));
await ctx.reply(
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
);
},
},
{
name: "pick",
aliases: ["выбрать"],
usage: "pick <номер>",
description: "Добавить трек из результатов поиска",
async run(ctx) {
const tracks = recallSearch(ctx);
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const track = tracks[index - 1];
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
textChannelId: ctx.channelId,
});
await ctx.reply(
outcome.startedNow
? `▶️ Играю: ${trackLine(track)}`
: ` В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
);
},
},
{
name: "skip",
aliases: ["sk", "пропусти"],
usage: "skip [количество]",
description: "Пропустить текущий трек",
async run(ctx) {
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
},
},
{
name: "stop",
aliases: ["стоп"],
usage: "stop",
description: "Остановить воспроизведение и очистить очередь",
async run(ctx) {
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
await ctx.reply("⏹️ Остановлено, очередь очищена.");
},
},
{
name: "pause",
aliases: ["пауза"],
usage: "pause",
description: "Поставить на паузу / снять с паузы",
async run(ctx) {
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
},
},
{
name: "resume",
aliases: ["продолжи"],
usage: "resume",
description: "Продолжить воспроизведение",
async run(ctx) {
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
await ctx.reply("▶️ Продолжаю.");
},
},
{
name: "queue",
aliases: ["q", "очередь"],
usage: "queue [страница]",
description: "Показать очередь",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current && snapshot.queue.length === 0) {
await ctx.reply("Очередь пуста.");
return;
}
const pageSize = 10;
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
const parts: string[] = [];
if (snapshot.current) {
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
parts.push(progressBar(snapshot.position, snapshot.current.duration));
}
if (slice.length > 0) {
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
}
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
parts.push(
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
);
await ctx.reply(parts.join("\n\n"));
},
},
{
name: "nowplaying",
aliases: ["np", "сейчас"],
usage: "nowplaying",
description: "Показать текущий трек",
async run(ctx) {
const snapshot = ctx.manager.snapshot(ctx.serverId);
if (!snapshot.current) {
await ctx.reply("Сейчас ничего не играет.");
return;
}
await ctx.reply(
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
);
},
},
{
name: "volume",
aliases: ["vol", "громкость"],
usage: "volume [0-200]",
description: "Показать или изменить громкость",
async run(ctx) {
if (ctx.args.length === 0) {
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
return;
}
const value = Number.parseInt(ctx.args[0] ?? "", 10);
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
},
},
{
name: "loop",
aliases: ["repeat", "повтор"],
usage: "loop [off|track|queue]",
description: "Режим повтора",
async run(ctx) {
const raw = (ctx.args[0] ?? "").toLowerCase();
const map: Record<string, LoopMode> = {
off: "off",
выкл: "off",
track: "track",
трек: "track",
one: "track",
queue: "queue",
очередь: "queue",
all: "queue",
};
const current = ctx.manager.snapshot(ctx.serverId).loop;
const nextMode =
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
},
},
{
name: "shuffle",
aliases: ["sh", "перемешай"],
usage: "shuffle",
description: "Перемешать очередь",
async run(ctx) {
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
await ctx.reply("🔀 Очередь перемешана.");
},
},
{
name: "remove",
aliases: ["rm", "удали"],
usage: "remove <номер>",
description: "Убрать трек из очереди",
async run(ctx) {
const index = Number.parseInt(ctx.args[0] ?? "", 10);
const snapshot = ctx.manager.snapshot(ctx.serverId);
const track = snapshot.queue[index - 1];
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
},
},
{
name: "clear",
aliases: ["очисти"],
usage: "clear",
description: "Очистить очередь, не останавливая текущий трек",
async run(ctx) {
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
await ctx.reply("🧹 Очередь очищена.");
},
},
{
name: "seek",
aliases: ["перемотай"],
usage: "seek <мм:сс>",
description: "Перемотать текущий трек",
async run(ctx) {
const seconds = parseTimecode(ctx.rest);
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
},
},
{
name: "join",
aliases: ["зайди"],
usage: "join",
description: "Позвать бота в ваш голосовой канал",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
textChannelId: ctx.channelId,
});
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
},
},
{
name: "leave",
aliases: ["dc", "выйди"],
usage: "leave",
description: "Выйти из голосового канала",
async run(ctx) {
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
await ctx.reply("👋 Вышел из голосового канала.");
},
},
{
name: "panel",
aliases: ["ui", "панель"],
usage: "panel",
description: "Получить ссылку на веб-панель",
async run(ctx) {
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
await ctx.reply(
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
);
},
},
{
name: "help",
aliases: ["h", "помощь"],
usage: "help",
description: "Показать список команд",
async run(ctx) {
const lines = commands.map(
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\`${command.description}`,
);
await ctx.reply(
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
);
},
},
];
const lookup = new Map<string, Command>();
for (const command of commands) {
lookup.set(command.name, command);
for (const alias of command.aliases) lookup.set(alias, command);
}
export function findCommand(name: string): Command | undefined {
return lookup.get(name.toLowerCase());
}
+120
View File
@@ -0,0 +1,120 @@
import type { Client } from "stoat.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { ServerRef, StoatContext, VoiceChannelRef } from "../core/manager.js";
import { fetchMember } from "../stoat/rest.js";
const log = logger.child({ mod: "bot-context" });
const MEMBERSHIP_TTL_MS = 30_000;
interface MembershipInfo {
isMember: boolean;
roles: string[];
at: number;
}
/** Implements the core's view of Stoat on top of the live bot client. */
export class BotStoatContext implements StoatContext {
private readonly membershipCache = new Map<string, MembershipInfo>();
constructor(private readonly client: Client) {}
getServerName(serverId: string): string | null {
return this.client.servers.get(serverId)?.name ?? null;
}
getVoiceChannel(channelId: string): VoiceChannelRef | null {
const channel = this.client.channels.get(channelId);
if (!channel?.isVoice) return null;
return { id: channel.id, name: channel.name };
}
listVoiceChannels(serverId: string): VoiceChannelRef[] {
const server = this.client.servers.get(serverId);
if (!server) return [];
return server.channels
.filter((channel) => channel.isVoice)
.map((channel) => ({ id: channel.id, name: channel.name }));
}
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null {
const server = this.client.servers.get(serverId);
if (!server) return null;
for (const channel of server.channels) {
if (!channel.isVoice) continue;
if (channel.voiceParticipants.has(userId)) {
return { id: channel.id, name: channel.name };
}
}
return null;
}
/** Servers where both the bot and the given user are members. */
async listServersForUser(userId: string): Promise<ServerRef[]> {
const servers = [...this.client.servers.values()];
const checks = await Promise.all(
servers.map(async (server) => {
const info = await this.membership(server.id, userId);
if (!info.isMember) return null;
return {
id: server.id,
name: server.name,
iconUrl: server.icon?.createFileURL() ?? null,
} satisfies ServerRef;
}),
);
return checks.filter((entry): entry is ServerRef => entry !== null);
}
async isMember(serverId: string, userId: string): Promise<boolean> {
return (await this.membership(serverId, userId)).isMember;
}
async canControl(serverId: string, userId: string): Promise<boolean> {
const info = await this.membership(serverId, userId);
if (!info.isMember) return false;
if (!config.REQUIRE_DJ_ROLE) return true;
const server = this.client.servers.get(serverId);
if (!server) return false;
if (server.owner?.id === userId) return true;
const member = server.getMember(userId);
if (member?.hasPermission(server, "ManageServer")) return true;
const djRole = [...server.roles.entries()].find(
([, role]) => role.name.toLowerCase() === config.DJ_ROLE_NAME.toLowerCase(),
);
if (!djRole) return false;
return info.roles.includes(djRole[0]);
}
private async membership(serverId: string, userId: string): Promise<MembershipInfo> {
const key = `${serverId}:${userId}`;
const cached = this.membershipCache.get(key);
if (cached && Date.now() - cached.at < MEMBERSHIP_TTL_MS) return cached;
const cachedMember = this.client.servers.get(serverId)?.getMember(userId);
if (cachedMember) {
const info: MembershipInfo = { isMember: true, roles: cachedMember.roles ?? [], at: Date.now() };
this.membershipCache.set(key, info);
return info;
}
let info: MembershipInfo = { isMember: false, roles: [], at: Date.now() };
try {
const member = await fetchMember(serverId, userId);
if (member) info = { isMember: true, roles: member.roles ?? [], at: Date.now() };
} catch (err) {
log.warn({ err, serverId, userId }, "membership lookup failed");
}
this.membershipCache.set(key, info);
return info;
}
async sendMessage(channelId: string, content: string): Promise<void> {
const channel = this.client.channels.get(channelId);
if (!channel) return;
await channel.sendMessage(content);
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { LoopMode, Track } from "../types.js";
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
export function parseTimecode(input: string): number | null {
const trimmed = input.trim();
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
if (!match) return null;
const [, hours, minutes, seconds] = match;
return (
Number.parseInt(hours ?? "0", 10) * 3600 +
Number.parseInt(minutes ?? "0", 10) * 60 +
Number.parseInt(seconds ?? "0", 10)
);
}
export function progressBar(position: number, duration: number, width = 22): string {
if (duration <= 0) return "🔴 прямой эфир";
const ratio = Math.min(1, Math.max(0, position / duration));
const filled = Math.round(ratio * (width - 1));
const bar = `${"─".repeat(filled)}${"─".repeat(Math.max(0, width - 1 - filled))}`;
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
}
const SOURCE_LABEL: Record<Track["source"], string> = {
youtube: "YouTube",
soundcloud: "SoundCloud",
direct: "Ссылка",
local: "Медиатека",
};
export function trackLine(track: Track, index?: number): string {
const prefix = index === undefined ? "" : `**${index}.** `;
const author = track.author ? `${track.author}` : "";
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
}
export function loopLabel(mode: LoopMode): string {
if (mode === "track") return "трек";
if (mode === "queue") return "очередь";
return "выключен";
}
+94
View File
@@ -0,0 +1,94 @@
import { Client, type Message } from "stoat.js";
import { config } from "../config.js";
import { logger } from "../logger.js";
import type { MusicManager } from "../core/manager.js";
import { UserFacingError } from "../types.js";
import { findCommand, type CommandContext } from "./commands.js";
import { BotStoatContext } from "./context.js";
const log = logger.child({ mod: "bot" });
export interface Bot {
client: Client;
context: BotStoatContext;
stop(): Promise<void>;
}
export async function startBot(manager: MusicManager): Promise<Bot> {
const client = new Client({ baseURL: config.STOAT_API_URL });
const context = new BotStoatContext(client);
manager.attachStoat(context);
client.on("ready", () => {
log.info({ user: client.user?.username }, "bot is ready");
});
client.on("error", (error) => {
log.error({ err: error }, "client error");
});
client.on("disconnected", () => log.warn("gateway disconnected"));
client.on("messageCreate", (message) => {
void handleMessage(manager, message).catch((err) => {
log.error({ err }, "unhandled command failure");
});
});
await client.loginBot(config.STOAT_BOT_TOKEN);
return {
client,
context,
async stop() {
await manager.destroyAll();
},
};
}
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
const content = message.content?.trim();
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
if (!message.authorId || message.author?.bot) return;
const serverId = message.server?.id;
const reply = async (text: string) => {
await message.channel?.sendMessage(text);
};
if (!serverId) {
await reply("Команды работают только внутри сервера.");
return;
}
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
const [rawName, ...args] = withoutPrefix.split(/\s+/);
if (!rawName) return;
const command = findCommand(rawName);
if (!command) return;
const ctx: CommandContext = {
manager,
serverId,
channelId: message.channelId,
actor: {
id: message.authorId,
username: message.member?.nickname || message.author?.username || "user",
},
args,
rest: withoutPrefix.slice(rawName.length).trim(),
reply,
};
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
try {
await command.run(ctx);
} catch (err) {
if (err instanceof UserFacingError) {
await reply(`⚠️ ${err.message}`);
return;
}
log.error({ err, command: command.name }, "command failed");
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
}
}