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