Two bits of clutter visible in one screenshot: the results list stayed in the channel after a track was chosen, still wearing its reactions, and every track produced two lines — the command's own reply and the player's "now playing" notice. Picking now spends the session and deletes the list, so a second reaction does nothing and the message goes away. The player announces a track only when it started one by itself; when a command started it, that command has already said so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
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<void>;
|
||
edit(content: string): Promise<void>;
|
||
}
|
||
|
||
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<SentMessage | undefined>;
|
||
}
|
||
|
||
export interface Command {
|
||
name: string;
|
||
aliases: string[];
|
||
usage: string;
|
||
description: string;
|
||
run(ctx: CommandContext): Promise<void>;
|
||
}
|
||
|
||
/** 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<string, SearchSession>();
|
||
const latestByUser = new Map<string, string>();
|
||
|
||
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<string> {
|
||
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<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) {
|
||
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<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: "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<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());
|
||
}
|