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`,
|
||||||
|
где превью запрошено явно.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Если бот не заходит в голосовой канал
|
## Если бот не заходит в голосовой канал
|
||||||
|
|||||||
+169
-78
@@ -3,7 +3,21 @@ 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,
|
||||||
|
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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CommandContext {
|
export interface CommandContext {
|
||||||
manager: MusicManager;
|
manager: MusicManager;
|
||||||
@@ -13,7 +27,7 @@ export interface CommandContext {
|
|||||||
/** Raw text after the command name. */
|
/** Raw text after the command name. */
|
||||||
rest: string;
|
rest: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
reply(content: string): Promise<void>;
|
reply(content: string): Promise<SentMessage | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Command {
|
export interface Command {
|
||||||
@@ -24,25 +38,74 @@ export interface Command {
|
|||||||
run(ctx: CommandContext): Promise<void>;
|
run(ctx: CommandContext): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEARCH_TTL_MS = 5 * 60_000;
|
/** Hidden from help when the bot was started without video support. */
|
||||||
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
|
const VIDEO_COMMANDS = new Set(["video", "playvideo"]);
|
||||||
|
|
||||||
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
|
/** Picking by reaction only works while the list is short enough to read. */
|
||||||
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
|
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,
|
tracks,
|
||||||
|
userId: ctx.actor.id,
|
||||||
|
channelId: ctx.channelId,
|
||||||
expiresAt: Date.now() + SEARCH_TTL_MS,
|
expiresAt: Date.now() + SEARCH_TTL_MS,
|
||||||
});
|
});
|
||||||
|
latestByUser.set(`${ctx.channelId}:${ctx.actor.id}`, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recallSearch(ctx: CommandContext): Track[] | null {
|
function activeSession(messageId: string | undefined): SearchSession | null {
|
||||||
const key = `${ctx.channelId}:${ctx.actor.id}`;
|
if (!messageId) return null;
|
||||||
const entry = searchSessions.get(key);
|
const session = sessionsByMessage.get(messageId);
|
||||||
if (!entry) return null;
|
if (!session) return null;
|
||||||
if (entry.expiresAt < Date.now()) {
|
if (session.expiresAt < Date.now()) {
|
||||||
searchSessions.delete(key);
|
sessionsByMessage.delete(messageId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return entry.tracks;
|
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<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> {
|
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<void> {
|
||||||
@@ -55,18 +118,14 @@ async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now")
|
|||||||
if (outcome.playlist) {
|
if (outcome.playlist) {
|
||||||
const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS;
|
const capped = outcome.tracks.length >= config.MAX_PLAYLIST_TRACKS;
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url})` +
|
`📥 Плейлист **${outcome.playlist.title}** — ${outcome.tracks.length} треков` +
|
||||||
(capped ? ` — это предел на один плейлист (MAX_PLAYLIST_TRACKS).` : "."),
|
(capped ? ` (предел на один плейлист, MAX_PLAYLIST_TRACKS).` : "."),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const track = outcome.tracks[0];
|
const track = outcome.tracks[0];
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
if (outcome.startedNow || mode === "now") {
|
await ctx.reply(added(track, outcome.startedNow || mode === "now", outcome.queuePosition));
|
||||||
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
|
|
||||||
} else {
|
|
||||||
await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commands: Command[] = [
|
export const commands: Command[] = [
|
||||||
@@ -81,7 +140,7 @@ export const commands: Command[] = [
|
|||||||
name: "playnext",
|
name: "playnext",
|
||||||
aliases: ["pn", "next"],
|
aliases: ["pn", "next"],
|
||||||
usage: "playnext <ссылка или название>",
|
usage: "playnext <ссылка или название>",
|
||||||
description: "Поставить трек следующим в очереди",
|
description: "Поставить трек следующим",
|
||||||
run: (ctx) => playCommand(ctx, "next"),
|
run: (ctx) => playCommand(ctx, "next"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,43 +150,59 @@ export const commands: Command[] = [
|
|||||||
description: "Включить трек немедленно",
|
description: "Включить трек немедленно",
|
||||||
run: (ctx) => playCommand(ctx, "now"),
|
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",
|
name: "search",
|
||||||
aliases: ["s", "найди"],
|
aliases: ["s", "найди"],
|
||||||
usage: "search <запрос>",
|
usage: "search <запрос>",
|
||||||
description: "Найти треки и выбрать нужный командой pick",
|
description: "Найти треки и выбрать реакцией",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
||||||
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
|
const tracks = (await ctx.manager.search(ctx.rest, ctx.actor, CHAT_SEARCH_LIMIT)).slice(
|
||||||
|
0,
|
||||||
|
CHAT_SEARCH_LIMIT,
|
||||||
|
);
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
await ctx.reply(`🔎 ${NOTHING_FOUND}`);
|
await ctx.reply(`🔎 ${NOTHING_FOUND}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rememberSearch(ctx, tracks);
|
|
||||||
const lines = tracks.map((track, index) => trackLine(track, index + 1));
|
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")}`);
|
||||||
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
|
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",
|
name: "pick",
|
||||||
aliases: ["выбрать"],
|
aliases: ["выбрать"],
|
||||||
usage: "pick <номер>",
|
usage: "pick <номер>",
|
||||||
description: "Добавить трек из результатов поиска",
|
description: "Выбрать трек из результатов поиска",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const tracks = recallSearch(ctx);
|
const session = activeSession(latestByUser.get(`${ctx.channelId}:${ctx.actor.id}`));
|
||||||
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
|
if (!session) throw new UserFacingError("Сначала выполните поиск");
|
||||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
const track = tracks[index - 1];
|
const track = session.tracks[index - 1];
|
||||||
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
|
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${session.tracks.length}`);
|
||||||
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
|
|
||||||
textChannelId: ctx.channelId,
|
|
||||||
});
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
outcome.startedNow
|
await enqueueChoice(ctx.manager, ctx.serverId, ctx.channelId, ctx.actor, track),
|
||||||
? `▶️ Играю: ${trackLine(track)}`
|
|
||||||
: `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,14 +214,14 @@ export const commands: Command[] = [
|
|||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
|
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);
|
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
|
||||||
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
|
await ctx.reply(next ? `⏭️ ${trackTitle(next)}` : "⏭️ Очередь пуста.");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "stop",
|
name: "stop",
|
||||||
aliases: ["стоп"],
|
aliases: ["стоп"],
|
||||||
usage: "stop",
|
usage: "stop",
|
||||||
description: "Остановить воспроизведение и очистить очередь",
|
description: "Остановить и очистить очередь",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
||||||
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
||||||
@@ -156,7 +231,7 @@ export const commands: Command[] = [
|
|||||||
name: "pause",
|
name: "pause",
|
||||||
aliases: ["пауза"],
|
aliases: ["пауза"],
|
||||||
usage: "pause",
|
usage: "pause",
|
||||||
description: "Поставить на паузу / снять с паузы",
|
description: "Пауза / продолжить",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
||||||
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
||||||
@@ -188,35 +263,47 @@ export const commands: Command[] = [
|
|||||||
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
|
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 slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const lines: string[] = [];
|
||||||
if (snapshot.current) {
|
if (snapshot.current) {
|
||||||
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
|
lines.push(`▶️ ${trackTitle(snapshot.current)}`);
|
||||||
parts.push(progressBar(snapshot.position, snapshot.current.duration));
|
lines.push(progressBar(snapshot.position, snapshot.current.duration));
|
||||||
}
|
}
|
||||||
if (slice.length > 0) {
|
if (slice.length > 0) {
|
||||||
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
|
lines.push("");
|
||||||
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
|
for (const [index, track] of slice.entries()) {
|
||||||
|
lines.push(trackLine(track, { index: (page - 1) * pageSize + index + 1, requester: true }));
|
||||||
}
|
}
|
||||||
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
}
|
||||||
parts.push(
|
|
||||||
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
|
const total = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
||||||
);
|
const footer = [
|
||||||
await ctx.reply(parts.join("\n\n"));
|
`в очереди ${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",
|
name: "nowplaying",
|
||||||
aliases: ["np", "сейчас"],
|
aliases: ["np", "сейчас"],
|
||||||
usage: "nowplaying",
|
usage: "nowplaying",
|
||||||
description: "Показать текущий трек",
|
description: "Что играет сейчас",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||||
if (!snapshot.current) {
|
const track = snapshot.current;
|
||||||
|
if (!track) {
|
||||||
await ctx.reply("Сейчас ничего не играет.");
|
await ctx.reply("Сейчас ничего не играет.");
|
||||||
return;
|
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(
|
await ctx.reply(
|
||||||
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
|
`🎵 ${trackTitle(track)}\n${progressBar(snapshot.position, track.duration)}${link}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -226,14 +313,12 @@ export const commands: Command[] = [
|
|||||||
usage: "volume [0-200]",
|
usage: "volume [0-200]",
|
||||||
description: "Показать или изменить громкость",
|
description: "Показать или изменить громкость",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
if (ctx.args.length === 0) {
|
if (ctx.args.length > 0) {
|
||||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
||||||
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
||||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
}
|
||||||
|
await ctx.reply(`🔊 Громкость ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -264,7 +349,7 @@ export const commands: Command[] = [
|
|||||||
name: "video",
|
name: "video",
|
||||||
aliases: ["видео"],
|
aliases: ["видео"],
|
||||||
usage: "video [on|off]",
|
usage: "video [on|off]",
|
||||||
description: "Показывать клип как демонстрацию экрана",
|
description: "Показывать клип вместе со звуком",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
if (!config.VIDEO_ENABLED) {
|
if (!config.VIDEO_ENABLED) {
|
||||||
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
|
throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)");
|
||||||
@@ -274,9 +359,7 @@ export const commands: Command[] = [
|
|||||||
const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current;
|
const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current;
|
||||||
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next);
|
await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next);
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
next
|
next ? "📺 Видео включено — со следующего трека." : "🔇 Видео выключено, играю звук.",
|
||||||
? "📺 Клип будет показан как демонстрация экрана — со следующего трека."
|
|
||||||
: "🔇 Видео выключено, играю только звук.",
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -297,18 +380,17 @@ export const commands: Command[] = [
|
|||||||
description: "Убрать трек из очереди",
|
description: "Убрать трек из очереди",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
const track = ctx.manager.snapshot(ctx.serverId).queue[index - 1];
|
||||||
const track = snapshot.queue[index - 1];
|
if (!track) throw new UserFacingError("Укажите номер трека из очереди");
|
||||||
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
|
|
||||||
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
||||||
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
|
await ctx.reply(`🗑️ Убрано: ${trackTitle(track)}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "clear",
|
name: "clear",
|
||||||
aliases: ["очисти"],
|
aliases: ["очисти"],
|
||||||
usage: "clear",
|
usage: "clear",
|
||||||
description: "Очистить очередь, не останавливая текущий трек",
|
description: "Очистить очередь, не трогая текущий трек",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
|
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
|
||||||
await ctx.reply("🧹 Очередь очищена.");
|
await ctx.reply("🧹 Очередь очищена.");
|
||||||
@@ -323,7 +405,7 @@ export const commands: Command[] = [
|
|||||||
const seconds = parseTimecode(ctx.rest);
|
const seconds = parseTimecode(ctx.rest);
|
||||||
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
|
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
|
||||||
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
|
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
|
||||||
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
|
await ctx.reply(`⏩ ${formatDuration(seconds)}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -353,12 +435,12 @@ export const commands: Command[] = [
|
|||||||
name: "panel",
|
name: "panel",
|
||||||
aliases: ["ui", "панель"],
|
aliases: ["ui", "панель"],
|
||||||
usage: "panel",
|
usage: "panel",
|
||||||
description: "Получить ссылку на веб-панель",
|
description: "Личная ссылка на веб-панель",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
||||||
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
|
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
|
`🎛️ ${quietLink(`${config.PUBLIC_URL}/login?token=${token}`)}\n-# ссылка личная, действует 10 минут`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -366,15 +448,24 @@ export const commands: Command[] = [
|
|||||||
name: "help",
|
name: "help",
|
||||||
aliases: ["h", "помощь"],
|
aliases: ["h", "помощь"],
|
||||||
usage: "help",
|
usage: "help",
|
||||||
description: "Показать список команд",
|
description: "Список команд",
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const lines = commands
|
const lines = commands
|
||||||
.filter((command) => command.name !== "video" || config.VIDEO_ENABLED)
|
.filter((command) => !VIDEO_COMMANDS.has(command.name) || config.VIDEO_ENABLED)
|
||||||
.map(
|
.map((command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`);
|
||||||
(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(
|
await ctx.reply(
|
||||||
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
|
[
|
||||||
|
"**Команды**",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
`-# источники: ${sources.join(", ")} · префиксы поиска: ${prefixes.join(", ")}`,
|
||||||
|
].join("\n"),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+28
-7
@@ -39,18 +39,39 @@ export function progressBar(position: number, duration: number, width = 22): str
|
|||||||
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
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 `<http…>`.
|
||||||
|
*/
|
||||||
|
export function quietLink(url: string): string {
|
||||||
|
return /^https?:\/\//i.test(url) ? `<${url}>` : url;
|
||||||
|
}
|
||||||
|
|
||||||
const SOURCE_LABEL: Record<Track["source"], string> = {
|
const SOURCE_LABEL: Record<Track["source"], string> = {
|
||||||
youtube: "YouTube",
|
youtube: "YouTube",
|
||||||
soundcloud: "SoundCloud",
|
soundcloud: "SoundCloud",
|
||||||
direct: "Ссылка",
|
direct: "ссылка",
|
||||||
local: "Медиатека",
|
local: "медиатека",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function trackLine(track: Track, index?: number): string {
|
/** Title and artist, without anything that would grow into a preview. */
|
||||||
const prefix = index === undefined ? "" : `**${index}.** `;
|
export function trackTitle(track: Track): string {
|
||||||
const author = track.author ? ` — ${track.author}` : "";
|
return track.author ? `**${track.title}** — ${track.author}` : `**${track.title}**`;
|
||||||
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 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 {
|
export function loopLabel(mode: LoopMode): string {
|
||||||
|
|||||||
+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("Команды работают только внутри сервера.");
|
||||||
|
|||||||
+7
-1
@@ -299,8 +299,14 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
|||||||
(await this.require(serverId, userId)).setVolume(volume);
|
(await this.require(serverId, userId)).setVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
async setVideo(serverId: string, userId: string, enabled: boolean): Promise<void> {
|
||||||
(await this.require(serverId, userId)).setVideo(enabled);
|
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