import { signPanelLink } from "../auth/tokens.js"; import { config } from "../config.js"; import type { MusicManager } from "../core/manager.js"; import { NOTHING_FOUND } from "../sources/index.js"; import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js"; import { formatDuration, loopLabel, parseTimecode, progressBar, quietLink, trackLine, trackTitle, } from "./format.js"; /** What we need back from a sent message to work with it afterwards. */ export interface SentMessage { id: string; react(emoji: string): Promise; edit(content: string): Promise; } 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; } export interface Command { name: string; aliases: string[]; usage: string; description: string; run(ctx: CommandContext): Promise; } /** Hidden from help when the bot was started without video support. */ const VIDEO_COMMANDS = new Set(["video", "playvideo"]); /** Picking by reaction only works while the list is short enough to read. */ export const CHOICE_EMOJI = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; const CHAT_SEARCH_LIMIT = CHOICE_EMOJI.length; const SEARCH_TTL_MS = 5 * 60_000; interface SearchSession { tracks: Track[]; userId: string; channelId: string; expiresAt: number; } const sessionsByMessage = new Map(); const latestByUser = new Map(); function rememberSearch(ctx: CommandContext, messageId: string, tracks: Track[]): void { for (const [id, session] of sessionsByMessage) { if (session.expiresAt < Date.now()) sessionsByMessage.delete(id); } sessionsByMessage.set(messageId, { tracks, userId: ctx.actor.id, channelId: ctx.channelId, expiresAt: Date.now() + SEARCH_TTL_MS, }); latestByUser.set(`${ctx.channelId}:${ctx.actor.id}`, messageId); } function activeSession(messageId: string | undefined): SearchSession | null { if (!messageId) return null; const session = sessionsByMessage.get(messageId); if (!session) return null; if (session.expiresAt < Date.now()) { sessionsByMessage.delete(messageId); return null; } return session; } /** * Resolves a reaction on a results message into the track it stands for, and * spends the session: the list is answered once, so a second reaction on the * same message does nothing and the message itself can go away. */ export function trackForReaction(messageId: string, userId: string, emoji: string): Track | null { const session = activeSession(messageId); // Only the person who searched picks; otherwise anyone could hijack the list. if (!session || session.userId !== userId) return null; const index = CHOICE_EMOJI.indexOf(emoji); const track = index === -1 ? null : (session.tracks[index] ?? null); if (track) { sessionsByMessage.delete(messageId); latestByUser.delete(`${session.channelId}:${session.userId}`); } return track; } function added(track: Track, startedNow: boolean, position: number): string { return startedNow ? `▶️ ${trackTitle(track)}` : `➕ ${trackTitle(track)} · в очереди #${position}`; } export async function enqueueChoice( manager: MusicManager, serverId: string, channelId: string, actor: Requester, track: Track, ): Promise { const outcome = await manager.enqueueTracks(serverId, actor, [track], { textChannelId: channelId, }); return added(track, outcome.startedNow, outcome.queuePosition); } async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise { 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) { const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS; await ctx.reply( `📥 Плейлист **${outcome.playlist.title}** — ${outcome.tracks.length} треков` + (capped ? ` (предел на один плейлист, MAX_PLAYLIST_TRACKS).` : "."), ); return; } const track = outcome.tracks[0]; if (!track) return; await ctx.reply(added(track, outcome.startedNow || mode === "now", outcome.queuePosition)); } 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: "playvideo", aliases: ["pv", "клип"], usage: "playvideo <ссылка или название>", description: "Включить видео и добавить трек", async run(ctx) { if (!config.VIDEO_ENABLED) { throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)"); } await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, true); await playCommand(ctx, "append"); }, }, { name: "search", aliases: ["s", "найди"], usage: "search <запрос>", description: "Найти треки и выбрать реакцией", async run(ctx) { if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос"); // Searching several sources can take tens of seconds through a proxy, and // the command message is already deleted by then — without a placeholder // the chat sits silent and the bot looks broken. const placeholder = await ctx.reply(`🔎 Ищу **${ctx.rest}**…`); const tracks = (await ctx.manager.search(ctx.rest, ctx.actor, CHAT_SEARCH_LIMIT)).slice( 0, CHAT_SEARCH_LIMIT, ); if (tracks.length === 0) { const missing = `🔎 ${NOTHING_FOUND}`; if (placeholder) await placeholder.edit(missing); else await ctx.reply(missing); return; } const lines = tracks.map((track, index) => trackLine(track, { index: index + 1 })); const text = `🔎 **${ctx.rest}**\n${lines.join("\n")}`; const sent = placeholder ?? (await ctx.reply(text)); if (!sent) return; if (placeholder) await placeholder.edit(text); rememberSearch(ctx, sent.id, tracks); for (const emoji of CHOICE_EMOJI.slice(0, tracks.length)) { // Reactions are a convenience; `pick` still works if they fail. await sent.react(emoji).catch(() => {}); } }, }, { name: "pick", aliases: ["выбрать"], usage: "pick <номер>", description: "Выбрать трек из результатов поиска", async run(ctx) { const session = activeSession(latestByUser.get(`${ctx.channelId}:${ctx.actor.id}`)); if (!session) throw new UserFacingError("Сначала выполните поиск"); const index = Number.parseInt(ctx.args[0] ?? "", 10); const track = session.tracks[index - 1]; if (!track) throw new UserFacingError(`Укажите номер от 1 до ${session.tracks.length}`); await ctx.reply( await enqueueChoice(ctx.manager, ctx.serverId, ctx.channelId, ctx.actor, 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 ? `⏭️ ${trackTitle(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 lines: string[] = []; if (snapshot.current) { lines.push(`▶️ ${trackTitle(snapshot.current)}`); lines.push(progressBar(snapshot.position, snapshot.current.duration)); } if (slice.length > 0) { lines.push(""); for (const [index, track] of slice.entries()) { lines.push(trackLine(track, { index: (page - 1) * pageSize + index + 1, requester: true })); } } const total = snapshot.queue.reduce((acc, track) => acc + track.duration, 0); const footer = [ `в очереди ${snapshot.queue.length}`, formatDuration(total), `повтор: ${loopLabel(snapshot.loop)}`, `громкость ${snapshot.volume}%`, ]; if (pages > 1) footer.unshift(`стр. ${page}/${pages}`); lines.push("", `-# ${footer.join(" · ")}`); await ctx.reply(lines.join("\n")); }, }, { name: "nowplaying", aliases: ["np", "сейчас"], usage: "nowplaying", description: "Что играет сейчас", async run(ctx) { const snapshot = ctx.manager.snapshot(ctx.serverId); const track = snapshot.current; if (!track) { await ctx.reply("Сейчас ничего не играет."); return; } // The only place a preview is welcome: it was asked for explicitly. const link = /^https?:/i.test(track.url) ? `\n${track.url}` : ""; await ctx.reply( `🎵 ${trackTitle(track)}\n${progressBar(snapshot.position, track.duration)}${link}`, ); }, }, { name: "volume", aliases: ["vol", "громкость"], usage: "volume [0-200]", description: "Показать или изменить громкость", async run(ctx) { if (ctx.args.length > 0) { 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 = { 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: "video", aliases: ["видео"], usage: "video [on|off]", description: "Показывать клип вместе со звуком", async run(ctx) { if (!config.VIDEO_ENABLED) { throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)"); } const raw = (ctx.args[0] ?? "").toLowerCase(); const current = ctx.manager.snapshot(ctx.serverId).videoEnabled; const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current; await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next); await ctx.reply( next ? "📺 Видео включено — со следующего трека." : "🔇 Видео выключено, играю звук.", ); }, }, { 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 track = ctx.manager.snapshot(ctx.serverId).queue[index - 1]; if (!track) throw new UserFacingError("Укажите номер трека из очереди"); await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id); await ctx.reply(`🗑️ Убрано: ${trackTitle(track)}`); }, }, { 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( `🎛️ ${quietLink(`${config.PUBLIC_URL}/login?token=${token}`)}\n-# ссылка личная, действует 10 минут`, ); }, }, { name: "help", aliases: ["h", "помощь"], usage: "help", description: "Список команд", async run(ctx) { const lines = commands .filter((command) => !VIDEO_COMMANDS.has(command.name) || config.VIDEO_ENABLED) .map((command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`); const sources = ["YouTube", "SoundCloud", "прямые ссылки и радио"]; if (config.LOCAL_MEDIA_DIR) sources.push("локальная медиатека"); const prefixes = ["`yt:`", "`sc:`"]; if (config.LOCAL_MEDIA_DIR) prefixes.push("`local:`"); await ctx.reply( [ "**Команды**", ...lines, "", `-# источники: ${sources.join(", ")} · префиксы поиска: ${prefixes.join(", ")}`, ].join("\n"), ); }, }, ]; const lookup = new Map(); 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()); }