mirror of
https://github.com/aydenjahola/discord-multipurpose-bot.git
synced 2025-09-21 06:41:35 +01:00
Some checks are pending
Docker / build (push) Waiting to run
* add simple music functionality * update workflow * update Dockerfile * update Dockerfile * update Dockerfile * update Dockerfile * add few more music commands * add lyrics command * update lyrics command * add loop, and add categories to all commands * change discord status * seperate distube and change startup console theme * Update README * UPDATE LICENSE file * fix docker compose image, add better error handling for distube and update tagging workflow * switch to node-alpine image for docker * switch to node-alpine image for docker * update ascii * music commands imporvements, implement live lyrics, some guards and bot leaving on empty * use ffmpeg package rather than ffmpeg-static
269 lines
8.7 KiB
JavaScript
269 lines
8.7 KiB
JavaScript
const {
|
||
SlashCommandBuilder,
|
||
ActionRowBuilder,
|
||
ButtonBuilder,
|
||
ButtonStyle,
|
||
EmbedBuilder,
|
||
StringSelectMenuBuilder,
|
||
ComponentType,
|
||
} = require("discord.js");
|
||
const { requireQueue } = require("../../utils/musicGuards");
|
||
|
||
const PAGE_SIZE = 10;
|
||
const COLLECTOR_IDLE_MS = 60_000; // stop listening after 60s idle
|
||
const REFRESH_INTERVAL_MS = 2000; // live refresh throttle
|
||
|
||
function fmtHMS(totalSeconds = 0) {
|
||
const s = Math.max(0, Math.floor(totalSeconds));
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const sec = s % 60;
|
||
return h > 0
|
||
? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`
|
||
: `${m}:${String(sec).padStart(2, "0")}`;
|
||
}
|
||
|
||
function safe(text, max = 128) {
|
||
if (!text) return "";
|
||
text = String(text);
|
||
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
||
}
|
||
|
||
function sumDurations(songs) {
|
||
let total = 0;
|
||
for (const s of songs) if (Number.isFinite(s?.duration)) total += s.duration;
|
||
return total;
|
||
}
|
||
|
||
function queueFingerprint(q) {
|
||
// A tiny hash-ish snapshot: current song id/url + length + volume
|
||
const now = q.songs?.[0];
|
||
const id = now?.id || now?.url || now?.name || "";
|
||
return `${id}|len:${q.songs?.length || 0}|vol:${q.volume || 0}|t:${Math.floor(
|
||
q.currentTime || 0
|
||
)}`;
|
||
}
|
||
|
||
/** Build a single queue page embed */
|
||
function buildEmbed(queue, page, totalPages) {
|
||
const now = queue.songs[0];
|
||
const upcoming = queue.songs.slice(1);
|
||
|
||
const start = page * PAGE_SIZE;
|
||
const end = Math.min(start + PAGE_SIZE, upcoming.length);
|
||
const chunk = upcoming.slice(start, end);
|
||
|
||
const lines = chunk.map((song, i) => {
|
||
const index = start + i + 1;
|
||
const dur = Number.isFinite(song.duration) ? fmtHMS(song.duration) : "LIVE";
|
||
const requester = song.user?.id || song.member?.id;
|
||
return `**${index}.** ${safe(song.name, 90)} — \`${dur}\`${
|
||
requester ? ` • <@${requester}>` : ""
|
||
}`;
|
||
});
|
||
|
||
const totalLen = sumDurations(queue.songs);
|
||
const footerParts = [
|
||
`Page ${page + 1}/${totalPages}`,
|
||
`Volume ${queue.volume ?? 100}%`,
|
||
`Total: ${fmtHMS(totalLen)} • ${queue.songs.length} track${
|
||
queue.songs.length === 1 ? "" : "s"
|
||
}`,
|
||
];
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setColor(0x00a2ff)
|
||
.setTitle("🎶 Music Queue")
|
||
.setDescription(
|
||
[
|
||
`**Now Playing**`,
|
||
`${safe(now?.name ?? "Nothing", 128)} — \`${
|
||
Number.isFinite(now?.duration) ? fmtHMS(now.duration) : "LIVE"
|
||
}\`${now?.user?.id ? ` • <@${now.user.id}>` : ""}`,
|
||
"",
|
||
chunk.length ? "**Up Next**" : "*No more songs queued.*",
|
||
lines.join("\n") || "",
|
||
].join("\n")
|
||
)
|
||
.setFooter({ text: footerParts.join(" • ") });
|
||
|
||
if (now?.thumbnail) embed.setThumbnail(now.thumbnail);
|
||
return embed;
|
||
}
|
||
|
||
function buildButtons(page, totalPages, disabled = false) {
|
||
const first = new ButtonBuilder()
|
||
.setCustomId("queue_first")
|
||
.setEmoji("⏮️")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(disabled || page === 0);
|
||
const prev = new ButtonBuilder()
|
||
.setCustomId("queue_prev")
|
||
.setEmoji("◀️")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(disabled || page === 0);
|
||
const next = new ButtonBuilder()
|
||
.setCustomId("queue_next")
|
||
.setEmoji("▶️")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(disabled || page >= totalPages - 1);
|
||
const last = new ButtonBuilder()
|
||
.setCustomId("queue_last")
|
||
.setEmoji("⏭️")
|
||
.setStyle(ButtonStyle.Secondary)
|
||
.setDisabled(disabled || page >= totalPages - 1);
|
||
const stop = new ButtonBuilder()
|
||
.setCustomId("queue_stop")
|
||
.setEmoji("🛑")
|
||
.setStyle(ButtonStyle.Danger)
|
||
.setDisabled(disabled);
|
||
|
||
return new ActionRowBuilder().addComponents(first, prev, next, last, stop);
|
||
}
|
||
|
||
function buildJumpMenu(page, totalPages, disabled = false) {
|
||
// Up to 25 options allowed by Discord — group pages in chunks
|
||
const options = [];
|
||
for (let p = 0; p < totalPages && options.length < 25; p++) {
|
||
options.push({
|
||
label: `Page ${p + 1}`,
|
||
value: String(p),
|
||
description: `Tracks ${p * PAGE_SIZE + 1}–${Math.min(
|
||
(p + 1) * PAGE_SIZE,
|
||
Math.max(0, totalPages * PAGE_SIZE)
|
||
)}`,
|
||
default: p === page,
|
||
});
|
||
}
|
||
const menu = new StringSelectMenuBuilder()
|
||
.setCustomId("queue_jump")
|
||
.setPlaceholder("Jump to page…")
|
||
.setDisabled(disabled)
|
||
.addOptions(options);
|
||
|
||
return new ActionRowBuilder().addComponents(menu);
|
||
}
|
||
|
||
module.exports = {
|
||
data: new SlashCommandBuilder()
|
||
.setName("queue")
|
||
.setDescription("Show the current music queue (live, paginated)."),
|
||
category: "Music",
|
||
|
||
async execute(interaction, client) {
|
||
try {
|
||
await interaction.deferReply();
|
||
|
||
const queue = requireQueue(client, interaction);
|
||
const upcomingCount = Math.max(0, queue.songs.length - 1);
|
||
let totalPages = Math.max(1, Math.ceil(upcomingCount / PAGE_SIZE));
|
||
let page = 0;
|
||
|
||
let fingerprint = queueFingerprint(queue);
|
||
|
||
const embed = buildEmbed(queue, page, totalPages);
|
||
const rowButtons = buildButtons(page, totalPages);
|
||
const rowJump = buildJumpMenu(page, totalPages);
|
||
|
||
const message = await interaction.followUp({
|
||
embeds: [embed],
|
||
components: [rowButtons, rowJump],
|
||
});
|
||
|
||
// Live refresh loop (throttled)
|
||
let stopped = false;
|
||
const interval = setInterval(async () => {
|
||
if (stopped) return;
|
||
const q = client.distube.getQueue(interaction.guildId);
|
||
if (!q) return; // might have ended
|
||
const fp = queueFingerprint(q);
|
||
if (fp !== fingerprint) {
|
||
fingerprint = fp;
|
||
// recompute pagination info if size changed
|
||
const upCount = Math.max(0, q.songs.length - 1);
|
||
totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE));
|
||
if (page > totalPages - 1) page = totalPages - 1;
|
||
|
||
const newEmbed = buildEmbed(q, page, totalPages);
|
||
const newButtons = buildButtons(page, totalPages);
|
||
const newJump = buildJumpMenu(page, totalPages);
|
||
try {
|
||
await message.edit({
|
||
embeds: [newEmbed],
|
||
components: [newButtons, newJump],
|
||
});
|
||
} catch {}
|
||
}
|
||
}, REFRESH_INTERVAL_MS);
|
||
|
||
// Component collector (buttons + select menu)
|
||
const collector = message.createMessageComponentCollector({
|
||
componentType: ComponentType.MessageComponent,
|
||
filter: (i) => i.user.id === interaction.user.id,
|
||
idle: COLLECTOR_IDLE_MS,
|
||
time: COLLECTOR_IDLE_MS * 3,
|
||
});
|
||
|
||
collector.on("collect", async (i) => {
|
||
try {
|
||
if (i.customId === "queue_stop") {
|
||
collector.stop("stopped");
|
||
return i.update({
|
||
components: [
|
||
buildButtons(page, totalPages, true),
|
||
buildJumpMenu(page, totalPages, true),
|
||
],
|
||
});
|
||
}
|
||
|
||
if (i.customId === "queue_jump" && i.isStringSelectMenu()) {
|
||
const choice = Number(i.values?.[0] ?? 0);
|
||
page = Math.min(Math.max(0, choice), totalPages - 1);
|
||
} else if (i.customId === "queue_first") page = 0;
|
||
else if (i.customId === "queue_prev") page = Math.max(0, page - 1);
|
||
else if (i.customId === "queue_next")
|
||
page = Math.min(totalPages - 1, page + 1);
|
||
else if (i.customId === "queue_last") page = totalPages - 1;
|
||
|
||
const q = client.distube.getQueue(interaction.guildId) ?? queue;
|
||
const upCount = Math.max(0, q.songs.length - 1);
|
||
totalPages = Math.max(1, Math.ceil(upCount / PAGE_SIZE));
|
||
if (page > totalPages - 1) page = totalPages - 1;
|
||
|
||
const newEmbed = buildEmbed(q, page, totalPages);
|
||
const newButtons = buildButtons(page, totalPages);
|
||
const newJump = buildJumpMenu(page, totalPages);
|
||
await i.update({
|
||
embeds: [newEmbed],
|
||
components: [newButtons, newJump],
|
||
});
|
||
} catch (err) {
|
||
console.error("queue component update failed:", err);
|
||
try {
|
||
await i.deferUpdate();
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
collector.on("end", async () => {
|
||
stopped = true;
|
||
clearInterval(interval);
|
||
try {
|
||
await message.edit({
|
||
components: [
|
||
buildButtons(page, totalPages, true),
|
||
buildJumpMenu(page, totalPages, true),
|
||
],
|
||
});
|
||
} catch {}
|
||
});
|
||
} catch (e) {
|
||
const msg = e?.message ?? "❌ Failed to show queue.";
|
||
if (interaction.deferred || interaction.replied) {
|
||
await interaction.followUp({ content: msg, ephemeral: true });
|
||
} else {
|
||
await interaction.reply({ content: msg, ephemeral: true });
|
||
}
|
||
}
|
||
},
|
||
};
|