feat: music improvements (#225)
All checks were successful
Publish latest version / build (push) Successful in 1m7s

Close #223

Kinda refactor logic around buttons, now adding buttons to music

Reviewed-on: #225
Co-authored-by: Mylloon <kennel.anri@tutanota.com>
Co-committed-by: Mylloon <kennel.anri@tutanota.com>
This commit is contained in:
Mylloon 2025-03-06 22:49:58 +01:00 committed by Mylloon
parent ae2f1a7d0c
commit c3d4ab4ebf
Signed by: Forgejo
GPG key ID: E72245C752A07631
21 changed files with 361 additions and 115 deletions

View file

@ -209,6 +209,8 @@ même message.
Contrairement aux autres éléments, les boutons doivent se faire collecter via
la fonction [`collect`](./src/buttons/loader.ts#L46) juste après leur déclaration.
Aussi, le payload des boutons est défini dans la classe [ButtonAction](./src/utils/monads/ButtonAction.ts).
## Autocomplétion
La réponse qu'attend Discord doit se faire obligatoirement sous 3 secondes.

View file

@ -8,6 +8,7 @@ import {
MessageFlags,
} from "discord.js";
import { getLocale } from "../utils/locales";
import { ButtonActionTypes } from "../utils/monads/ButtonAction";
export default async (client: Client) => {
// Dossier des buttons
@ -74,7 +75,16 @@ export const collect = (
}
const msg = await button?.interaction(i, client);
if (msg !== undefined) {
await i.update(msg);
const result = msg.unwrap();
switch (result.type) {
case ButtonActionTypes.RemoveComponents:
message.edit({ components: [] });
break;
case ButtonActionTypes.EditMessage:
await i.update(result.message);
break;
}
}
});
};

View file

@ -10,6 +10,8 @@ import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { embedListReminders } from "../../utils/commands/reminder";
import { collect } from "../loader";
import { reminderListNext, reminderListPrec } from "../../utils/constants";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
export default {
data: {
@ -41,8 +43,8 @@ export default {
interaction.locale,
);
const idPrec = "reminderList-prec_" + uuidv4();
const idNext = "reminderList-next_" + uuidv4();
const idPrec = reminderListPrec + uuidv4();
const idNext = reminderListNext + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
@ -61,9 +63,12 @@ export default {
collect(client, interaction, idPrec, interaction.message);
collect(client, interaction, idNext, interaction.message);
return {
embeds: [list],
components: [row],
};
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [list],
components: [row],
},
});
},
};

View file

@ -10,6 +10,8 @@ import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { embedListReminders } from "../../utils/commands/reminder";
import { collect } from "../loader";
import { reminderListNext, reminderListPrec } from "../../utils/constants";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
export default {
data: {
@ -41,8 +43,8 @@ export default {
interaction.locale,
);
const idPrec = "reminderList-prec_" + uuidv4();
const idNext = "reminderList-next_" + uuidv4();
const idPrec = reminderListPrec + uuidv4();
const idNext = reminderListNext + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
@ -61,9 +63,12 @@ export default {
collect(client, interaction, idPrec, interaction.message);
collect(client, interaction, idNext, interaction.message);
return {
embeds: [list],
components: [row],
};
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [list],
components: [row],
},
});
},
};

View file

@ -0,0 +1,52 @@
import { Client, MessageComponentInteraction } from "discord.js";
import { v4 as uuidv4 } from "uuid";
import { getFilename } from "../../utils/misc";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
import {
embedLyrics,
embedNowPlaying,
findLyricsFromPlayer,
musicButtons,
} from "../../utils/commands/music";
import { useMainPlayer, useQueue } from "discord-player";
import { getLocale } from "../../utils/locales";
import {
discord_limit_embed_per_message,
musicLyrics,
musicPlayResume,
} from "../../utils/constants";
import { collect } from "../loader";
export default {
data: {
name: getFilename(__filename),
},
interaction: async (interaction: MessageComponentInteraction, client: Client) => {
const queue = useQueue(interaction.guildId!);
if (queue && queue.currentTrack && interaction.channel?.isSendable()) {
const loc = getLocale(client, interaction.locale);
const idPauseResume = musicPlayResume + uuidv4();
const idLyrics = musicLyrics + uuidv4();
collect(client, interaction, idPauseResume, interaction.message);
collect(client, interaction, idLyrics, interaction.message);
const data = await findLyricsFromPlayer(useMainPlayer(), queue.currentTrack);
const embeds = embedLyrics(data[0]);
interaction.channel?.send({ embeds: embeds.slice(0, discord_limit_embed_per_message) });
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [embedNowPlaying(queue.currentTrack, loc)],
components: [musicButtons(loc, queue, idPauseResume, idLyrics)],
},
});
}
interaction.deferUpdate({ withResponse: false });
return new ButtonAction({ type: ButtonActionTypes.RemoveComponents });
},
};

