Files
stoat-mbot/src/bot/commands.ts
T
Leonid PershinandClaude Opus 5 a63db8c957 Clear the search list after a pick, and stop double-announcing tracks
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>
2026-09-09 06:04:17 +03:00

502 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}