Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same player through both chat commands and a browser panel, so the two never drift apart: everything routes through a single MusicManager. - core: per-server GuildPlayer (queue, loop, shuffle, seek, volume, idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg - sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet radio, optional local library with path-traversal guards - bot: 18 chat commands with aliases, plus !panel one-time login links - api: Fastify REST + WebSocket, sessions authenticated against the instance's own /auth/session/login (TOTP supported), permissions re-checked against Stoat membership and roles on every request - web: React panel with search, queue editing, seek and volume - deploy: Dockerfile, compose.override.yml and Caddyfile snippets for dropping the service into an existing /opt/stoat stack Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs (9 API checks). Voice playback itself needs a live instance to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -0,0 +1,366 @@
|
||||
import { signPanelLink } from "../auth/tokens.js";
|
||||
import { config } from "../config.js";
|
||||
import type { MusicManager } from "../core/manager.js";
|
||||
import { UserFacingError, type LoopMode, type Requester, type Track } from "../types.js";
|
||||
import { formatDuration, loopLabel, parseTimecode, progressBar, trackLine } from "./format.js";
|
||||
|
||||
export interface CommandContext {
|
||||
manager: MusicManager;
|
||||
serverId: string;
|
||||
channelId: string;
|
||||
actor: Requester;
|
||||
/** Raw text after the command name. */
|
||||
rest: string;
|
||||
args: string[];
|
||||
reply(content: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string;
|
||||
aliases: string[];
|
||||
usage: string;
|
||||
description: string;
|
||||
run(ctx: CommandContext): Promise<void>;
|
||||
}
|
||||
|
||||
const SEARCH_TTL_MS = 5 * 60_000;
|
||||
const searchSessions = new Map<string, { tracks: Track[]; expiresAt: number }>();
|
||||
|
||||
function rememberSearch(ctx: CommandContext, tracks: Track[]): void {
|
||||
searchSessions.set(`${ctx.channelId}:${ctx.actor.id}`, {
|
||||
tracks,
|
||||
expiresAt: Date.now() + SEARCH_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function recallSearch(ctx: CommandContext): Track[] | null {
|
||||
const key = `${ctx.channelId}:${ctx.actor.id}`;
|
||||
const entry = searchSessions.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
searchSessions.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.tracks;
|
||||
}
|
||||
|
||||
async function playCommand(ctx: CommandContext, mode: "append" | "next" | "now"): Promise<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) {
|
||||
await ctx.reply(
|
||||
`📥 Добавлено **${outcome.tracks.length}** треков из плейлиста [${outcome.playlist.title}](${outcome.playlist.url}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const track = outcome.tracks[0];
|
||||
if (!track) return;
|
||||
if (outcome.startedNow || mode === "now") {
|
||||
await ctx.reply(`▶️ Играю: ${trackLine(track)}`);
|
||||
} else {
|
||||
await ctx.reply(`➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const commands: Command[] = [
|
||||
{
|
||||
name: "play",
|
||||
aliases: ["p", "играй"],
|
||||
usage: "play <ссылка или название>",
|
||||
description: "Добавить трек или плейлист в очередь",
|
||||
run: (ctx) => playCommand(ctx, "append"),
|
||||
},
|
||||
{
|
||||
name: "playnext",
|
||||
aliases: ["pn", "next"],
|
||||
usage: "playnext <ссылка или название>",
|
||||
description: "Поставить трек следующим в очереди",
|
||||
run: (ctx) => playCommand(ctx, "next"),
|
||||
},
|
||||
{
|
||||
name: "playnow",
|
||||
aliases: ["now"],
|
||||
usage: "playnow <ссылка или название>",
|
||||
description: "Включить трек немедленно",
|
||||
run: (ctx) => playCommand(ctx, "now"),
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
aliases: ["s", "найди"],
|
||||
usage: "search <запрос>",
|
||||
description: "Найти треки и выбрать нужный командой pick",
|
||||
async run(ctx) {
|
||||
if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос");
|
||||
const tracks = await ctx.manager.search(ctx.rest, ctx.actor, config.SEARCH_RESULT_LIMIT);
|
||||
if (tracks.length === 0) {
|
||||
await ctx.reply("Ничего не найдено.");
|
||||
return;
|
||||
}
|
||||
rememberSearch(ctx, tracks);
|
||||
const lines = tracks.map((track, index) => trackLine(track, index + 1));
|
||||
await ctx.reply(
|
||||
`🔎 Результаты поиска:\n${lines.join("\n")}\n\nВыберите: \`${config.COMMAND_PREFIX}pick <номер>\``,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pick",
|
||||
aliases: ["выбрать"],
|
||||
usage: "pick <номер>",
|
||||
description: "Добавить трек из результатов поиска",
|
||||
async run(ctx) {
|
||||
const tracks = recallSearch(ctx);
|
||||
if (!tracks) throw new UserFacingError("Сначала выполните поиск");
|
||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||
const track = tracks[index - 1];
|
||||
if (!track) throw new UserFacingError(`Укажите номер от 1 до ${tracks.length}`);
|
||||
const outcome = await ctx.manager.enqueueTracks(ctx.serverId, ctx.actor, [track], {
|
||||
textChannelId: ctx.channelId,
|
||||
});
|
||||
await ctx.reply(
|
||||
outcome.startedNow
|
||||
? `▶️ Играю: ${trackLine(track)}`
|
||||
: `➕ В очередь (#${outcome.queuePosition}): ${trackLine(track)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip",
|
||||
aliases: ["sk", "пропусти"],
|
||||
usage: "skip [количество]",
|
||||
description: "Пропустить текущий трек",
|
||||
async run(ctx) {
|
||||
const count = Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1);
|
||||
const next = await ctx.manager.skip(ctx.serverId, ctx.actor.id, count);
|
||||
await ctx.reply(next ? `⏭️ Играю: ${trackLine(next)}` : "⏭️ Очередь пуста.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
aliases: ["стоп"],
|
||||
usage: "stop",
|
||||
description: "Остановить воспроизведение и очистить очередь",
|
||||
async run(ctx) {
|
||||
await ctx.manager.stop(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply("⏹️ Остановлено, очередь очищена.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pause",
|
||||
aliases: ["пауза"],
|
||||
usage: "pause",
|
||||
description: "Поставить на паузу / снять с паузы",
|
||||
async run(ctx) {
|
||||
const state = await ctx.manager.togglePause(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply(state === "paused" ? "⏸️ Пауза." : "▶️ Продолжаю.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resume",
|
||||
aliases: ["продолжи"],
|
||||
usage: "resume",
|
||||
description: "Продолжить воспроизведение",
|
||||
async run(ctx) {
|
||||
await ctx.manager.resume(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply("▶️ Продолжаю.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "queue",
|
||||
aliases: ["q", "очередь"],
|
||||
usage: "queue [страница]",
|
||||
description: "Показать очередь",
|
||||
async run(ctx) {
|
||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||
if (!snapshot.current && snapshot.queue.length === 0) {
|
||||
await ctx.reply("Очередь пуста.");
|
||||
return;
|
||||
}
|
||||
const pageSize = 10;
|
||||
const pages = Math.max(1, Math.ceil(snapshot.queue.length / pageSize));
|
||||
const page = Math.min(pages, Math.max(1, Number.parseInt(ctx.args[0] ?? "1", 10) || 1));
|
||||
const slice = snapshot.queue.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (snapshot.current) {
|
||||
parts.push(`**Сейчас играет**\n${trackLine(snapshot.current)}`);
|
||||
parts.push(progressBar(snapshot.position, snapshot.current.duration));
|
||||
}
|
||||
if (slice.length > 0) {
|
||||
const lines = slice.map((track, index) => trackLine(track, (page - 1) * pageSize + index + 1));
|
||||
parts.push(`**Дальше (${snapshot.queue.length})**\n${lines.join("\n")}`);
|
||||
}
|
||||
const totalDuration = snapshot.queue.reduce((acc, track) => acc + track.duration, 0);
|
||||
parts.push(
|
||||
`Страница ${page}/${pages} · Всего: ${formatDuration(totalDuration)} · Повтор: ${loopLabel(snapshot.loop)} · Громкость: ${snapshot.volume}%`,
|
||||
);
|
||||
await ctx.reply(parts.join("\n\n"));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nowplaying",
|
||||
aliases: ["np", "сейчас"],
|
||||
usage: "nowplaying",
|
||||
description: "Показать текущий трек",
|
||||
async run(ctx) {
|
||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||
if (!snapshot.current) {
|
||||
await ctx.reply("Сейчас ничего не играет.");
|
||||
return;
|
||||
}
|
||||
await ctx.reply(
|
||||
`🎵 ${trackLine(snapshot.current)}\n${progressBar(snapshot.position, snapshot.current.duration)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volume",
|
||||
aliases: ["vol", "громкость"],
|
||||
usage: "volume [0-200]",
|
||||
description: "Показать или изменить громкость",
|
||||
async run(ctx) {
|
||||
if (ctx.args.length === 0) {
|
||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||
return;
|
||||
}
|
||||
const value = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||
if (Number.isNaN(value)) throw new UserFacingError("Укажите число от 0 до 200");
|
||||
await ctx.manager.setVolume(ctx.serverId, ctx.actor.id, value);
|
||||
await ctx.reply(`🔊 Громкость: ${ctx.manager.snapshot(ctx.serverId).volume}%`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "loop",
|
||||
aliases: ["repeat", "повтор"],
|
||||
usage: "loop [off|track|queue]",
|
||||
description: "Режим повтора",
|
||||
async run(ctx) {
|
||||
const raw = (ctx.args[0] ?? "").toLowerCase();
|
||||
const map: Record<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: "shuffle",
|
||||
aliases: ["sh", "перемешай"],
|
||||
usage: "shuffle",
|
||||
description: "Перемешать очередь",
|
||||
async run(ctx) {
|
||||
await ctx.manager.shuffle(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply("🔀 Очередь перемешана.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove",
|
||||
aliases: ["rm", "удали"],
|
||||
usage: "remove <номер>",
|
||||
description: "Убрать трек из очереди",
|
||||
async run(ctx) {
|
||||
const index = Number.parseInt(ctx.args[0] ?? "", 10);
|
||||
const snapshot = ctx.manager.snapshot(ctx.serverId);
|
||||
const track = snapshot.queue[index - 1];
|
||||
if (!track) throw new UserFacingError("Укажите корректный номер трека из очереди");
|
||||
await ctx.manager.remove(ctx.serverId, ctx.actor.id, track.id);
|
||||
await ctx.reply(`🗑️ Удалено: **${track.title}**`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
aliases: ["очисти"],
|
||||
usage: "clear",
|
||||
description: "Очистить очередь, не останавливая текущий трек",
|
||||
async run(ctx) {
|
||||
await ctx.manager.clearQueue(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply("🧹 Очередь очищена.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "seek",
|
||||
aliases: ["перемотай"],
|
||||
usage: "seek <мм:сс>",
|
||||
description: "Перемотать текущий трек",
|
||||
async run(ctx) {
|
||||
const seconds = parseTimecode(ctx.rest);
|
||||
if (seconds === null) throw new UserFacingError("Формат: `seek 1:23` или `seek 83`");
|
||||
await ctx.manager.seek(ctx.serverId, ctx.actor.id, seconds);
|
||||
await ctx.reply(`⏩ Перемотано на ${formatDuration(seconds)}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join",
|
||||
aliases: ["зайди"],
|
||||
usage: "join",
|
||||
description: "Позвать бота в ваш голосовой канал",
|
||||
async run(ctx) {
|
||||
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
||||
const player = await ctx.manager.connect(ctx.serverId, ctx.actor.id, {
|
||||
textChannelId: ctx.channelId,
|
||||
});
|
||||
await ctx.reply(`🔉 Подключился к **${player.voiceChannelName ?? "каналу"}**.`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leave",
|
||||
aliases: ["dc", "выйди"],
|
||||
usage: "leave",
|
||||
description: "Выйти из голосового канала",
|
||||
async run(ctx) {
|
||||
await ctx.manager.leave(ctx.serverId, ctx.actor.id);
|
||||
await ctx.reply("👋 Вышел из голосового канала.");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "panel",
|
||||
aliases: ["ui", "панель"],
|
||||
usage: "panel",
|
||||
description: "Получить ссылку на веб-панель",
|
||||
async run(ctx) {
|
||||
await ctx.manager.assertControl(ctx.serverId, ctx.actor.id);
|
||||
const token = await signPanelLink(ctx.actor.id, ctx.actor.username, ctx.serverId);
|
||||
await ctx.reply(
|
||||
`🎛️ Панель управления: ${config.PUBLIC_URL}/login?token=${token}\nСсылка личная и действует 10 минут.`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
aliases: ["h", "помощь"],
|
||||
usage: "help",
|
||||
description: "Показать список команд",
|
||||
async run(ctx) {
|
||||
const lines = commands.map(
|
||||
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`,
|
||||
);
|
||||
await ctx.reply(
|
||||
`**Команды музыкального бота**\n${lines.join("\n")}\n\nИсточники: YouTube, SoundCloud, прямые ссылки и радио${config.LOCAL_MEDIA_DIR ? ", локальная медиатека" : ""}. Префиксы поиска: \`sc:\`, \`yt:\`${config.LOCAL_MEDIA_DIR ? ", \\`local:\\`" : ""}.`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const lookup = new Map<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());
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Client } from "stoat.js";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import type { ServerRef, StoatContext, VoiceChannelRef } from "../core/manager.js";
|
||||
import { fetchMember } from "../stoat/rest.js";
|
||||
|
||||
const log = logger.child({ mod: "bot-context" });
|
||||
const MEMBERSHIP_TTL_MS = 30_000;
|
||||
|
||||
interface MembershipInfo {
|
||||
isMember: boolean;
|
||||
roles: string[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** Implements the core's view of Stoat on top of the live bot client. */
|
||||
export class BotStoatContext implements StoatContext {
|
||||
private readonly membershipCache = new Map<string, MembershipInfo>();
|
||||
|
||||
constructor(private readonly client: Client) {}
|
||||
|
||||
getServerName(serverId: string): string | null {
|
||||
return this.client.servers.get(serverId)?.name ?? null;
|
||||
}
|
||||
|
||||
getVoiceChannel(channelId: string): VoiceChannelRef | null {
|
||||
const channel = this.client.channels.get(channelId);
|
||||
if (!channel?.isVoice) return null;
|
||||
return { id: channel.id, name: channel.name };
|
||||
}
|
||||
|
||||
listVoiceChannels(serverId: string): VoiceChannelRef[] {
|
||||
const server = this.client.servers.get(serverId);
|
||||
if (!server) return [];
|
||||
return server.channels
|
||||
.filter((channel) => channel.isVoice)
|
||||
.map((channel) => ({ id: channel.id, name: channel.name }));
|
||||
}
|
||||
|
||||
findUserVoiceChannel(serverId: string, userId: string): VoiceChannelRef | null {
|
||||
const server = this.client.servers.get(serverId);
|
||||
if (!server) return null;
|
||||
for (const channel of server.channels) {
|
||||
if (!channel.isVoice) continue;
|
||||
if (channel.voiceParticipants.has(userId)) {
|
||||
return { id: channel.id, name: channel.name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Servers where both the bot and the given user are members. */
|
||||
async listServersForUser(userId: string): Promise<ServerRef[]> {
|
||||
const servers = [...this.client.servers.values()];
|
||||
const checks = await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
const info = await this.membership(server.id, userId);
|
||||
if (!info.isMember) return null;
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
iconUrl: server.icon?.createFileURL() ?? null,
|
||||
} satisfies ServerRef;
|
||||
}),
|
||||
);
|
||||
return checks.filter((entry): entry is ServerRef => entry !== null);
|
||||
}
|
||||
|
||||
async isMember(serverId: string, userId: string): Promise<boolean> {
|
||||
return (await this.membership(serverId, userId)).isMember;
|
||||
}
|
||||
|
||||
async canControl(serverId: string, userId: string): Promise<boolean> {
|
||||
const info = await this.membership(serverId, userId);
|
||||
if (!info.isMember) return false;
|
||||
if (!config.REQUIRE_DJ_ROLE) return true;
|
||||
|
||||
const server = this.client.servers.get(serverId);
|
||||
if (!server) return false;
|
||||
if (server.owner?.id === userId) return true;
|
||||
|
||||
const member = server.getMember(userId);
|
||||
if (member?.hasPermission(server, "ManageServer")) return true;
|
||||
|
||||
const djRole = [...server.roles.entries()].find(
|
||||
([, role]) => role.name.toLowerCase() === config.DJ_ROLE_NAME.toLowerCase(),
|
||||
);
|
||||
if (!djRole) return false;
|
||||
return info.roles.includes(djRole[0]);
|
||||
}
|
||||
|
||||
private async membership(serverId: string, userId: string): Promise<MembershipInfo> {
|
||||
const key = `${serverId}:${userId}`;
|
||||
const cached = this.membershipCache.get(key);
|
||||
if (cached && Date.now() - cached.at < MEMBERSHIP_TTL_MS) return cached;
|
||||
|
||||
const cachedMember = this.client.servers.get(serverId)?.getMember(userId);
|
||||
if (cachedMember) {
|
||||
const info: MembershipInfo = { isMember: true, roles: cachedMember.roles ?? [], at: Date.now() };
|
||||
this.membershipCache.set(key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
let info: MembershipInfo = { isMember: false, roles: [], at: Date.now() };
|
||||
try {
|
||||
const member = await fetchMember(serverId, userId);
|
||||
if (member) info = { isMember: true, roles: member.roles ?? [], at: Date.now() };
|
||||
} catch (err) {
|
||||
log.warn({ err, serverId, userId }, "membership lookup failed");
|
||||
}
|
||||
this.membershipCache.set(key, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
async sendMessage(channelId: string, content: string): Promise<void> {
|
||||
const channel = this.client.channels.get(channelId);
|
||||
if (!channel) return;
|
||||
await channel.sendMessage(content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LoopMode, Track } from "../types.js";
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
|
||||
const total = Math.floor(seconds);
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const secs = total % 60;
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
|
||||
}
|
||||
|
||||
export function parseTimecode(input: string): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
||||
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
|
||||
if (!match) return null;
|
||||
const [, hours, minutes, seconds] = match;
|
||||
return (
|
||||
Number.parseInt(hours ?? "0", 10) * 3600 +
|
||||
Number.parseInt(minutes ?? "0", 10) * 60 +
|
||||
Number.parseInt(seconds ?? "0", 10)
|
||||
);
|
||||
}
|
||||
|
||||
export function progressBar(position: number, duration: number, width = 22): string {
|
||||
if (duration <= 0) return "🔴 прямой эфир";
|
||||
const ratio = Math.min(1, Math.max(0, position / duration));
|
||||
const filled = Math.round(ratio * (width - 1));
|
||||
const bar = `${"─".repeat(filled)}⬤${"─".repeat(Math.max(0, width - 1 - filled))}`;
|
||||
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<Track["source"], string> = {
|
||||
youtube: "YouTube",
|
||||
soundcloud: "SoundCloud",
|
||||
direct: "Ссылка",
|
||||
local: "Медиатека",
|
||||
};
|
||||
|
||||
export function trackLine(track: Track, index?: number): string {
|
||||
const prefix = index === undefined ? "" : `**${index}.** `;
|
||||
const author = track.author ? ` — ${track.author}` : "";
|
||||
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
|
||||
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
|
||||
}
|
||||
|
||||
export function loopLabel(mode: LoopMode): string {
|
||||
if (mode === "track") return "трек";
|
||||
if (mode === "queue") return "очередь";
|
||||
return "выключен";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Client, type Message } from "stoat.js";
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import type { MusicManager } from "../core/manager.js";
|
||||
import { UserFacingError } from "../types.js";
|
||||
import { findCommand, type CommandContext } from "./commands.js";
|
||||
import { BotStoatContext } from "./context.js";
|
||||
|
||||
const log = logger.child({ mod: "bot" });
|
||||
|
||||
export interface Bot {
|
||||
client: Client;
|
||||
context: BotStoatContext;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startBot(manager: MusicManager): Promise<Bot> {
|
||||
const client = new Client({ baseURL: config.STOAT_API_URL });
|
||||
const context = new BotStoatContext(client);
|
||||
manager.attachStoat(context);
|
||||
|
||||
client.on("ready", () => {
|
||||
log.info({ user: client.user?.username }, "bot is ready");
|
||||
});
|
||||
client.on("error", (error) => {
|
||||
log.error({ err: error }, "client error");
|
||||
});
|
||||
client.on("disconnected", () => log.warn("gateway disconnected"));
|
||||
|
||||
client.on("messageCreate", (message) => {
|
||||
void handleMessage(manager, message).catch((err) => {
|
||||
log.error({ err }, "unhandled command failure");
|
||||
});
|
||||
});
|
||||
|
||||
await client.loginBot(config.STOAT_BOT_TOKEN);
|
||||
|
||||
return {
|
||||
client,
|
||||
context,
|
||||
async stop() {
|
||||
await manager.destroyAll();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMessage(manager: MusicManager, message: Message): Promise<void> {
|
||||
const content = message.content?.trim();
|
||||
if (!content || !content.startsWith(config.COMMAND_PREFIX)) return;
|
||||
if (!message.authorId || message.author?.bot) return;
|
||||
|
||||
const serverId = message.server?.id;
|
||||
const reply = async (text: string) => {
|
||||
await message.channel?.sendMessage(text);
|
||||
};
|
||||
|
||||
if (!serverId) {
|
||||
await reply("Команды работают только внутри сервера.");
|
||||
return;
|
||||
}
|
||||
|
||||
const withoutPrefix = content.slice(config.COMMAND_PREFIX.length).trim();
|
||||
const [rawName, ...args] = withoutPrefix.split(/\s+/);
|
||||
if (!rawName) return;
|
||||
|
||||
const command = findCommand(rawName);
|
||||
if (!command) return;
|
||||
|
||||
const ctx: CommandContext = {
|
||||
manager,
|
||||
serverId,
|
||||
channelId: message.channelId,
|
||||
actor: {
|
||||
id: message.authorId,
|
||||
username: message.member?.nickname || message.author?.username || "user",
|
||||
},
|
||||
args,
|
||||
rest: withoutPrefix.slice(rawName.length).trim(),
|
||||
reply,
|
||||
};
|
||||
|
||||
log.debug({ command: command.name, user: ctx.actor.id, server: serverId }, "command");
|
||||
|
||||
try {
|
||||
await command.run(ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof UserFacingError) {
|
||||
await reply(`⚠️ ${err.message}`);
|
||||
return;
|
||||
}
|
||||
log.error({ err, command: command.name }, "command failed");
|
||||
await reply("⚠️ Внутренняя ошибка, подробности в логах бота.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user