Tidy chat output and pick search results by reaction
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cca374e66
commit
e7e80e704b
@@ -120,12 +120,13 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `!play <ссылка или название>` | добавить трек/плейлист в очередь |
|
| `!play <ссылка или название>` | добавить трек/плейлист в очередь |
|
||||||
| `!playnext`, `!playnow` | следующим / немедленно |
|
| `!playnext`, `!playnow` | следующим / немедленно |
|
||||||
| `!search <запрос>` → `!pick <n>` | поиск с выбором из списка |
|
| `!search <запрос>` | поиск: выбор реакцией 1️⃣–5️⃣ (или `!pick <n>`) |
|
||||||
| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением |
|
| `!skip [n]`, `!stop`, `!pause`, `!resume` | управление воспроизведением |
|
||||||
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
||||||
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
||||||
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
||||||
| `!video [on\|off]` | показывать клип как демонстрацию экрана |
|
| `!video [on\|off]` | показывать клип вместе со звуком |
|
||||||
|
| `!playvideo <ссылка или название>` | включить видео и добавить трек |
|
||||||
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
||||||
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
||||||
| `!help` | список команд |
|
| `!help` | список команд |
|
||||||
@@ -137,6 +138,10 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
В панели: поиск с добавлением в очередь/следующим/сейчас, drag-free перестановка треков стрелками,
|
||||||
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
клик по полосе прогресса — перемотка, слайдер громкости, выбор голосового канала, история.
|
||||||
|
|
||||||
|
Ссылки в ответах бота намеренно оформлены так, чтобы Stoat не разворачивал их в превью: список из
|
||||||
|
пяти результатов иначе превращается в стену видеоплееров. Единственное исключение — `!nowplaying`,
|
||||||
|
где превью запрошено явно.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Если бот не заходит в голосовой канал
|
## Если бот не заходит в голосовой канал
|
||||||
|
|||||||
+482
-391
@@ -1,391 +1,482 @@
|
|||||||
import { signPanelLink } from "../auth/tokens.js";
|
import { signPanelLink } from "../auth/tokens.js";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import type { MusicManager } from "../core/manager.js";
|
import type { MusicManager } from "../core/manager.js";
|
||||||
import { NOTHING_FOUND } from "../sources/index.js";
|
import { NOTHING_FOUND } from "../sources/index.js";
|
||||||
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
|
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
|
||||||
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
|
import {
|
||||||
|
formatDuration,
|
||||||
export interface CommandContext {
|
loopLabel,
|
||||||
manager: MusicManager;
|
parseTimecode,
|
||||||
serverId: string;
|
progressBar,
|
||||||
channelId: string;
|
quietLink,
|
||||||
actor: Requester;
|
trackLine,
|
||||||
/** Raw text after the command name. */
|
trackTitle,
|
||||||
rest: string;
|
} from "./format.js";
|
||||||
args: string[];
|
|
||||||
reply(content: string): Promise<void>;
|
/** What we need back from a sent message to hang reactions on it. */
|
||||||
}
|
export interface SentMessage {
|
||||||
|
id: string;
|
||||||
export interface Command {
|
react(emoji: string): Promise<void>;
|
||||||
name: string;
|
}
|
||||||
aliases: string[];
|
|
||||||
usage: string;
|
export interface CommandContext {
|
||||||
description: string;
|
manager: MusicManager;
|
||||||
run(ctx: CommandContext): Promise<void>;
|
serverId: string;
|
||||||
}
|
channelId: string;
|
||||||
|
actor: Requester;
|
||||||
const SEARCH_TTL_MS = 5 * 60_000;
|
/** Raw text after the command name. */
|
||||||
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
|
rest: string;
|
||||||
|
args: string[];
|
||||||
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
|
reply(content: string): Promise<SentMessage | undefined>;
|
||||||
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
|
}
|
||||||
tracks,
|
|
||||||
expiresAt: Date.now() + SEARCH_TTL_MS,
|
export interface Command {
|
||||||
});
|
name: string;
|
||||||
}
|
aliases: string[];
|
||||||
|
usage: string;
|
||||||
function recallSearch(ctx: CommandContext): Track[] | null {
|
description: string;
|
||||||
const key = `${ctx.channelId}:${ctx.actor.id}`;
|
run(ctx: CommandContext): Promise<void>;
|
||||||
const entry = searchSessions.get(key);
|
}
|
||||||
if (!entry) return null;
|
|
||||||
if (entry.expiresAt < Date.now()) {
|
/** Hidden from help when the bot was started without video support. */
|
||||||
searchSessions.delete(key);
|
const VIDEO_COMMANDS = new Set(["video", "playvideo"]);
|
||||||
return null;
|
|
||||||
}
|
/** Picking by reaction only works while the list is short enough to read. */
|
||||||
return entry.tracks;
|
export const CHOICE_EMOJI = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
|
||||||
}
|
const CHAT_SEARCH_LIMIT = CHOICE_EMOJI.length;
|
||||||
|
const SEARCH_TTL_MS = 5 * 60_000;
|
||||||
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
|
|
||||||
if (!ctx.rest) throw new UserFacingError("Укажите название трека или ссылку");
|
interface SearchSession {
|
||||||
const outcome = await ctx.manager.play(ctx.serverId, ctx.actor, ctx.rest, {
|
tracks: Track[];
|
||||||
mode,
|
userId: string;
|
||||||
textChannelId: ctx.channelId,
|
channelId: string;
|
||||||
});
|
expiresAt: number;
|
||||||
|
}
|
||||||
if (outcome.playlist) {
|
|
||||||
const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS;
|
const sessionsByMessage = new Map<string, SearchSession>();
|
||||||
await ctx.reply(
|
const latestByUser = new Map<string, string>();
|
||||||
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url})` +
|
|
||||||
(capped ? ` — это предел на один плейлист (MAX_PLAYLIST_TRACKS).` : "."),
|
function rememberSearch(ctx: CommandContext, messageId: string, tracks: Track[]): void {
|
||||||
);
|
for (const [id, session] of sessionsByMessage) {
|
||||||
return;
|
if (session.expiresAt < Date.now()) sessionsByMessage.delete(id);
|
||||||
}
|
}
|
||||||
const track = outcome.tracks[0];
|
sessionsByMessage.set(messageId, {
|
||||||
if (!track) return;
|
tracks,
|
||||||
if (outcome.startedNow || mode === "now") {
|
userId: ctx.actor.id,
|
||||||
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
|
channelId: ctx.channelId,
|
||||||
} else {
|
expiresAt: Date.now() + SEARCH_TTL_MS,
|
||||||
await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
|
});
|
||||||
}
|
latestByUser.set(`${ctx.channelId}:${ctx.actor.id}`, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commands: Command[] = [
|
function activeSession(messageId: string | undefined): SearchSession | null {
|
||||||
{
|
if (!messageId) return null;
|
||||||
name: "play",
|
const session = sessionsByMessage.get(messageId);
|
||||||
aliases: ["p", "играй"],
|
if (!session) return null;
|
||||||
usage: "play <ссылка или название>",
|
if (session.expiresAt < Date.now()) {
|
||||||
description: "Добавить трек или плейлист в очередь",
|
sessionsByMessage.delete(messageId);
|
||||||
run: (ctx) => playCommand(ctx, "append"),
|
return null;
|
||||||
},
|
}
|
||||||
{
|
return session;
|
||||||
name: "playnext",
|
}
|
||||||
aliases: ["pn", "next"],
|
|
||||||
usage: "playnext <ссылка или название>",
|
/** Resolves a reaction on a results message into the track it stands for. */
|
||||||
description: "Поставить трек следующим в очереди",
|
export function trackForReaction(messageId: string, userId: string, emoji: string): Track | null {
|
||||||
run: (ctx) => playCommand(ctx, "next"),
|
const session = activeSession(messageId);
|
||||||
},
|
// Only the person who searched picks; otherwise anyone could hijack the list.
|
||||||
{
|
if (!session || session.userId !== userId) return null;
|
||||||
name: "playnow",
|
const index = CHOICE_EMOJI.indexOf(emoji);
|
||||||
aliases: ["now"],
|
return index === -1 ? null : (session.tracks[index] ?? null);
|
||||||
usage: "playnow <ссылка или название>",
|
}
|
||||||
description: "Включить трек немедленно",
|
|
||||||
run: (ctx) => playCommand(ctx, "now"),
|
function added(track: Track, startedNow: boolean, position: number): string {
|
||||||
},
|
return startedNow
|
||||||
{
|
? `▶️ ${trackTitle(track)}`
|
||||||
name: "search",
|
: `➕ ${trackTitle(track)} · в очереди #${position}`;
|
||||||
aliases: ["s", "найди"],
|
}
|
||||||
usage: "search <запрос>",
|
|
||||||
description: "Найти треки и выбрать нужный командой pick",
|
export async function enqueueChoice(
|
||||||
async run(ctx) {
|
manager: MusicManager,
|
||||||
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
serverId: string,
|
||||||
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
|
channelId: string,
|
||||||
if (tracks.length === 0) {
|
actor: Requester,
|
||||||
await ctx.reply(`🔎 ${NOTHING_FOUND}`);
|
track: Track,
|
||||||
return;
|
): Promise<string> {
|
||||||
}
|
const outcome = await manager.enqueueTracks(serverId, actor, [track], {
|
||||||
rememberSearch(ctx, tracks);
|
textChannelId: channelId,
|
||||||
const lines = tracks.map((track, index) => trackLine(track, index + 1));
|
});
|
||||||
await ctx.reply(
|
return added(track, outcome.startedNow, outcome.queuePosition);
|
||||||
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
|
}
|
||||||
);
|
|
||||||
},
|
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, {
|
||||||
name: "pick",
|
mode,
|
||||||
aliases: ["выбрать"],
|
textChannelId: ctx.channelId,
|
||||||
usage: "pick <номер>",
|
});
|
||||||
description: "Добавить трек из результатов поиска",
|
|
||||||
async run(ctx) {
|
if (outcome.playlist) {
|
||||||
const tracks = recallSearch(ctx);
|
const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS;
|
||||||
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
|
await ctx.reply(
|
||||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
`📥 Плейлист **${outcome.playlist.title}** — ${outcome.tracks.length} треков` +
|
||||||
const track = tracks[index - 1];
|
(capped ? ` (предел на один плейлист, MAX_PLAYLIST_TRACKS).` : "."),
|
||||||
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
|
);
|
||||||
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
|
return;
|
||||||
textChannelId: ctx.channelId,
|
}
|
||||||
});
|
const track = outcome.tracks[0];
|
||||||
await ctx.reply(
|
if (!track) return;
|
||||||
outcome.startedNow
|
await ctx.reply(added(track, outcome.startedNow || mode === "now", outcome.queuePosition));
|
||||||
? `▶️ Играю: ${trackLine(track)}`
|
}
|
||||||
: `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
|
|
||||||
);
|
export const commands: Command[] = [
|
||||||
},
|
{
|
||||||
},
|
name: "play",
|
||||||
{
|
aliases: ["p", "играй"],
|
||||||
name: "skip",
|
usage: "play <ссылка или название>",
|
||||||
aliases: ["sk", "пропусти"],
|
description: "Добавить трек или плейлист в очередь",
|
||||||
usage: "skip [количество]",
|
run: (ctx) => playCommand(ctx, "append"),
|
||||||
description: "Пропустить текущий трек",
|
},
|
||||||
async run(ctx) {
|
{
|
||||||
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
|
name: "playnext",
|
||||||
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
|
aliases: ["pn", "next"],
|
||||||
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
|
usage: "playnext <ссылка или название>",
|
||||||
},
|
description: "Поставить трек следующим",
|
||||||
},
|
run: (ctx) => playCommand(ctx, "next"),
|
||||||
{
|
},
|
||||||
name: "stop",
|
{
|
||||||
aliases: ["стоп"],
|
name: "playnow",
|
||||||
usage: "stop",
|
aliases: ["now"],
|
||||||
description: "Остановить воспроизведение и очистить очередь",
|
usage: "playnow <ссылка или название>",
|
||||||
async run(ctx) {
|
description: "Включить трек немедленно",
|
||||||
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
run: (ctx) => playCommand(ctx, "now"),
|
||||||
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
},
|
||||||
},
|
{
|
||||||
},
|
name: "playvideo",
|
||||||
{
|
aliases: ["pv", "клип"],
|
||||||
name: "pause",
|
usage: "playvideo <ссылка или название>",
|
||||||
aliases: ["пауза"],
|
description: "Включить видео и добавить трек",
|
||||||
usage: "pause",
|
async run(ctx) {
|
||||||
description: "Поставить на паузу / снять с паузы",
|
if (!config.VIDEO_ENABLED) {
|
||||||
async run(ctx) {
|
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
|
||||||
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
}
|
||||||
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, true);
|
||||||
},
|
await playCommand(ctx, "append");
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "resume",
|
{
|
||||||
aliases: ["продолжи"],
|
name: "search",
|
||||||
usage: "resume",
|
aliases: ["s", "найди"],
|
||||||
description: "Продолжить воспроизведение",
|
usage: "search <запрос>",
|
||||||
async run(ctx) {
|
description: "Найти треки и выбрать реакцией",
|
||||||
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
|
async run(ctx) {
|
||||||
await ctx.reply("▶️ Продолжаю.");
|
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
||||||
},
|
const tracks = (await ctx.manager.search(ctx.rest, ctx.actor, CHAT_SEARCH_LIMIT)).slice(
|
||||||
},
|
0,
|
||||||
{
|
CHAT_SEARCH_LIMIT,
|
||||||
name: "queue",
|
);
|
||||||
aliases: ["q", "очередь"],
|
if (tracks.length === 0) {
|
||||||
usage: "queue [страница]",
|
await ctx.reply(`🔎 ${NOTHING_FOUND}`);
|
||||||
description: "Показать очередь",
|
return;
|
||||||
async run(ctx) {
|
}
|
||||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
|
||||||
if (!snapshot.current && snapshot.queue.length === 0) {
|
const lines = tracks.map((track, index) => trackLine(track, { index: index + 1 }));
|
||||||
await ctx.reply("Очередь пуста.");
|
const sent = await ctx.reply(`🔎 **${ctx.rest}**\n${lines.join("\n")}`);
|
||||||
return;
|
if (!sent) return;
|
||||||
}
|
|
||||||
const pageSize = 10;
|
rememberSearch(ctx, sent.id, tracks);
|
||||||
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
|
for (const emoji of CHOICE_EMOJI.slice(0, tracks.length)) {
|
||||||
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
|
// Reactions are a convenience; `pick` still works if they fail.
|
||||||
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
|
await sent.react(emoji).catch(() => {});
|
||||||
|
}
|
||||||
const parts: string[] = [];
|
},
|
||||||
if (snapshot.current) {
|
},
|
||||||
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
|
{
|
||||||
parts.push(progressBar(snapshot.position, snapshot.current.duration));
|
name: "pick",
|
||||||
}
|
aliases: ["выбрать"],
|
||||||
if (slice.length > 0) {
|
usage: "pick <номер>",
|
||||||
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
|
description: "Выбрать трек из результатов поиска",
|
||||||
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
|
async run(ctx) {
|
||||||
}
|
const session = activeSession(latestByUser.get(`${ctx.channelId}:${ctx.actor.id}`));
|
||||||
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
if (!session) throw new UserFacingError("Сначала выполните поиск");
|
||||||
parts.push(
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
|
const track = session.tracks[index - 1];
|
||||||
);
|
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${session.tracks.length}`);
|
||||||
await ctx.reply(parts.join("\n\n"));
|
await ctx.reply(
|
||||||
},
|
await enqueueChoice(ctx.manager, ctx.serverId, ctx.channelId, ctx.actor, track),
|
||||||
},
|
);
|
||||||
{
|
},
|
||||||
name: "nowplaying",
|
},
|
||||||
aliases: ["np", "сейчас"],
|
{
|
||||||
usage: "nowplaying",
|
name: "skip",
|
||||||
description: "Показать текущий трек",
|
aliases: ["sk", "пропусти"],
|
||||||
async run(ctx) {
|
usage: "skip [количество]",
|
||||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
description: "Пропустить текущий трек",
|
||||||
if (!snapshot.current) {
|
async run(ctx) {
|
||||||
await ctx.reply("Сейчас ничего не играет.");
|
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
|
||||||
return;
|
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
|
||||||
}
|
await ctx.reply(next ? `⏭️ ${trackTitle(next)}` : "⏭️ Очередь пуста.");
|
||||||
await ctx.reply(
|
},
|
||||||
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
|
},
|
||||||
);
|
{
|
||||||
},
|
name: "stop",
|
||||||
},
|
aliases: ["стоп"],
|
||||||
{
|
usage: "stop",
|
||||||
name: "volume",
|
description: "Остановить и очистить очередь",
|
||||||
aliases: ["vol", "громкость"],
|
async run(ctx) {
|
||||||
usage: "volume [0-200]",
|
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
||||||
description: "Показать или изменить громкость",
|
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
||||||
async run(ctx) {
|
},
|
||||||
if (ctx.args.length === 0) {
|
},
|
||||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
{
|
||||||
return;
|
name: "pause",
|
||||||
}
|
aliases: ["пауза"],
|
||||||
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
usage: "pause",
|
||||||
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
description: "Пауза / продолжить",
|
||||||
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
async run(ctx) {
|
||||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
||||||
},
|
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "loop",
|
{
|
||||||
aliases: ["repeat", "повтор"],
|
name: "resume",
|
||||||
usage: "loop [off|track|queue]",
|
aliases: ["продолжи"],
|
||||||
description: "Режим повтора",
|
usage: "resume",
|
||||||
async run(ctx) {
|
description: "Продолжить воспроизведение",
|
||||||
const raw = (ctx.args[0] ?? "").toLowerCase();
|
async run(ctx) {
|
||||||
const map: Record<string, LoopMode> = {
|
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
|
||||||
off: "off",
|
await ctx.reply("▶️ Продолжаю.");
|
||||||
выкл: "off",
|
},
|
||||||
track: "track",
|
},
|
||||||
трек: "track",
|
{
|
||||||
one: "track",
|
name: "queue",
|
||||||
queue: "queue",
|
aliases: ["q", "очередь"],
|
||||||
очередь: "queue",
|
usage: "queue [страница]",
|
||||||
all: "queue",
|
description: "Показать очередь",
|
||||||
};
|
async run(ctx) {
|
||||||
const current = ctx.manager.snapshot(ctx.serverId).loop;
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
const nextMode =
|
if (!snapshot.current && snapshot.queue.length === 0) {
|
||||||
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
|
await ctx.reply("Очередь пуста.");
|
||||||
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
|
return;
|
||||||
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
|
}
|
||||||
},
|
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));
|
||||||
name: "video",
|
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
|
||||||
aliases: ["видео"],
|
|
||||||
usage: "video [on|off]",
|
const lines: string[] = [];
|
||||||
description: "Показывать клип как демонстрацию экрана",
|
if (snapshot.current) {
|
||||||
async run(ctx) {
|
lines.push(`▶️ ${trackTitle(snapshot.current)}`);
|
||||||
if (!config.VIDEO_ENABLED) {
|
lines.push(progressBar(snapshot.position, snapshot.current.duration));
|
||||||
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
|
}
|
||||||
}
|
if (slice.length > 0) {
|
||||||
const raw = (ctx.args[0] ?? "").toLowerCase();
|
lines.push("");
|
||||||
const current = ctx.manager.snapshot(ctx.serverId).videoEnabled;
|
for (const [index, track] of slice.entries()) {
|
||||||
const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current;
|
lines.push(trackLine(track, { index: (page - 1) * pageSize + index + 1, requester: true }));
|
||||||
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next);
|
}
|
||||||
await ctx.reply(
|
}
|
||||||
next
|
|
||||||
? "📺 Клип будет показан как демонстрация экрана — со следующего трека."
|
const total = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
||||||
: "🔇 Видео выключено, играю только звук.",
|
const footer = [
|
||||||
);
|
`в очереди ${snapshot.queue.length}`,
|
||||||
},
|
formatDuration(total),
|
||||||
},
|
`повтор: ${loopLabel(snapshot.loop)}`,
|
||||||
{
|
`громкость ${snapshot.volume}%`,
|
||||||
name: "shuffle",
|
];
|
||||||
aliases: ["sh", "перемешай"],
|
if (pages > 1) footer.unshift(`стр. ${page}/${pages}`);
|
||||||
usage: "shuffle",
|
lines.push("", `-# ${footer.join(" · ")}`);
|
||||||
description: "Перемешать очередь",
|
|
||||||
async run(ctx) {
|
await ctx.reply(lines.join("\n"));
|
||||||
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
|
},
|
||||||
await ctx.reply("🔀 Очередь перемешана.");
|
},
|
||||||
},
|
{
|
||||||
},
|
name: "nowplaying",
|
||||||
{
|
aliases: ["np", "сейчас"],
|
||||||
name: "remove",
|
usage: "nowplaying",
|
||||||
aliases: ["rm", "удали"],
|
description: "Что играет сейчас",
|
||||||
usage: "remove <номер>",
|
async run(ctx) {
|
||||||
description: "Убрать трек из очереди",
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
async run(ctx) {
|
const track = snapshot.current;
|
||||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
if (!track) {
|
||||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
await ctx.reply("Сейчас ничего не играет.");
|
||||||
const track = snapshot.queue[index - 1];
|
return;
|
||||||
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
|
}
|
||||||
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
// The only place a preview is welcome: it was asked for explicitly.
|
||||||
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
|
const link = /^https?:/i.test(track.url) ? `\n${track.url}` : "";
|
||||||
},
|
await ctx.reply(
|
||||||
},
|
`🎵 ${trackTitle(track)}\n${progressBar(snapshot.position, track.duration)}${link}`,
|
||||||
{
|
);
|
||||||
name: "clear",
|
},
|
||||||
aliases: ["очисти"],
|
},
|
||||||
usage: "clear",
|
{
|
||||||
description: "Очистить очередь, не останавливая текущий трек",
|
name: "volume",
|
||||||
async run(ctx) {
|
aliases: ["vol", "громкость"],
|
||||||
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
|
usage: "volume [0-200]",
|
||||||
await ctx.reply("🧹 Очередь очищена.");
|
description: "Показать или изменить громкость",
|
||||||
},
|
async run(ctx) {
|
||||||
},
|
if (ctx.args.length > 0) {
|
||||||
{
|
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
name: "seek",
|
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
||||||
aliases: ["перемотай"],
|
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
||||||
usage: "seek <мм:сс>",
|
}
|
||||||
description: "Перемотать текущий трек",
|
await ctx.reply(`🔊 Громкость ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||||
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);
|
name: "loop",
|
||||||
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
|
aliases: ["repeat", "повтор"],
|
||||||
},
|
usage: "loop [off|track|queue]",
|
||||||
},
|
description: "Режим повтора",
|
||||||
{
|
async run(ctx) {
|
||||||
name: "join",
|
const raw = (ctx.args[0] ?? "").toLowerCase();
|
||||||
aliases: ["зайди"],
|
const map: Record<string, LoopMode> = {
|
||||||
usage: "join",
|
off: "off",
|
||||||
description: "Позвать бота в ваш голосовой канал",
|
выкл: "off",
|
||||||
async run(ctx) {
|
track: "track",
|
||||||
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
трек: "track",
|
||||||
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
|
one: "track",
|
||||||
textChannelId: ctx.channelId,
|
queue: "queue",
|
||||||
});
|
очередь: "queue",
|
||||||
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
|
all: "queue",
|
||||||
},
|
};
|
||||||
},
|
const current = ctx.manager.snapshot(ctx.serverId).loop;
|
||||||
{
|
const nextMode =
|
||||||
name: "leave",
|
map[raw] ?? (current === "off" ? "track" : current === "track" ? "queue" : "off");
|
||||||
aliases: ["dc", "выйди"],
|
await ctx.manager.setLoop(ctx.serverId, ctx.actor.id, nextMode);
|
||||||
usage: "leave",
|
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
|
||||||
description: "Выйти из голосового канала",
|
},
|
||||||
async run(ctx) {
|
},
|
||||||
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
|
{
|
||||||
await ctx.reply("👋 Вышел из голосового канала.");
|
name: "video",
|
||||||
},
|
aliases: ["видео"],
|
||||||
},
|
usage: "video [on|off]",
|
||||||
{
|
description: "Показывать клип вместе со звуком",
|
||||||
name: "panel",
|
async run(ctx) {
|
||||||
aliases: ["ui", "панель"],
|
if (!config.VIDEO_ENABLED) {
|
||||||
usage: "panel",
|
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
|
||||||
description: "Получить ссылку на веб-панель",
|
}
|
||||||
async run(ctx) {
|
const raw = (ctx.args[0] ?? "").toLowerCase();
|
||||||
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
const current = ctx.manager.snapshot(ctx.serverId).videoEnabled;
|
||||||
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
|
const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current;
|
||||||
await ctx.reply(
|
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next);
|
||||||
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
|
await ctx.reply(
|
||||||
);
|
next ? "📺 Видео включено — со следующего трека." : "🔇 Видео выключено, играю звук.",
|
||||||
},
|
);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: "help",
|
{
|
||||||
aliases: ["h", "помощь"],
|
name: "shuffle",
|
||||||
usage: "help",
|
aliases: ["sh", "перемешай"],
|
||||||
description: "Показать список команд",
|
usage: "shuffle",
|
||||||
async run(ctx) {
|
description: "Перемешать очередь",
|
||||||
const lines = commands
|
async run(ctx) {
|
||||||
.filter((command) => command.name !== "video" || config.VIDEO_ENABLED)
|
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
|
||||||
.map(
|
await ctx.reply("🔀 Очередь перемешана.");
|
||||||
(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:\\`" : ""}.`,
|
name: "remove",
|
||||||
);
|
aliases: ["rm", "удали"],
|
||||||
},
|
usage: "remove <номер>",
|
||||||
},
|
description: "Убрать трек из очереди",
|
||||||
];
|
async run(ctx) {
|
||||||
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
const lookup = new Map<string, Command>();
|
const track = ctx.manager.snapshot(ctx.serverId).queue[index - 1];
|
||||||
for (const command of commands) {
|
if (!track) throw new UserFacingError("Укажите номер трека из очереди");
|
||||||
lookup.set(command.name, command);
|
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
||||||
for (const alias of command.aliases) lookup.set(alias, command);
|
await ctx.reply(`🗑️ Убрано: ${trackTitle(track)}`);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
export function findCommand(name: string): Command | undefined {
|
{
|
||||||
return lookup.get(name.toLowerCase());
|
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());
|
||||||
|
}
|
||||||
|
|||||||
+81
-60
@@ -1,60 +1,81 @@
|
|||||||
import type { LoopMode, Track } from "../types.js";
|
import type { LoopMode, Track } from "../types.js";
|
||||||
|
|
||||||
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
|
/** Clock formatting for any position or length; 0 is a legitimate "0:00". */
|
||||||
export function formatDuration(seconds: number): string {
|
export function formatDuration(seconds: number): string {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||||
const total = Math.floor(seconds);
|
const total = Math.floor(seconds);
|
||||||
const hours = Math.floor(total / 3600);
|
const hours = Math.floor(total / 3600);
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
const secs = total % 60;
|
const secs = total % 60;
|
||||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
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. */
|
/** Track length as shown to people: live streams and unknown lengths are not clocks. */
|
||||||
export function formatLength(track: { duration: number; isLive: boolean }): string {
|
export function formatLength(track: { duration: number; isLive: boolean }): string {
|
||||||
if (track.isLive) return "LIVE";
|
if (track.isLive) return "LIVE";
|
||||||
if (track.duration <= 0) return "—";
|
if (track.duration <= 0) return "—";
|
||||||
return formatDuration(track.duration);
|
return formatDuration(track.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTimecode(input: string): number | null {
|
export function parseTimecode(input: string): number | null {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
||||||
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
|
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const [, hours, minutes, seconds] = match;
|
const [, hours, minutes, seconds] = match;
|
||||||
return (
|
return (
|
||||||
Number.parseInt(hours ?? "0", 10) * 3600 +
|
Number.parseInt(hours ?? "0", 10) * 3600 +
|
||||||
Number.parseInt(minutes ?? "0", 10) * 60 +
|
Number.parseInt(minutes ?? "0", 10) * 60 +
|
||||||
Number.parseInt(seconds ?? "0", 10)
|
Number.parseInt(seconds ?? "0", 10)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function progressBar(position: number, duration: number, width = 22): string {
|
export function progressBar(position: number, duration: number, width = 22): string {
|
||||||
if (duration <= 0) return "🔴 прямой эфир";
|
if (duration <= 0) return "🔴 прямой эфир";
|
||||||
const ratio = Math.min(1, Math.max(0, position / duration));
|
const ratio = Math.min(1, Math.max(0, position / duration));
|
||||||
const filled = Math.round(ratio * (width - 1));
|
const filled = Math.round(ratio * (width - 1));
|
||||||
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
||||||
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<Track["source"], string> = {
|
/**
|
||||||
youtube: "YouTube",
|
* Stoat turns links into full-size previews, so a list of ten results becomes a
|
||||||
soundcloud: "SoundCloud",
|
* wall of video players. Angle brackets keep a link clickable while telling the
|
||||||
direct: "Ссылка",
|
* embed generator to leave it alone — it skips anything matching `<http…>`.
|
||||||
local: "Медиатека",
|
*/
|
||||||
};
|
export function quietLink(url: string): string {
|
||||||
|
return /^https?:\/\//i.test(url) ? `<${url}>` : url;
|
||||||
export function trackLine(track: Track, index?: number): string {
|
}
|
||||||
const prefix = index === undefined ? "" : `**${index}.** `;
|
|
||||||
const author = track.author ? ` — ${track.author}` : "";
|
const SOURCE_LABEL: Record<Track["source"], string> = {
|
||||||
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
|
youtube: "YouTube",
|
||||||
return `${prefix}${link}${author} \`[${formatLength(track)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
|
soundcloud: "SoundCloud",
|
||||||
}
|
direct: "ссылка",
|
||||||
|
local: "медиатека",
|
||||||
export function loopLabel(mode: LoopMode): string {
|
};
|
||||||
if (mode === "track") return "трек";
|
|
||||||
if (mode === "queue") return "очередь";
|
/** Title and artist, without anything that would grow into a preview. */
|
||||||
return "выключен";
|
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 "выключен";
|
||||||
|
}
|
||||||
|
|||||||
+34
-4
@@ -3,7 +3,7 @@ import { config } from "../config.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import type { MusicManager } from "../core/manager.js";
|
import type { MusicManager } from "../core/manager.js";
|
||||||
import { UserFacingError } from "../types.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";
|
import { BotStoatContext } from "./context.js";
|
||||||
|
|
||||||
const log = logger.child({ mod: "bot" });
|
const log = logger.child({ mod: "bot" });
|
||||||
@@ -33,6 +33,13 @@ export async function startBot(manager: MusicManager): Promise<Bot> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
await client.loginBot(config.STOAT_BOT_TOKEN);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -44,15 +51,38 @@ export async function startBot(manager: MusicManager): Promise<Bot> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<void> {
|
||||||
|
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<void> {
|
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
|
||||||
const content = message.content?.trim();
|
const content = message.content?.trim();
|
||||||
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
|
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
|
||||||
if (!message.authorId || message.author?.bot) return;
|
if (!message.authorId || message.author?.bot) return;
|
||||||
|
|
||||||
const serverId = message.server?.id;
|
const serverId = message.server?.id;
|
||||||
const reply = async (text: string) => {
|
const reply = async (text: string) => message.channel?.sendMessage(text);
|
||||||
await message.channel?.sendMessage(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
await reply("Команды работают только внутри сервера.");
|
await reply("Команды работают только внутри сервера.");
|
||||||
|
|||||||
+8
-2
@@ -299,8 +299,14 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
|||||||
(await this.require(serverId, userId)).setVolume(volume);
|
(await this.require(serverId, userId)).setVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setVideo(serverId: string, userId: string, enabled: boolean): Promise<void> {
|
/**
|
||||||
(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<void> {
|
||||||
|
await this.assertControl(serverId, userId);
|
||||||
|
this.getOrCreate(serverId).setVideo(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user