From e7e80e704bc84e884d105f6472762f2e0f6eaf13 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 9 Sep 2026 05:41:20 +0300 Subject: [PATCH] Tidy chat output and pick search results by reaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search reply carried ten markdown links, and Stoat expands every link into a full-size player — the result was a wall of embeds burying the list. Stoat's embed generator skips links inside code spans or angle brackets, so links now go out quietly, and list lines carry no links at all: title, artist, length, source. Only `!nowplaying` keeps a preview, where it was asked for. Choosing a track no longer needs a second command: the results message gets 1️⃣–5️⃣ reactions and picking one queues the track, with `!pick` still there when reactions fail or five results are not enough. Only the person who searched can pick, so a list cannot be hijacked. Also `!playvideo` to turn video on and queue a track in one go, and setVideo no longer requires a running player — the switch is a setting, and demanding a player made it fail on a server nothing had played on. Co-Authored-By: Claude Opus 5 --- README.md | 9 +- src/bot/commands.ts | 873 ++++++++++++++++++++++++-------------------- src/bot/format.ts | 141 ++++--- src/bot/index.ts | 38 +- src/core/manager.ts | 10 +- 5 files changed, 612 insertions(+), 459 deletions(-) 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 {