diff --git a/README.md b/README.md index 09872f9..cc6f93d 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,13 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f | --- | --- | | `!play <ссылка или название>` | добавить трек/плейлист в очередь | | `!playnext`, `!playnow` | следующим / немедленно | -| `!search <запрос>` → `!pick ` | поиск с выбором из списка | +| `!search <запрос>` | поиск: выбор реакцией 1️⃣–5️⃣ (или `!pick `) | | `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением | | `!queue [страница]`, `!nowplaying` | очередь и текущий трек | | `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок | | `!remove `, `!clear`, `!seek 1:23` | правка очереди и перемотка | -| `!video [on\|off]` | показывать клип как демонстрацию экрана | +| `!video [on\|off]` | показывать клип вместе со звуком | +| `!playvideo <ссылка или название>` | включить видео и добавить трек | | `!join`, `!leave` | зайти в ваш голосовой канал / выйти | | `!panel` | личная ссылка на веб-панель (действует 10 минут) | | `!help` | список команд | @@ -137,6 +138,10 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками, клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история. +Ссылки в ответах бота намеренно оформлены так, чтобы Stoat не разворачивал их в превью: список из +пяти результатов иначе превращается в стену видеоплееров. Единственное исключение — `!nowplaying`, +где превью запрошено явно. + --- ## Если бот не заходит в голосовой канал diff --git a/src/bot/commands.ts b/src/bot/commands.ts index ff6213f..8282d1b 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -1,391 +1,482 @@ -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, 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; -} - -export interface Command { - name: string; - aliases: string[]; - usage: string; - description: string; - run(ctx: CommandContext): Promise; -} - -const SEARCH_TTL_MS = 5 * 60_000; -const searchSessions = new Map(); - -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 { - 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.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url})` + - (capped ? ` — это предел на один плейлист (MAX_PLAYLIST_TRACKS).` : "."), - ); - 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(`🔎 ${NOTHING_FOUND}`); - 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 = { - 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 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 - .filter((command) => command.name !== "video" || config.VIDEO_ENABLED) - .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(); -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()); -} +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 hang reactions on it. */ +export interface SentMessage { + id: string; + react(emoji: 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. */ +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); + return index === -1 ? null : (session.tracks[index] ?? null); +} + +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("Укажите поисковый запрос"); + const tracks = (await ctx.manager.search(ctx.rest, ctx.actor, CHAT_SEARCH_LIMIT)).slice( + 0, + CHAT_SEARCH_LIMIT, + ); + if (tracks.length === 0) { + await ctx.reply(`🔎 ${NOTHING_FOUND}`); + return; + } + + const lines = tracks.map((track, index) => trackLine(track, { index: index + 1 })); + const sent = await ctx.reply(`🔎 **${ctx.rest}**\n${lines.join("\n")}`); + if (!sent) return; + + 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()); +} diff --git a/src/bot/format.ts b/src/bot/format.ts index 45a89a9..443f57b 100644 --- a/src/bot/format.ts +++ b/src/bot/format.ts @@ -1,60 +1,81 @@ -import type { LoopMode, Track } from "../types.js"; - -/** Clock formatting for any position or length; 0 is a legitimate "0:00". */ -export function formatDuration(seconds: number): string { - if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; - 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)}`; -} - -/** Track length as shown to people: live streams and unknown lengths are not clocks. */ -export function formatLength(track: { duration: number; isLive: boolean }): string { - if (track.isLive) return "LIVE"; - if (track.duration <= 0) return "—"; - return formatDuration(track.duration); -} - -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 = { - 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} \`[${formatLength(track)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`; -} - -export function loopLabel(mode: LoopMode): string { - if (mode === "track") return "трек"; - if (mode === "queue") return "очередь"; - return "выключен"; -} +import type { LoopMode, Track } from "../types.js"; + +/** Clock formatting for any position or length; 0 is a legitimate "0:00". */ +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + 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)}`; +} + +/** Track length as shown to people: live streams and unknown lengths are not clocks. */ +export function formatLength(track: { duration: number; isLive: boolean }): string { + if (track.isLive) return "LIVE"; + if (track.duration <= 0) return "—"; + return formatDuration(track.duration); +} + +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)}\``; +} + +/** + * Stoat turns links into full-size previews, so a list of ten results becomes a + * wall of video players. Angle brackets keep a link clickable while telling the + * embed generator to leave it alone — it skips anything matching ``. + */ +export function quietLink(url: string): string { + return /^https?:\/\//i.test(url) ? `<${url}>` : url; +} + +const SOURCE_LABEL: Record = { + youtube: "YouTube", + soundcloud: "SoundCloud", + direct: "ссылка", + local: "медиатека", +}; + +/** Title and artist, without anything that would grow into a preview. */ +export function trackTitle(track: Track): string { + return track.author ? `**${track.title}** — ${track.author}` : `**${track.title}**`; +} + +export interface TrackLineOptions { + /** Numbers the line, for queues and search results. */ + index?: number; + /** Adds who asked for it; worth the width in a queue, noise in search results. */ + requester?: boolean; +} + +export function trackLine(track: Track, options: TrackLineOptions = {}): string { + const number = options.index === undefined ? "" : `\`${options.index}.\` `; + const parts = [`\`${formatLength(track)}\``, SOURCE_LABEL[track.source]]; + if (options.requester) parts.push(track.requestedBy.username); + return `${number}${trackTitle(track)} · ${parts.join(" · ")}`; +} + +export function loopLabel(mode: LoopMode): string { + if (mode === "track") return "трек"; + if (mode === "queue") return "очередь"; + return "выключен"; +} diff --git a/src/bot/index.ts b/src/bot/index.ts index eed606a..332d33f 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -3,7 +3,7 @@ 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 { enqueueChoice, findCommand, trackForReaction, type CommandContext } from "./commands.js"; import { BotStoatContext } from "./context.js"; const log = logger.child({ mod: "bot" }); @@ -33,6 +33,13 @@ export async function startBot(manager: MusicManager): Promise { }); }); + client.on("messageReactionAdd", (message, userId, emoji) => { + if (userId === client.user?.id) return; + void handleChoice(manager, message, userId, emoji).catch((err) => { + log.error({ err }, "unhandled reaction failure"); + }); + }); + await client.loginBot(config.STOAT_BOT_TOKEN); return { @@ -44,15 +51,38 @@ export async function startBot(manager: MusicManager): Promise { }; } +/** Picking a search result by reacting to the bot's list of choices. */ +async function handleChoice( + manager: MusicManager, + message: Message, + userId: string, + emoji: string, +): Promise { + const serverId = message.server?.id; + if (!serverId) return; + const track = trackForReaction(message.id, userId, emoji); + if (!track) return; + + const username = message.server?.getMember(userId)?.nickname ?? track.requestedBy.username; + try { + const text = await enqueueChoice(manager, serverId, message.channelId, { id: userId, username }, track); + await message.channel?.sendMessage(text); + } catch (err) { + if (err instanceof UserFacingError) { + await message.channel?.sendMessage(`⚠️ ${err.message}`); + return; + } + throw err; + } +} + async function handleMessage(manager: MusicManager, message: Message): Promise { 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); - }; + const reply = async (text: string) => message.channel?.sendMessage(text); if (!serverId) { await reply("Команды работают только внутри сервера."); diff --git a/src/core/manager.ts b/src/core/manager.ts index 4876885..9a2d34d 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -299,8 +299,14 @@ export class MusicManager extends EventEmitter { (await this.require(serverId, userId)).setVolume(volume); } - async setVideo(serverId: string, userId: string, enabled: boolean): Promise { - (await this.require(serverId, userId)).setVideo(enabled); + /** + * Unlike the playback controls this works before anything has played: the + * switch is a setting, and demanding a running player would make "play this + * with video" fail on a server the bot has not sung on yet. + */ + async setVideo(serverId: string, userId: string, enabled: boolean): Promise { + await this.assertControl(serverId, userId); + this.getOrCreate(serverId).setVideo(enabled); } async setLoop(serverId: string, userId: string, mode: LoopMode): Promise {