View file

@ -0,0 +1,41 @@
import { useQueue } from "discord-player";
import { Client, MessageComponentInteraction } from "discord.js";
import { v4 as uuidv4 } from "uuid";
import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { embedNowPlaying, musicButtons, toggleMusicPause } from "../../utils/commands/music";
import { collect } from "../loader";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
import { musicLyrics, musicPlayResume } from "../../utils/constants";
export default {
data: {
name: getFilename(__filename),
},
interaction: async (interaction: MessageComponentInteraction, client: Client) => {
// Get queue
const queue = useQueue(interaction.guildId!);
if (queue && queue.currentTrack) {
const loc = getLocale(client, interaction.locale);
const idPauseResume = musicPlayResume + uuidv4();
const idLyrics = musicLyrics + uuidv4();
toggleMusicPause(queue);
collect(client, interaction, idPauseResume, interaction.message);
collect(client, interaction, idLyrics, interaction.message);
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [embedNowPlaying(queue.currentTrack, loc)],
components: [musicButtons(loc, queue, idPauseResume, idLyrics)],
},
});
}
interaction.deferUpdate({ withResponse: false });
return new ButtonAction({ type: ButtonActionTypes.RemoveComponents });
},
};

View file

@ -12,6 +12,8 @@ import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { collect } from "../loader";
import { embedListQueue } from "../../utils/commands/music";
import { musicQueueNext, musicQueuePrec } from "../../utils/constants";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
export default {
data: {
@ -40,8 +42,8 @@ export default {
embedListQueue(client, embed, queue, page, interaction.locale);
// Create buttons
const idPrec = "queueList-prec_" + uuidv4();
const idNext = "queueList-next_" + uuidv4();
const idPrec = musicQueuePrec + uuidv4();
const idNext = musicQueueNext + uuidv4();
rows.push(
new ActionRowBuilder<ButtonBuilder>()
.addComponents(
@ -65,9 +67,13 @@ export default {
// In case queue doesn't exists
embed.setDescription(loc.get("c_queue2"));
}
return {
embeds: [embed],
components: rows,
};
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [embed],
components: rows,
},
});
},
};

View file

@ -12,6 +12,8 @@ import { getLocale } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { collect } from "../loader";
import { embedListQueue } from "../../utils/commands/music";
import { musicQueueNext, musicQueuePrec } from "../../utils/constants";
import { ButtonAction, ButtonActionTypes } from "../../utils/monads/ButtonAction";
export default {
data: {
@ -40,8 +42,8 @@ export default {
embedListQueue(client, embed, queue, page, interaction.locale);
// Create buttons
const idPrec = "queueList-prec_" + uuidv4();
const idNext = "queueList-next_" + uuidv4();
const idPrec = musicQueuePrec + uuidv4();
const idNext = musicQueueNext + uuidv4();
rows.push(
new ActionRowBuilder<ButtonBuilder>()
.addComponents(
@ -65,9 +67,13 @@ export default {
// In case queue doesn't exists
embed.setDescription(loc.get("c_queue2"));
}
return {
embeds: [embed],
components: rows,
};
return new ButtonAction({
type: ButtonActionTypes.EditMessage,
message: {
embeds: [embed],
components: rows,
},
});
},
};

View file

@ -21,6 +21,7 @@ import {
getReminderInfo,
newReminder,
} from "../../utils/commands/reminder";
import { reminderListNext, reminderListPrec } from "../../utils/constants";
export default {
scope: () => [],
@ -218,8 +219,8 @@ export default {
interaction.locale,
);
const idPrec = "reminderList-prec_" + uuidv4();
const idNext = "reminderList-next_" + uuidv4();
const idPrec = reminderListPrec + uuidv4();
const idNext = reminderListNext + uuidv4();
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()

View file

@ -1,16 +1,10 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { useMainPlayer, useQueue } from "discord-player";
import {
ChatInputCommandInteraction,
Client,
EmbedBuilder,
Message,
MessageFlags,
} from "discord.js";
import { ChatInputCommandInteraction, Client, Message, MessageFlags } from "discord.js";
import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { discord_limit_message } from "../../utils/constants";
import { RegexC } from "../../utils/regex";
import { discord_limit_embed_per_message, discord_limit_message } from "../../utils/constants";
import { embedLyrics, findLyricsFromPlayer } from "../../utils/commands/music";
export default {
scope: () => [],
@ -115,11 +109,8 @@ export default {
} else if (queue) {
const track = queue.history.currentTrack;
if (track) {
request = track.cleanTitle + " " + track.author.replaceAll(RegexC(/ - Topic$/), "");
try {
data = await player.lyrics.search({
q: request,
});
data = await findLyricsFromPlayer(player, track);
} catch {
return await interaction.followUp(`❌ | ${loc.get("c_lyrics2")} \`${track.title}\``);
}
@ -205,52 +196,16 @@ export default {
}
if (data) {
if (data.length === 0 || (data.length > 0 && data[0].plainLyrics !== null)) {
if (data.length === 0 || (data.length > 0 && data[0].plainLyrics == null)) {
return await interaction.followUp(`❌ | ${loc.get("c_lyrics2")} \`${request}\``);
}
const title = data[0];
const limit_desc = 4096;
const nb_embed = Math.ceil(title.plainLyrics.length / limit_desc);
const embeds = embedLyrics(data[0]);
// https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/186
// TODO: If lyrics < 6000, only send one message with multiples embed
for (let i = 0, j = 0; i < nb_embed; i++, j += limit_desc) {
// + Better cut in lyrics
const lyrics = title.plainLyrics.slice(j, j + limit_desc);
let embed;
switch (i) {
case 0: {
// First embed
embed = new EmbedBuilder();
embed
.setTitle(title.trackName)
.setAuthor({ name: title.artistName })
.setDescription(lyrics);
break;
}
case nb_embed - 1: {
// Footer of last embed in case of multiple embed
embed = new EmbedBuilder().setDescription(lyrics).setFooter({
text: `${title.artistName} · ${title.trackName}`,
});
break;
}
default: {
// Embed with only lyrics in case of multiple embed
embed = new EmbedBuilder().setDescription(lyrics);
break;
}
}
// Send embed
await interaction.followUp({ embeds: [embed] });
}
return;
// TODO: Send multiple messages if there is too many embeds, instead of not sending the end
return await interaction.followUp({
embeds: embeds.slice(0, discord_limit_embed_per_message),
});
}
return await interaction.followUp(`❌ | ${loc.get("c_lyrics1")}`);

View file

@ -3,6 +3,7 @@ import { useQueue } from "discord-player";
import { ChatInputCommandInteraction, Client, EmbedBuilder } from "discord.js";
import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { QueueStatus, toggleMusicPause } from "../../utils/commands/music";
export default {
scope: () => [],
@ -27,14 +28,10 @@ export default {
if (queue) {
const embed = new EmbedBuilder();
if (queue.node.isPaused()) {
queue.node.resume();
if (toggleMusicPause(queue) == QueueStatus.Play) {
embed.setDescription(loc.get("c_pause1"));
return await interaction.reply({ embeds: [embed] });
} else {
queue.node.pause();
embed.setDescription(loc.get("c_pause2"));
return await interaction.reply({ embeds: [embed] });
}

View file

@ -132,7 +132,7 @@ export default {
defaultFFmpegFilters: ["silenceremove"],
leaveOnEndCooldown: 15000,
metadata: {
channel: interaction.channel,
interaction: interaction,
} as Metadata,
});

View file

@ -13,6 +13,7 @@ import { collect } from "../../buttons/loader";
import { getLocale, getLocalizations } from "../../utils/locales";
import { getFilename } from "../../utils/misc";
import { embedListQueue } from "../../utils/commands/music";
import { musicQueueNext, musicQueuePrec } from "../../utils/constants";
export default {
scope: () => [],
@ -109,8 +110,8 @@ export default {
embedListQueue(client, embed, queue, page, interaction.locale);
const rows = [];
const idPrec = "queueList-prec_" + uuidv4();
const idNext = "queueList-next_" + uuidv4();
const idPrec = musicQueuePrec + uuidv4();
const idNext = musicQueueNext + uuidv4();
rows.push(
new ActionRowBuilder<ButtonBuilder>()
.addComponents(

View file

@ -1,26 +1,29 @@
import { EmbedBuilder } from "@discordjs/builders";
import { GuildQueue, Track } from "discord-player";
import { Client } from "discord.js";
import { Metadata } from "../../utils/metadata";
import { emojiPng } from "../../utils/misc";
import { v4 as uuidv4 } from "uuid";
import { collect } from "../../buttons/loader";
import { embedNowPlaying, musicButtons } from "../../utils/commands/music";
import { getLocale } from "../../utils/locales";
import { musicLyrics, musicPlayResume } from "../../utils/constants";
/** https://discord-player.js.org/docs/types/discord-player/GuildQueueEvents */
export default (queue: GuildQueue<Metadata>, track: Track, client: Client) => {
const loc_default = client.locales.get(client.config.default_lang);
export default async (queue: GuildQueue<Metadata>, track: Track, client: Client) => {
const loc = getLocale(client, queue.metadata.interaction?.locale);
const embed = new EmbedBuilder()
.setDescription(`${loc_default?.get("e_trackstart1")} ${track.requestedBy}`)
.setTitle(track.title + " • " + track.author)
.setURL(track.url)
.setThumbnail(track.thumbnail)
.setFooter({
text: `${loc_default?.get("e_trackstart2")} ${
track.duration
} via ${track.source.capitalize()}`,
iconURL: emojiPng("🎶"),
const embed = embedNowPlaying(track, loc);
const idPauseResume = musicPlayResume + uuidv4();
const idLyrics = musicLyrics + uuidv4();
if (queue.metadata.interaction?.channel?.isSendable()) {
const message = await queue.metadata?.interaction?.channel.send({
embeds: [embed],
components: [musicButtons(loc, queue, idPauseResume, idLyrics)],
});
if (queue.metadata.channel?.isSendable()) {
queue.metadata?.channel?.send({ embeds: [embed] });
// Buttons interactions
collect(client, queue.metadata.interaction!, idPauseResume, message);
collect(client, queue.metadata.interaction!, idLyrics, message);
}
};

View file

@ -108,9 +108,11 @@
"c_pause_name": "pause",
"c_pause_desc": "Pauses or restarts music",
"c_pause1": "Resuming music...",
"c_pause1": "Resuming music.",
"c_pause2": "Pause the music.",
"c_pause3": "The bot is not playing anything right now.",
"c_pause4": "Pause",
"c_pause5": "Resume",
"c_queue_name": "queue",
"c_queue_desc": "Command relative to the music queue",

View file

@ -108,9 +108,11 @@
"c_pause_name": "pause",
"c_pause_desc": "Met en pause ou relance la musique",
"c_pause1": "Relance la musique...",
"c_pause1": "Relance la musique.",
"c_pause2": "Met en pause la musique.",
"c_pause3": "Le bot ne joue rien en ce moment.",
"c_pause4": "Pause",
"c_pause5": "Reprend",
"c_queue_name": "queue",
"c_queue_desc": "Commande relative à la file d'attente des musiques",

View file

@ -1,6 +1,7 @@
import { SlashCommandBuilder } from "@discordjs/builders";
import { Collection } from "discord.js";
import { Database } from "sqlite3";
import { ButtonAction } from "../utils/monads/ButtonAction";
export {};
@ -58,7 +59,7 @@ declare module "discord.js" {
interaction: (
interaction: MessageComponentInteraction,
client: Client,
) => Promise<InteractionUpdateOptions>;
) => Promise<ButtonAction>;
}
>;
};

View file

@ -1,9 +1,10 @@
import { EmbedBuilder } from "@discordjs/builders";
import { GuildQueue, QueueRepeatMode } from "discord-player";
import { Client } from "discord.js";
import { GuildQueue, LrcSearchResult, Player, QueueRepeatMode, Track } from "discord-player";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client } from "discord.js";
import { getLocale } from "../locales";
import { blank } from "../misc";
import { discord_limit_embed_field } from "../constants";
import { blank, emojiPng } from "../misc";
import { discord_limit_embed_description_length, discord_limit_embed_field } from "../constants";
import { RegexC } from "../regex";
export const embedListQueue = (
client: Client,
@ -58,3 +59,107 @@ const printRepeatMode = (mode: QueueRepeatMode, loc: Map<string, string>) => {
break;
}
};
export enum QueueStatus {
/** Currently playing */
Play = 0,
/** Currently in pause */
Pause = 1,
}
/**
* Change the status of the queue
* @param queue Queue
* @returns Current status, after the changes
*/
export const toggleMusicPause = (queue: GuildQueue) => {
if (queue.node.isPaused()) {
queue.node.resume();
return QueueStatus.Play;
}
queue.node.pause();
return QueueStatus.Pause;
};
export const embedNowPlaying = (track: Track, loc: Map<string, string>) => {
return new EmbedBuilder()
.setDescription(`${loc?.get("e_trackstart1")} ${track.requestedBy}`)
.setTitle(track.title + " • " + track.author)
.setURL(track.url)
.setThumbnail(track.thumbnail)
.setFooter({
text: `${loc?.get("e_trackstart2")} ${track.duration} via ${track.source.capitalize()}`,
iconURL: emojiPng("🎶"),
});
};
export const embedLyrics = (title: LrcSearchResult) => {
const nb_embed = Math.ceil(title.plainLyrics.length / discord_limit_embed_description_length);
/* https://git.mylloon.fr/ConfrerieDuKassoulait/Botanique/issues/186
* TODO: If lyrics < 6000, only send one message with multiples embed
* + Better cut in lyrics */
const embeds = [];
for (let i = 0, j = 0; i < nb_embed; i++, j += discord_limit_embed_description_length) {
const lyrics = title.plainLyrics.slice(j, j + discord_limit_embed_description_length);
const embed = new EmbedBuilder();
switch (i) {
case 0: {
// First embed
embed
.setTitle(title.trackName)
.setAuthor({ name: title.artistName })
.setDescription(lyrics);
break;
}
case nb_embed - 1: {
// Footer of last embed in case of multiple embed
embed.setDescription(lyrics).setFooter({
text: `${title.artistName} · ${title.trackName}`,
});
break;
}
default: {
// Embed with only lyrics in case of multiple embed
embed.setDescription(lyrics);
break;
}
}
embeds.push(embed);
}
return embeds;
};
export const findLyricsFromPlayer = async (player: Player, track: Track) => {
const request = track.cleanTitle + " " + track.author.replace(RegexC(/ - Topic$/), "");
return await player.lyrics.search({
q: request,
});
};
export const musicButtons = (
loc: Map<string, string>,
queue: GuildQueue,
idPauseResume: string,
idLyrics: string,
) => {
return new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(idPauseResume)
.setLabel(queue.node.isPaused() ? loc.get("c_pause5")! : loc.get("c_pause4")!)
.setStyle(queue.node.isPaused() ? ButtonStyle.Primary : ButtonStyle.Secondary),
)
.addComponents(
new ButtonBuilder()
.setCustomId(idLyrics)
.setLabel(loc.get("c_lyrics_name")!.capitalize())
.setStyle(ButtonStyle.Secondary),
);
};

View file

@ -1,6 +1,9 @@
/** Max message length */
export const discord_limit_message = 2000;
/** Max embed description length */
export const discord_limit_embed_description_length = 4096;
/** Max embed field an embed can have */
export const discord_limit_embed_field = 25;
@ -9,3 +12,24 @@ export const discord_limit_autocompletion_list_length = 25;
/** Max length of an element in autocompletion of slash commands */
export const discord_limit_autocompletion_value_length = 100;
/** Max embeds per message */
export const discord_limit_embed_per_message = 10;
/** Last page in the music queue */
export const musicQueuePrec = "queueList-prec_";
/** Next page in the music queue */
export const musicQueueNext = "queueList-next_";
/** Last page in the reminder list */
export const reminderListPrec = "reminderList-prec_";
/** Next page in the reminder queue */
export const reminderListNext = "reminderList-next_";
/** Pause or resume the current track */
export const musicPlayResume = "playPauseResume_";
/** Print lyrics of the current track */
export const musicLyrics = "playLyrics_";

View file

@ -1,5 +1,5 @@
import { TextBasedChannel } from "discord.js";
import { ChatInputCommandInteraction } from "discord.js";
export type Metadata = {
channel: TextBasedChannel | null;
interaction: ChatInputCommandInteraction | null;
};

View file

@ -0,0 +1,28 @@
import { InteractionUpdateOptions } from "discord.js";
export enum ButtonActionTypes {
RemoveComponents,
EditMessage,
ChangeNothing,
}
type UnitType = {
type: ButtonActionTypes.RemoveComponents | ButtonActionTypes.ChangeNothing;
};
type MessageType = {
type: ButtonActionTypes.EditMessage;
message: InteractionUpdateOptions;
};
type ButtonActionPossibilities = UnitType | MessageType;
export class ButtonAction {
constructor(private readonly action: ButtonActionPossibilities) {
this.action = action;
}
unwrap(): ButtonActionPossibilities {
return this.action;
}
}