feat: add resource checker prompts and display component types for enhanced component management

This commit is contained in:
2025-10-03 18:17:43 -05:00
parent e32dff0a4d
commit 76d4f57e77
7 changed files with 2380 additions and 2181 deletions

View File

@@ -0,0 +1,41 @@
# Prompt: Node.js Resource Checker (Memory & CPU)
Whenever Copilot generates, modifies, or reviews code in Node.js:
1. **Memory Monitoring**
- Always remind to check memory usage with:
```js
logger.info(process.memoryUsage());
```
which returns RSS, Heap Used, Heap Total, External, and Array Buffers.
- If `Heap Used` grows continuously without freeing, suggest potential memory leaks (unreleased timers, listeners, or large cached data).
- If `RSS` (Resident Set Size) is unusually higher than `Heap Total`, warn that external/native modules might be consuming memory.
2. **CPU Usage**
- Remind to check CPU load using:
```bash
top -p <pid>
```
or Node.js profiling (`--inspect`, `clinic flame`).
- Flag infinite loops, synchronous blocking calls, or heavy computations running on the main thread as potential CPU overuse.
- Encourage offloading heavy tasks to Workers, child processes, or queues when appropriate.
3. **Best Practices**
- Always validate memory and CPU usage after implementing new features.
- Suggest garbage collection checks (`global.gc()`) in development when Node is run with `--expose-gc`.
- Warn if long-living references (e.g., large Maps, Sets, caches, or listeners) might not be released.
- Highlight that CPU and memory efficiency are as important as correctness.
4. **Output Analysis**
- When given memory stats like:
```
RSS: 186.9MB
Heap Used: 67.6MB
Heap Total: 71.2MB
External: 5.0MB
```
- Compare `Heap Used` vs `Heap Total`: if close to the limit, risk of OOM.
- Compare `RSS` vs `Heap Total`: if RSS is much larger, check for native module or buffer leaks.
- If growth is unbounded, warn about potential memory leaks.
5. **Always remind** to rerun tests with `npx tsc --noEmit` (for type safety) and memory checks together, ensuring both correctness and performance.

File diff suppressed because it is too large Load Diff

View File

@@ -1,217 +1,411 @@
import {
Message,
ButtonInteraction,
StringSelectMenuInteraction,
MessageComponentInteraction,
ComponentType,
ButtonStyle,
APIEmbed
} from "discord.js";
import { CommandMessage } from "../../../core/types/commands"; import { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client";
import type { JsonValue } from "@prisma/client/runtime/library";
interface BlockItem {
name: string;
id: string;
}
interface ActionRowBuilder {
type: ComponentType.ActionRow;
components: any[];
}
export const command: CommandMessage = { export const command: CommandMessage = {
name: "eliminar-embed", name: "eliminar-embed",
type: "message", type: "message",
aliases: ["embed-eliminar", "borrar-embed", "embeddelete"], aliases: ["embed-eliminar", "borrar-embed", "embeddelete"],
cooldown: 10, cooldown: 10,
run: async (message: any, args: string[], client: any) => { description: "Elimina bloques DisplayComponents del servidor",
category: "Alianzas",
usage: "eliminar-embed [nombre_bloque]",
run: async (message: Message, args: string[], client: Amayo): Promise<void> => {
if (!message.member?.permissions.has("Administrator")) { if (!message.member?.permissions.has("Administrator")) {
await message.reply("❌ No tienes permisos de Administrador."); await message.reply("❌ No tienes permisos de Administrador.");
return; return;
} }
// Obtener todos los bloques del servidor // If specific block name provided, handle direct deletion
const blocks = await client.prisma.blockV2Config.findMany({ if (args.length > 0) {
where: { guildId: message.guildId! }, const blockName = args.join(" ").trim();
select: { name: true, id: true } await handleDirectDeletion(message, client, blockName);
});
if (blocks.length === 0) {
const noBlocksEmbed = {
color: 0xf04747,
title: "🗂️ Panel de Eliminación de Bloques",
description: "📭 **No hay bloques disponibles**\n\nNo se encontraron bloques para eliminar en este servidor.\n\nPuedes crear nuevos bloques usando `!blockcreate`.",
footer: {
text: "Sistema de gestión de bloques • Amayo Bot"
}
};
await message.reply({
embeds: [noBlocksEmbed]
});
return; return;
} }
// Crear opciones para el select menu // Otherwise, show interactive panel
const selectOptions = blocks.slice(0, 25).map((block: any, index: number) => ({ await showDeletionPanel(message, client);
label: block.name,
value: block.name,
description: `ID: ${block.id}`,
emoji: index < 10 ? { name: `${index + 1}️⃣` } : { name: "📄" }
}));
// Crear embed principal de eliminación
const deleteEmbed = {
color: 0xff6b35,
title: "🗑️ Panel de Eliminación de Bloques",
description: `📊 **${blocks.length} bloque(s) encontrado(s)**\n\n⚠ **ADVERTENCIA:** La eliminación es permanente e irreversible.\n\nSelecciona el bloque que deseas eliminar del menú de abajo:`,
footer: {
text: "Selecciona un bloque para eliminar • Timeout: 5 minutos"
}
};
const actionRow = {
type: 1,
components: [
{
type: 3, // StringSelect
custom_id: "delete_block_select",
placeholder: "🗑️ Selecciona un bloque para eliminar...",
min_values: 1,
max_values: 1,
options: selectOptions
}
]
};
const cancelRow = {
type: 1,
components: [
{
type: 2, // Button
style: 4, // Danger
label: "❌ Cancelar",
custom_id: "cancel_delete"
}
]
};
const panelMessage = await message.reply({
embeds: [deleteEmbed],
components: [actionRow, cancelRow]
});
const collector = panelMessage.createMessageComponentCollector({
time: 300000, // 5 minutos
filter: (i: any) => i.user.id === message.author.id
});
collector.on("collect", async (interaction: any) => {
if (interaction.customId === "cancel_delete") {
const canceledEmbed = {
color: 0x36393f,
title: "❌ Operación Cancelada",
description: "La eliminación de bloques ha sido cancelada.\nNingún bloque fue eliminado.",
footer: { text: "Operación cancelada por el usuario" }
};
await interaction.update({
embeds: [canceledEmbed],
components: []
});
collector.stop("cancelled");
return;
}
if (interaction.customId === "delete_block_select" && interaction.isStringSelectMenu()) {
const selectedBlock = interaction.values[0];
const confirmationEmbed = {
color: 0xf04747,
title: "⚠️ CONFIRMAR ELIMINACIÓN",
description: `🗑️ **Bloque a eliminar:** \`${selectedBlock}\`\n\n❗ **ESTA ACCIÓN ES IRREVERSIBLE**\n\nUna vez eliminado, no podrás recuperar:\n• Toda la configuración del bloque\n• Los componentes y contenido\n• Las imágenes y colores personalizados\n\n¿Estás seguro de que quieres continuar?`,
footer: { text: "⚠️ Acción irreversible - Piénsalo bien" }
};
const confirmationRow = {
type: 1,
components: [
{
type: 2,
style: 4, // Danger
label: "🗑️ SÍ, ELIMINAR",
custom_id: `confirm_delete_${selectedBlock}`
},
{
type: 2,
style: 2, // Secondary
label: "↩️ Volver Atrás",
custom_id: "back_to_selection"
}
]
};
await interaction.update({
embeds: [confirmationEmbed],
components: [confirmationRow]
});
return;
}
if (interaction.customId.startsWith("confirm_delete_")) {
const blockName = interaction.customId.replace("confirm_delete_", "");
try {
await client.prisma.blockV2Config.delete({
where: {
guildId_name: {
guildId: message.guildId!,
name: blockName,
},
},
});
const successEmbed = {
color: 0x57f287,
title: "✅ Eliminación Exitosa",
description: `🗑️ **Bloque eliminado:** \`${blockName}\`\n\n✨ El bloque ha sido eliminado permanentemente de la base de datos.\n\n📋 Para ver los bloques restantes, usa: \`!embedlist\`\n📝 Para crear un nuevo bloque, usa: \`!blockcreate\``,
footer: { text: "Bloque eliminado exitosamente" }
};
await interaction.update({
embeds: [successEmbed],
components: []
});
collector.stop("success");
} catch (error) {
const errorEmbed = {
color: 0xf04747,
title: "❌ Error en la Eliminación",
description: `🔍 **Bloque no encontrado:** \`${blockName}\`\n\n💭 Posibles causas:\n• El bloque ya fue eliminado\n• Error de conexión con la base de datos\n• El nombre del bloque cambió\n\n🔄 Intenta refrescar la lista con \`!embedlist\``,
footer: { text: "Error de eliminación" }
};
await interaction.update({
embeds: [errorEmbed],
components: []
});
collector.stop("error");
}
return;
}
if (interaction.customId === "back_to_selection") {
await interaction.update({
embeds: [deleteEmbed],
components: [actionRow, cancelRow]
});
return;
}
});
collector.on("end", async (collected: any, reason: string) => {
if (reason === "time") {
const timeoutEmbed = {
color: 0x36393f,
title: "⏰ Tiempo Agotado",
description: "El panel de eliminación ha expirado por inactividad.\nUsa el comando nuevamente si necesitas eliminar bloques.",
footer: { text: "Panel expirado por inactividad" }
};
try {
await panelMessage.edit({
embeds: [timeoutEmbed],
components: []
});
} catch (error) {
// Mensaje ya eliminado o error de edición
}
}
});
}, },
}; };
async function handleDirectDeletion(
message: Message,
client: Amayo,
blockName: string
): Promise<void> {
const block = await client.prisma.blockV2Config.findFirst({
where: {
guildId: message.guildId!,
name: blockName
}
});
if (!block) {
await message.reply(`❌ No se encontró un bloque llamado \`${blockName}\`.`);
return;
}
// Show confirmation for direct deletion
const confirmEmbed: APIEmbed = {
color: 0xff6b35,
title: "⚠️ Confirmar Eliminación",
description: `¿Estás seguro de que quieres eliminar el bloque \`${blockName}\`?\n\n**Esta acción es irreversible.**`,
footer: { text: "Confirma la eliminación usando los botones" }
};
const confirmRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "🗑️ Confirmar Eliminación",
custom_id: `confirm_delete_${block.id}`
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "❌ Cancelar",
custom_id: "cancel_delete"
}
]
};
const confirmMessage = await message.reply({
embeds: [confirmEmbed],
components: [confirmRow]
});
await handleConfirmationInteraction(confirmMessage, message, client, block);
}
async function showDeletionPanel(message: Message, client: Amayo): Promise<void> {
const blocks = await fetchBlocks(client, message.guildId!);
if (blocks.length === 0) {
await handleNoBlocks(message);
return;
}
const deleteEmbed = createDeletionEmbed(blocks);
const actionRow = createBlockSelectRow(blocks);
const cancelRow = createCancelRow();
const panelMessage = await message.reply({
embeds: [deleteEmbed],
components: [actionRow, cancelRow]
});
await handlePanelInteractions(panelMessage, message, client, blocks);
}
async function fetchBlocks(client: Amayo, guildId: string): Promise<BlockItem[]> {
return await client.prisma.blockV2Config.findMany({
where: { guildId },
select: { name: true, id: true },
orderBy: { name: 'asc' }
});
}
async function handleNoBlocks(message: Message): Promise<void> {
const noBlocksEmbed: APIEmbed = {
color: 0xf04747,
title: "🗂️ Panel de Eliminación de Bloques",
description: "📭 **No hay bloques disponibles**\n\nNo se encontraron bloques para eliminar en este servidor.\n\nPuedes crear nuevos bloques usando `!crear-embed`.",
footer: { text: "Sistema de gestión de bloques • Amayo Bot" }
};
await message.reply({
embeds: [noBlocksEmbed]
});
}
function createDeletionEmbed(blocks: BlockItem[]): APIEmbed {
return {
color: 0xff6b35,
title: "🗑️ Panel de Eliminación de Bloques",
description: `📊 **${blocks.length} bloque(s) encontrado(s)**\n\n⚠ **ADVERTENCIA:** La eliminación es permanente e irreversible.\n\nSelecciona el bloque que deseas eliminar del menú de abajo:`,
footer: { text: "Selecciona un bloque para eliminar • Timeout: 5 minutos" }
};
}
function createBlockSelectRow(blocks: BlockItem[]): ActionRowBuilder {
const selectOptions = blocks.slice(0, 25).map((block, index) => ({
label: block.name,
value: block.id, // Use ID instead of name for better uniqueness
description: `ID: ${block.id.slice(-8)}`,
emoji: index < 10 ? { name: `${index + 1}️⃣` } : { name: "📄" }
}));
return {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.StringSelect,
custom_id: "delete_block_select",
placeholder: "🗑️ Selecciona un bloque para eliminar...",
min_values: 1,
max_values: 1,
options: selectOptions
}
]
};
}
function createCancelRow(): ActionRowBuilder {
return {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "❌ Cancelar",
custom_id: "cancel_delete"
}
]
};
}
async function handlePanelInteractions(
panelMessage: Message,
originalMessage: Message,
client: Amayo,
blocks: BlockItem[]
): Promise<void> {
const collector = panelMessage.createMessageComponentCollector({
time: 300000, // 5 minutes
filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id
});
collector.on("collect", async (interaction: MessageComponentInteraction) => {
try {
if (interaction.isButton() && interaction.customId === "cancel_delete") {
await handleCancellation(interaction);
collector.stop();
} else if (interaction.isStringSelectMenu() && interaction.customId === "delete_block_select") {
const selectedBlockId = interaction.values[0];
const selectedBlock = blocks.find(b => b.id === selectedBlockId);
if (selectedBlock) {
await handleBlockSelection(interaction, client, selectedBlock);
collector.stop();
}
}
} catch (error) {
console.error("Error handling deletion interaction:", error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: "❌ Ocurrió un error al procesar la interacción.",
ephemeral: true
});
}
}
});
collector.on("end", async (collected, reason) => {
if (reason === "time") {
await handlePanelTimeout(panelMessage);
}
});
}
async function handleCancellation(interaction: ButtonInteraction): Promise<void> {
const canceledEmbed: APIEmbed = {
color: 0x36393f,
title: "❌ Operación Cancelada",
description: "La eliminación de bloques ha sido cancelada.\nNingún bloque fue eliminado.",
footer: { text: "Operación cancelada por el usuario" }
};
await interaction.update({
embeds: [canceledEmbed],
components: []
});
}
async function handleBlockSelection(
interaction: StringSelectMenuInteraction,
client: Amayo,
selectedBlock: BlockItem
): Promise<void> {
const confirmEmbed: APIEmbed = {
color: 0xff4444,
title: "⚠️ Confirmar Eliminación",
description: `¿Estás seguro de que quieres **eliminar permanentemente** el bloque?\n\n📄 **Nombre:** \`${selectedBlock.name}\`\n🔑 **ID:** \`${selectedBlock.id}\`\n\n❗ **Esta acción NO se puede deshacer.**`,
footer: { text: "Confirma tu decisión usando los botones" }
};
const confirmRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "🗑️ SÍ, ELIMINAR",
custom_id: `confirm_delete_${selectedBlock.id}`
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "❌ Cancelar",
custom_id: "cancel_delete_final"
}
]
};
await interaction.update({
embeds: [confirmEmbed],
components: [confirmRow]
});
// Handle final confirmation
const finalCollector = interaction.message.createMessageComponentCollector({
time: 60000, // 1 minute for final confirmation
filter: (i: MessageComponentInteraction) => i.user.id === interaction.user.id
});
finalCollector.on("collect", async (finalInteraction: ButtonInteraction) => {
try {
if (finalInteraction.customId === "cancel_delete_final") {
await handleCancellation(finalInteraction);
} else if (finalInteraction.customId === `confirm_delete_${selectedBlock.id}`) {
await executeBlockDeletion(finalInteraction, client, selectedBlock);
}
finalCollector.stop();
} catch (error) {
console.error("Error in final confirmation:", error);
}
});
finalCollector.on("end", async (collected, reason) => {
if (reason === "time") {
await handleConfirmationTimeout(interaction.message);
}
});
}
async function handleConfirmationInteraction(
confirmMessage: Message,
originalMessage: Message,
client: Amayo,
block: any
): Promise<void> {
const collector = confirmMessage.createMessageComponentCollector({
time: 60000, // 1 minute
filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id
});
collector.on("collect", async (interaction: ButtonInteraction) => {
try {
if (interaction.customId === "cancel_delete") {
await handleCancellation(interaction);
} else if (interaction.customId === `confirm_delete_${block.id}`) {
await executeBlockDeletion(interaction, client, { name: block.name, id: block.id });
}
collector.stop();
} catch (error) {
console.error("Error in confirmation interaction:", error);
}
});
collector.on("end", async (collected, reason) => {
if (reason === "time") {
await handleConfirmationTimeout(confirmMessage);
}
});
}
async function executeBlockDeletion(
interaction: ButtonInteraction,
client: Amayo,
block: BlockItem
): Promise<void> {
try {
// Delete the block from database
await client.prisma.blockV2Config.delete({
where: { id: block.id }
});
const successEmbed: APIEmbed = {
color: 0x57f287,
title: "✅ Bloque Eliminado",
description: `El bloque \`${block.name}\` ha sido eliminado exitosamente.\n\n🗑 **Operación completada**\n📄 **Bloque:** \`${block.name}\`\n🔑 **ID:** \`${block.id}\``,
footer: { text: "Bloque eliminado permanentemente" }
};
await interaction.update({
embeds: [successEmbed],
components: []
});
} catch (error) {
console.error("Error deleting block:", error);
const errorEmbed: APIEmbed = {
color: 0xf04747,
title: "❌ Error al Eliminar",
description: `No se pudo eliminar el bloque \`${block.name}\`.\n\nPor favor, inténtalo de nuevo más tarde.`,
footer: { text: "Error en la eliminación" }
};
await interaction.update({
embeds: [errorEmbed],
components: []
});
}
}
async function handlePanelTimeout(panelMessage: Message): Promise<void> {
const timeoutEmbed: APIEmbed = {
color: 0x36393f,
title: "⏰ Panel Expirado",
description: "El panel de eliminación ha expirado por inactividad.\n\nUsa `!eliminar-embed` para abrir un nuevo panel.",
footer: { text: "Panel expirado por inactividad" }
};
try {
await panelMessage.edit({
embeds: [timeoutEmbed],
components: []
});
} catch (error) {
console.log("Could not edit message on timeout, likely deleted");
}
}
async function handleConfirmationTimeout(confirmMessage: Message): Promise<void> {
const timeoutEmbed: APIEmbed = {
color: 0x36393f,
title: "⏰ Confirmación Expirada",
description: "La confirmación ha expirado por inactividad.\nLa eliminación ha sido cancelada.",
footer: { text: "Confirmación expirada" }
};
try {
await confirmMessage.edit({
embeds: [timeoutEmbed],
components: []
});
} catch (error) {
console.log("Could not edit confirmation message on timeout");
}
}

View File

@@ -1,402 +1,483 @@
import {
Message,
ButtonInteraction,
StringSelectMenuInteraction,
MessageComponentInteraction,
ComponentType,
ButtonStyle,
APIButtonComponent,
APIStringSelectComponent,
APIEmbed
} from "discord.js";
import { CommandMessage } from "../../../core/types/commands"; import { CommandMessage } from "../../../core/types/commands";
import type {
BlockConfig,
PaginationData
} from "../../../core/types/displayComponents";
import type Amayo from "../../../core/client";
import type { JsonValue } from "@prisma/client/runtime/library";
interface BlockListItem {
name: string;
id: string;
config: JsonValue; // Use Prisma's JsonValue type
}
interface ActionRowBuilder {
type: ComponentType.ActionRow;
components: (APIButtonComponent | APIStringSelectComponent)[];
}
export const command: CommandMessage = { export const command: CommandMessage = {
name: "lista-embeds", name: "lista-embeds",
type: "message", type: "message",
aliases: ["embeds", "ver-embeds", "embedlist"], aliases: ["embeds", "ver-embeds", "embedlist"],
cooldown: 10, cooldown: 10,
run: async (message: any, args: string[], client: any) => { description: "Muestra todos los bloques DisplayComponents configurados en el servidor",
category: "Alianzas",
usage: "lista-embeds",
run: async (message: Message, args: string[], client: Amayo): Promise<void> => {
// Permission check
if (!message.member?.permissions.has("Administrator")) { if (!message.member?.permissions.has("Administrator")) {
return message.reply("❌ No tienes permisos de Administrador."); await message.reply("❌ No tienes permisos de Administrador.");
}
const blocks = await client.prisma.blockV2Config.findMany({
where: { guildId: message.guildId! },
select: {
name: true,
id: true,
config: true
},
orderBy: { name: 'asc' }
});
if (blocks.length === 0) {
const emptyEmbed = {
color: 0x5865f2,
title: "📚 Centro de Gestión de Bloques",
description: "📭 **No hay bloques disponibles**\n\nEste servidor aún no tiene bloques configurados.\n\n🚀 **¿Quieres empezar?**\n• Usa `!crear-embed <nombre>` para crear tu primer bloque\n• Usa `!editar-embed <nombre>` para editar bloques existentes",
footer: { text: "Sistema de gestión de bloques • Amayo Bot" }
};
const createRow = {
type: 1,
components: [
{
type: 2,
style: 3,
label: "📝 Crear Primer Bloque",
custom_id: "show_create_help"
}
]
};
const helpMessage = await message.reply({
embeds: [emptyEmbed],
components: [createRow]
});
const helpCollector = helpMessage.createMessageComponentCollector({
time: 60000,
filter: (i: any) => i.user.id === message.author.id
});
helpCollector.on("collect", async (interaction: any) => {
if (interaction.customId === "show_create_help") {
const helpEmbed = {
color: 0x57f287,
title: "📖 Guía de Creación de Bloques",
description: "🔧 **Comandos disponibles:**\n\n• `!crear-embed <nombre>` - Crear nuevo bloque\n• `!editar-embed <nombre>` - Editar bloque existente\n• `!eliminar-embed <nombre>` - Eliminar bloque\n• `!lista-embeds` - Ver todos los bloques\n\n💡 **Tip:** Los bloques permiten crear interfaces modernas e interactivas.",
footer: { text: "Guía de comandos de creación" }
};
await interaction.update({
embeds: [helpEmbed],
components: []
});
}
});
return; return;
} }
// Dividir bloques en páginas de 5 const blocks = await fetchBlocks(client, message.guildId!);
const itemsPerPage = 5;
const totalPages = Math.ceil(blocks.length / itemsPerPage);
let currentPage = 0;
const generateBlockListEmbed = (page: number) => { if (blocks.length === 0) {
const startIndex = page * itemsPerPage; await handleEmptyBlocksList(message);
const endIndex = Math.min(startIndex + itemsPerPage, blocks.length); return;
const pageBlocks = blocks.slice(startIndex, endIndex); }
let blockListText = `📊 **Página ${page + 1} de ${totalPages}** (${blocks.length} total)\n\n`;
pageBlocks.forEach((block: any, index: number) => {
const globalIndex = startIndex + index + 1;
const componentsCount = Array.isArray(block.config?.components) ? block.config.components.length : 0;
const hasImage = block.config?.coverImage ? "🖼️" : "";
blockListText += `**${globalIndex}.** \`${block.name}\` ${hasImage}\n`;
blockListText += `${componentsCount} componente(s) • ID: ${block.id.slice(-8)}\n\n`;
});
return {
color: 0x5865f2,
title: "📚 Centro de Gestión de Bloques",
description: blockListText,
footer: { text: `Página ${page + 1}/${totalPages}${blocks.length} bloques total` }
};
};
const generateActionRows = (page: number) => {
const rows = [];
// Select menu para acciones rápidas
const currentPageBlocks = blocks.slice(page * itemsPerPage, (page + 1) * itemsPerPage);
if (currentPageBlocks.length > 0) {
const selectOptions = currentPageBlocks.map((block: any) => ({
label: block.name,
value: block.name,
description: `${Array.isArray(block.config?.components) ? block.config.components.length : 0} componente(s)`,
emoji: { name: "⚙️" }
}));
rows.push({
type: 1,
components: [
{
type: 3,
custom_id: "block_actions_select",
placeholder: "⚙️ Selecciona un bloque para gestionar...",
options: selectOptions
}
]
});
}
// Botones de navegación y acciones generales
const navigationRow: any = {
type: 1,
components: []
};
// Navegación
if (totalPages > 1) {
navigationRow.components.push({
type: 2,
style: 2,
label: "◀️ Anterior",
custom_id: "prev_page",
disabled: page === 0
});
navigationRow.components.push({
type: 2,
style: 2,
label: `${page + 1}/${totalPages}`,
custom_id: "page_info",
disabled: true
});
navigationRow.components.push({
type: 2,
style: 2,
label: "▶️ Siguiente",
custom_id: "next_page",
disabled: page === totalPages - 1
});
}
// Botón de refrescar
navigationRow.components.push({
type: 2,
style: 1,
label: "🔄 Refrescar",
custom_id: "refresh_list"
});
rows.push(navigationRow);
// Acciones principales
const actionsRow = {
type: 1,
components: [
{
type: 2,
style: 3,
label: "📝 Crear Nuevo",
custom_id: "show_create_commands"
},
{
type: 2,
style: 2,
label: "📋 Exportar Lista",
custom_id: "export_block_list"
},
{
type: 2,
style: 4,
label: "🗑️ Eliminar",
custom_id: "show_delete_commands"
}
]
};
rows.push(actionsRow);
return rows;
};
const pagination = createPagination(blocks, 0, 5);
const panelMessage = await message.reply({ const panelMessage = await message.reply({
embeds: [generateBlockListEmbed(currentPage)], embeds: [generateBlockListEmbed(pagination)],
components: generateActionRows(currentPage) components: generateActionRows(pagination)
}); });
const collector = panelMessage.createMessageComponentCollector({ await handleInteractions(panelMessage, message, client, pagination);
time: 600000,
filter: (i: any) => i.user.id === message.author.id
});
collector.on("collect", async (interaction: any) => {
switch (interaction.customId) {
case "prev_page":
if (currentPage > 0) {
currentPage--;
await interaction.update({
embeds: [generateBlockListEmbed(currentPage)],
components: generateActionRows(currentPage)
});
}
break;
case "next_page":
if (currentPage < totalPages - 1) {
currentPage++;
await interaction.update({
embeds: [generateBlockListEmbed(currentPage)],
components: generateActionRows(currentPage)
});
}
break;
case "refresh_list":
// Recargar datos
const refreshedBlocks = await client.prisma.blockV2Config.findMany({
where: { guildId: message.guildId! },
select: {
name: true,
id: true,
config: true
},
orderBy: { name: 'asc' }
});
blocks.length = 0;
blocks.push(...refreshedBlocks);
const newTotalPages = Math.ceil(blocks.length / itemsPerPage);
if (currentPage >= newTotalPages) {
currentPage = Math.max(0, newTotalPages - 1);
}
await interaction.update({
embeds: [generateBlockListEmbed(currentPage)],
components: generateActionRows(currentPage)
});
break;
case "block_actions_select":
if (interaction.isStringSelectMenu()) {
const selectedBlock = interaction.values[0];
const blockActionEmbed = {
color: 0xff9500,
title: `⚙️ Gestión de Bloque: \`${selectedBlock}\``,
description: "Selecciona la acción que deseas realizar con este bloque:",
footer: { text: "Acciones disponibles para el bloque seleccionado" }
};
const blockActionsRow = {
type: 1,
components: [
{
type: 2,
style: 1,
label: "✏️ Editar",
custom_id: `edit_block_${selectedBlock}`
},
{
type: 2,
style: 2,
label: "👁️ Vista Previa",
custom_id: `preview_block_${selectedBlock}`
},
{
type: 2,
style: 2,
label: "📋 Duplicar",
custom_id: `duplicate_block_${selectedBlock}`
},
{
type: 2,
style: 4,
label: "🗑️ Eliminar",
custom_id: `delete_block_${selectedBlock}`
}
]
};
const backRow = {
type: 1,
components: [
{
type: 2,
style: 2,
label: "↩️ Volver a la Lista",
custom_id: "back_to_list"
}
]
};
await interaction.update({
embeds: [blockActionEmbed],
components: [blockActionsRow, backRow]
});
}
break;
case "back_to_list":
await interaction.update({
embeds: [generateBlockListEmbed(currentPage)],
components: generateActionRows(currentPage)
});
break;
case "show_create_commands":
await interaction.reply({
content: `🔧 **Crear nuevos bloques:**\n\n• \`!crear-embed <nombre>\` - Crear bloque básico\n• \`!editar-embed <nombre>\` - Editor avanzado\n\n💡 **Ejemplo:** \`!crear-embed bienvenida\`\n\n📖 **Guía completa:** Los bloques usan DisplayComponents para crear interfaces modernas e interactivas.`,
flags: 64
});
break;
case "show_delete_commands":
await interaction.reply({
content: `⚠️ **Eliminar bloques:**\n\n• \`!eliminar-embed\` - Panel interactivo de eliminación\n• \`!eliminar-embed <nombre>\` - Eliminación directa\n\n❗ **Advertencia:** La eliminación es irreversible.`,
flags: 64
});
break;
case "export_block_list":
const exportText = blocks.map((block: any, index: number) => {
const componentsCount = Array.isArray(block.config?.components) ? block.config.components.length : 0;
return `${index + 1}. ${block.name} (${componentsCount} componentes) - ID: ${block.id}`;
}).join('\n');
await interaction.reply({
content: `📋 **Lista Exportada:**\n\`\`\`\n${exportText}\`\`\``,
flags: 64
});
break;
default:
// Manejar acciones específicas de bloques
if (interaction.customId.startsWith("edit_block_")) {
const blockName = interaction.customId.replace("edit_block_", "");
await interaction.reply({
content: `Usa: \`!editar-embed ${blockName}\``,
flags: 64
});
} else if (interaction.customId.startsWith("delete_block_")) {
const blockName = interaction.customId.replace("delete_block_", "");
await interaction.reply({
content: `Usa: \`!eliminar-embed ${blockName}\` para eliminar este bloque de forma segura.`,
flags: 64
});
} else if (interaction.customId.startsWith("preview_block_")) {
const blockName = interaction.customId.replace("preview_block_", "");
await interaction.reply({
content: `Vista previa de \`${blockName}\` - Funcionalidad en desarrollo`,
flags: 64
});
} else if (interaction.customId.startsWith("duplicate_block_")) {
const blockName = interaction.customId.replace("duplicate_block_", "");
await interaction.reply({
content: `Funcionalidad de duplicación de \`${blockName}\` en desarrollo`,
flags: 64
});
}
break;
}
});
collector.on("end", async (collected: any, reason: string) => {
if (reason === "time") {
const timeoutEmbed = {
color: 0x36393f,
title: "⏰ Panel Expirado",
description: "El panel de gestión ha expirado por inactividad.\n\nUsa `!lista-embeds` para abrir un nuevo panel de gestión.",
footer: { text: "Panel expirado por inactividad" }
};
try {
await panelMessage.edit({
embeds: [timeoutEmbed],
components: []
});
} catch (error) {
// Mensaje eliminado o error de edición
}
}
});
}, },
}; };
async function fetchBlocks(client: Amayo, guildId: string): Promise<BlockListItem[]> {
return await client.prisma.blockV2Config.findMany({
where: { guildId },
select: {
name: true,
id: true,
config: true
},
orderBy: { name: 'asc' }
});
}
async function handleEmptyBlocksList(message: Message): Promise<void> {
const emptyEmbed: APIEmbed = {
color: 0x5865f2,
title: "📚 Centro de Gestión de Bloques",
description: "📭 **No hay bloques disponibles**\n\nEste servidor aún no tiene bloques configurados.\n\n🚀 **¿Quieres empezar?**\n• Usa `!crear-embed <nombre>` para crear tu primer bloque\n• Usa `!editar-embed <nombre>` para editar bloques existentes",
footer: { text: "Sistema de gestión de bloques • Amayo Bot" }
};
const createRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Success,
label: "📝 Crear Primer Bloque",
custom_id: "show_create_help"
}
]
};
const helpMessage = await message.reply({
embeds: [emptyEmbed],
components: [createRow]
});
const helpCollector = helpMessage.createMessageComponentCollector({
time: 60000,
filter: (interaction: MessageComponentInteraction) => interaction.user.id === message.author.id
});
helpCollector.on("collect", async (interaction: ButtonInteraction) => {
if (interaction.customId === "show_create_help") {
const helpEmbed: APIEmbed = {
color: 0x57f287,
title: "📖 Guía de Creación de Bloques",
description: "🔧 **Comandos disponibles:**\n\n• `!crear-embed <nombre>` - Crear nuevo bloque\n• `!editar-embed <nombre>` - Editar bloque existente\n• `!eliminar-embed <nombre>` - Eliminar bloque\n• `!lista-embeds` - Ver todos los bloques\n\n💡 **Tip:** Los bloques permiten crear interfaces modernas e interactivas.",
footer: { text: "Guía de comandos de creación" }
};
await interaction.update({
embeds: [helpEmbed],
components: []
});
}
});
}
function createPagination(blocks: BlockListItem[], currentPage: number, itemsPerPage: number): PaginationData<BlockListItem> {
const totalPages = Math.ceil(blocks.length / itemsPerPage);
return {
items: blocks,
currentPage,
totalPages,
itemsPerPage
};
}
function generateBlockListEmbed(pagination: PaginationData<BlockListItem>): APIEmbed {
const { items, currentPage, totalPages, itemsPerPage } = pagination;
const startIndex = currentPage * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, items.length);
const pageBlocks = items.slice(startIndex, endIndex);
let blockListText = `📊 **Página ${currentPage + 1} de ${totalPages}** (${items.length} total)\n\n`;
pageBlocks.forEach((block, index) => {
const globalIndex = startIndex + index + 1;
const config = block.config as BlockConfig;
const componentsCount = Array.isArray(config?.components) ? config.components.length : 0;
const hasImage = config?.coverImage ? "🖼️" : "";
blockListText += `**${globalIndex}.** \`${block.name}\` ${hasImage}\n`;
blockListText += `${componentsCount} componente(s) • ID: ${block.id.slice(-8)}\n\n`;
});
return {
color: 0x5865f2,
title: "📚 Centro de Gestión de Bloques",
description: blockListText,
footer: { text: `Página ${currentPage + 1}/${totalPages}${items.length} bloques total` }
};
}
function generateActionRows(pagination: PaginationData<BlockListItem>): ActionRowBuilder[] {
const rows: ActionRowBuilder[] = [];
const { items, currentPage, totalPages, itemsPerPage } = pagination;
// Select menu for quick actions on current page blocks
const currentPageBlocks = items.slice(currentPage * itemsPerPage, (currentPage + 1) * itemsPerPage);
if (currentPageBlocks.length > 0) {
const selectOptions = currentPageBlocks.map((block) => {
const config = block.config as BlockConfig;
const componentsCount = Array.isArray(config?.components) ? config.components.length : 0;
return {
label: block.name,
value: block.name,
description: `${componentsCount} componente(s)`,
emoji: { name: "⚙️" }
};
});
rows.push({
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.StringSelect,
custom_id: "block_actions_select",
placeholder: "⚙️ Selecciona un bloque para gestionar...",
options: selectOptions
}
]
});
}
// Navigation buttons
const navigationComponents: APIButtonComponent[] = [];
if (totalPages > 1) {
navigationComponents.push(
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "◀️ Anterior",
custom_id: "prev_page",
disabled: currentPage === 0
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: `${currentPage + 1}/${totalPages}`,
custom_id: "page_info",
disabled: true
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "▶️ Siguiente",
custom_id: "next_page",
disabled: currentPage === totalPages - 1
}
);
}
navigationComponents.push({
type: ComponentType.Button,
style: ButtonStyle.Primary,
label: "🔄 Refrescar",
custom_id: "refresh_list"
});
rows.push({
type: ComponentType.ActionRow,
components: navigationComponents
});
// Action buttons
rows.push({
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Success,
label: "📝 Crear Nuevo",
custom_id: "show_create_commands"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📋 Exportar Lista",
custom_id: "export_block_list"
},
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "🗑️ Eliminar",
custom_id: "show_delete_commands"
}
]
});
return rows;
}
async function handleInteractions(
panelMessage: Message,
originalMessage: Message,
client: Amayo,
pagination: PaginationData<BlockListItem>
): Promise<void> {
const collector = panelMessage.createMessageComponentCollector({
time: 600000,
filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id
});
collector.on("collect", async (interaction: MessageComponentInteraction) => {
try {
if (interaction.isButton()) {
await handleButtonInteraction(interaction, client, pagination, originalMessage.guildId!);
} else if (interaction.isStringSelectMenu()) {
await handleSelectMenuInteraction(interaction);
}
} catch (error) {
console.error("Error handling interaction:", error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: "❌ Ocurrió un error al procesar la interacción.",
ephemeral: true
});
}
}
});
collector.on("end", async (collected, reason) => {
if (reason === "time") {
await handleCollectorTimeout(panelMessage);
}
});
}
async function handleButtonInteraction(
interaction: ButtonInteraction,
client: Amayo,
pagination: PaginationData<BlockListItem>,
guildId: string
): Promise<void> {
switch (interaction.customId) {
case "prev_page":
if (pagination.currentPage > 0) {
pagination.currentPage--;
await interaction.update({
embeds: [generateBlockListEmbed(pagination)],
components: generateActionRows(pagination)
});
}
break;
case "next_page":
if (pagination.currentPage < pagination.totalPages - 1) {
pagination.currentPage++;
await interaction.update({
embeds: [generateBlockListEmbed(pagination)],
components: generateActionRows(pagination)
});
}
break;
case "refresh_list":
const refreshedBlocks = await fetchBlocks(client, guildId);
pagination.items = refreshedBlocks;
pagination.totalPages = Math.ceil(refreshedBlocks.length / pagination.itemsPerPage);
if (pagination.currentPage >= pagination.totalPages) {
pagination.currentPage = Math.max(0, pagination.totalPages - 1);
}
await interaction.update({
embeds: [generateBlockListEmbed(pagination)],
components: generateActionRows(pagination)
});
break;
case "show_create_commands":
await interaction.reply({
content: `🔧 **Crear nuevos bloques:**\n\n• \`!crear-embed <nombre>\` - Crear bloque básico\n• \`!editar-embed <nombre>\` - Editor avanzado\n\n💡 **Ejemplo:** \`!crear-embed bienvenida\`\n\n📖 **Guía completa:** Los bloques usan DisplayComponents para crear interfaces modernas e interactivas.`,
ephemeral: true
});
break;
case "show_delete_commands":
await interaction.reply({
content: `⚠️ **Eliminar bloques:**\n\n• \`!eliminar-embed\` - Panel interactivo de eliminación\n• \`!eliminar-embed <nombre>\` - Eliminación directa\n\n❗ **Advertencia:** La eliminación es irreversible.`,
ephemeral: true
});
break;
case "export_block_list":
const exportText = pagination.items.map((block, index) => {
const config = block.config as BlockConfig;
const componentsCount = Array.isArray(config?.components) ? config.components.length : 0;
return `${index + 1}. ${block.name} (${componentsCount} componentes) - ID: ${block.id}`;
}).join('\n');
await interaction.reply({
content: `📋 **Lista Exportada:**\n\`\`\`\n${exportText}\`\`\``,
ephemeral: true
});
break;
case "back_to_list":
await interaction.update({
embeds: [generateBlockListEmbed(pagination)],
components: generateActionRows(pagination)
});
break;
default:
await handleSpecificBlockActions(interaction);
break;
}
}
async function handleSelectMenuInteraction(interaction: StringSelectMenuInteraction): Promise<void> {
if (interaction.customId === "block_actions_select") {
const selectedBlock = interaction.values[0];
const blockActionEmbed: APIEmbed = {
color: 0xff9500,
title: `⚙️ Gestión de Bloque: \`${selectedBlock}\``,
description: "Selecciona la acción que deseas realizar con este bloque:",
footer: { text: "Acciones disponibles para el bloque seleccionado" }
};
const blockActionsRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Primary,
label: "✏️ Editar",
custom_id: `edit_block_${selectedBlock}`
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "👁️ Vista Previa",
custom_id: `preview_block_${selectedBlock}`
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📋 Duplicar",
custom_id: `duplicate_block_${selectedBlock}`
},
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "🗑️ Eliminar",
custom_id: `delete_block_${selectedBlock}`
}
]
};
const backRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "↩️ Volver a la Lista",
custom_id: "back_to_list"
}
]
};
await interaction.update({
embeds: [blockActionEmbed],
components: [blockActionsRow, backRow]
});
}
}
async function handleSpecificBlockActions(interaction: ButtonInteraction): Promise<void> {
const { customId } = interaction;
if (customId.startsWith("edit_block_")) {
const blockName = customId.replace("edit_block_", "");
await interaction.reply({
content: `Usa: \`!editar-embed ${blockName}\``,
ephemeral: true
});
} else if (customId.startsWith("delete_block_")) {
const blockName = customId.replace("delete_block_", "");
await interaction.reply({
content: `Usa: \`!eliminar-embed ${blockName}\` para eliminar este bloque de forma segura.`,
ephemeral: true
});
} else if (customId.startsWith("preview_block_")) {
const blockName = customId.replace("preview_block_", "");
await interaction.reply({
content: `Vista previa de \`${blockName}\` - Funcionalidad en desarrollo`,
ephemeral: true
});
} else if (customId.startsWith("duplicate_block_")) {
const blockName = customId.replace("duplicate_block_", "");
await interaction.reply({
content: `Funcionalidad de duplicación de \`${blockName}\` en desarrollo`,
ephemeral: true
});
}
}
async function handleCollectorTimeout(panelMessage: Message): Promise<void> {
const timeoutEmbed: APIEmbed = {
color: 0x36393f,
title: "⏰ Panel Expirado",
description: "El panel de gestión ha expirado por inactividad.\n\nUsa `!lista-embeds` para abrir un nuevo panel de gestión.",
footer: { text: "Panel expirado por inactividad" }
};
try {
await panelMessage.edit({
embeds: [timeoutEmbed],
components: []
});
} catch (error) {
// Message was deleted or other edit error - ignore
console.log("Could not edit message on timeout, likely deleted");
}
}

View File

@@ -1,4 +1,21 @@
import {
Message,
ButtonInteraction,
MessageComponentInteraction,
ComponentType,
ButtonStyle,
MessageFlags
} from "discord.js";
import { CommandMessage } from "../../../core/types/commands"; import { CommandMessage } from "../../../core/types/commands";
import type {
DisplayComponentContainer
} from "../../../core/types/displayComponents";
import type Amayo from "../../../core/client";
interface ActionRowBuilder {
type: ComponentType.ActionRow;
components: any[]; // Discord.js API components
}
export const command: CommandMessage = { export const command: CommandMessage = {
name: "displaydemo", name: "displaydemo",
@@ -8,401 +25,450 @@ export const command: CommandMessage = {
description: "Demostración de DisplayComponents con accesorios y acciones.", description: "Demostración de DisplayComponents con accesorios y acciones.",
category: "Alianzas", category: "Alianzas",
usage: "displaydemo", usage: "displaydemo",
run: async (message, args, client) => { run: async (message: Message, _args: string[], _client: Amayo): Promise<void> => {
if (!message.member?.permissions.has("Administrator")) { if (!message.member?.permissions.has("Administrator")) {
await message.reply("❌ No tienes permisos de Administrador."); await message.reply("❌ No tienes permisos de Administrador.");
return; return;
} }
// 🎯 DEMOSTRACIÓN COMPLETA DE DISPLAYCOMPONENTS CON ACCESORIOS const mainPanel = createMainPanel(message);
const actionRow = createActionRow();
// Panel principal con accessory de thumbnail
const mainPanel = {
type: 17, // Container
accent_color: 0x5865f2,
components: [
{
type: 10, // TextDisplay
content: "🎨 **Demostración de DisplayComponents Avanzados**"
},
{
type: 14, // Separator
divider: true,
spacing: 2
},
// Sección con accessory de botón
{
type: 9, // Section
components: [
{
type: 10,
content: "🔘 **Sección con Botón Accesorio**\n\nEste texto aparece junto a un botón como accesorio. Los accesorios permiten añadir elementos interactivos sin ocupar una fila completa."
}
],
accessory: {
type: 2, // Button
style: 1, // Primary
label: "Acción Rápida",
custom_id: "quick_action",
emoji: { name: "⚡" }
}
},
{
type: 14,
divider: true,
spacing: 1
},
// Sección con accessory de thumbnail
{
type: 9, // Section
components: [
{
type: 10,
content: "🖼️ **Sección con Thumbnail**\n\nAquí se muestra texto con una imagen en miniatura como accesorio. Perfecto para mostrar íconos de servidores, avatares o logotipos."
}
],
accessory: {
type: 11, // Thumbnail
media: {
url: message.guild?.iconURL({ forceStatic: false }) || "https://cdn.discordapp.com/embed/avatars/0.png"
}
}
},
{
type: 14,
divider: true,
spacing: 1
},
// Sección con accessory de link button
{
type: 9, // Section
components: [
{
type: 10,
content: "🔗 **Sección con Botón de Enlace**\n\nEste tipo de accesorio permite enlaces externos directos sin necesidad de interacciones complejas."
}
],
accessory: {
type: 2, // Button
style: 5, // Link
label: "Ir a Discord",
url: "https://discord.com",
emoji: { name: "🚀" }
}
}
]
};
// Fila de botones normales para interacción
const actionRow = {
type: 1, // ActionRow
components: [
{
type: 2,
style: 3, // Success
label: "✨ Más Ejemplos",
custom_id: "show_more_examples"
},
{
type: 2,
style: 2, // Secondary
label: "🔄 Cambiar Estilos",
custom_id: "change_styles"
},
{
type: 2,
style: 4, // Danger
label: "❌ Cerrar",
custom_id: "close_demo"
}
]
};
const demoMessage = await message.reply({ const demoMessage = await message.reply({
// Activar Display Components V2 y mantener SuppressEmbeds // Enable Display Components V2 and suppress embeds
flags: (32768 | 4096), flags: MessageFlags.SuppressEmbeds | 32768,
components: [mainPanel, actionRow] components: [mainPanel, actionRow]
}); });
const collector = demoMessage.createMessageComponentCollector({ await handleDemoInteractions(demoMessage, message);
time: 300000, // 5 minutos },
filter: (i: any) => i.user.id === message.author.id };
});
collector.on("collect", async (interaction: any) => { function createMainPanel(message: Message): DisplayComponentContainer {
return {
type: 17, // Container
accent_color: 0x5865f2,
components: [
{
type: 10, // TextDisplay
content: "🎨 **Demostración de DisplayComponents Avanzados**"
},
{
type: 14, // Separator
divider: true,
spacing: 2
},
// Section with button accessory
{
type: 9, // Section
components: [
{
type: 10,
content: "🔘 **Sección con Botón Accesorio**\n\nEste texto aparece junto a un botón como accesorio. Los accesorios permiten añadir elementos interactivos sin ocupar una fila completa."
}
],
accessory: {
type: 2, // Button
style: ButtonStyle.Primary,
label: "Acción Rápida",
custom_id: "quick_action",
emoji: { name: "⚡" }
}
},
{
type: 14,
divider: true,
spacing: 1
},
// Section with thumbnail accessory
{
type: 9, // Section
components: [
{
type: 10,
content: "🖼️ **Sección con Thumbnail**\n\nAquí se muestra texto con una imagen en miniatura como accesorio. Perfecto para mostrar íconos de servidores, avatares o logotipos."
}
],
accessory: {
type: 11, // Thumbnail
media: {
url: message.guild?.iconURL({ forceStatic: false }) || "https://cdn.discordapp.com/embed/avatars/0.png"
}
}
},
{
type: 14,
divider: true,
spacing: 1
},
// Section with link button accessory
{
type: 9, // Section
components: [
{
type: 10,
content: "🔗 **Sección con Botón de Enlace**\n\nEste tipo de accesorio permite enlaces externos directos sin necesidad de interacciones complejas."
}
],
accessory: {
type: 2, // Button
style: ButtonStyle.Link,
label: "Ir a Discord",
url: "https://discord.com",
emoji: { name: "🚀" }
}
}
]
};
}
function createActionRow(): ActionRowBuilder {
return {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Success,
label: "✨ Más Ejemplos",
custom_id: "show_more_examples"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🔄 Cambiar Estilos",
custom_id: "change_styles"
},
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "❌ Cerrar",
custom_id: "close_demo"
}
]
};
}
async function handleDemoInteractions(demoMessage: Message, originalMessage: Message): Promise<void> {
const collector = demoMessage.createMessageComponentCollector({
time: 300000, // 5 minutes
filter: (interaction: MessageComponentInteraction) => interaction.user.id === originalMessage.author.id
});
collector.on("collect", async (interaction: ButtonInteraction) => {
try {
switch (interaction.customId) { switch (interaction.customId) {
case "quick_action": case "quick_action":
await interaction.reply({ await interaction.reply({
content: "⚡ **Acción Rápida Ejecutada!**\n\nEste botón estaba como accesorio en una sección.", content: "⚡ **Acción Rápida Ejecutada!**\n\nEste botón estaba como accesorio en una sección.",
flags: 64 // Ephemeral ephemeral: true
}); });
break; break;
case "show_more_examples": case "show_more_examples":
// Panel con múltiples ejemplos de accesorios await handleMoreExamples(interaction, originalMessage);
const examplesPanel = {
type: 17,
accent_color: 0xff9500,
components: [
{
type: 10,
content: "🎯 **Más Ejemplos de Accesorios**"
},
{
type: 14,
divider: true,
spacing: 2
},
// Ejemplo con avatar del usuario
{
type: 9,
components: [
{
type: 10,
content: `👤 **Perfil de ${message.author.username}**\n\nEjemplo usando tu avatar como thumbnail accesorio.`
}
],
accessory: {
type: 11,
media: {
url: message.author.displayAvatarURL({ forceStatic: false })
}
}
},
{
type: 14,
divider: false,
spacing: 1
},
// Ejemplo con botón de estilo diferente
{
type: 9,
components: [
{
type: 10,
content: "🎨 **Botones con Diferentes Estilos**\n\nLos accesorios pueden tener distintos estilos y emojis personalizados."
}
],
accessory: {
type: 2,
style: 4, // Danger
label: "Peligro",
custom_id: "danger_button",
emoji: { name: "⚠️" }
}
},
{
type: 14,
divider: false,
spacing: 1
},
// Imagen como accessory
{
type: 9,
components: [
{
type: 10,
content: "🖼️ **Imágenes Personalizadas**\n\nTambién puedes usar imágenes personalizadas, íconos de servidores invitados, etc."
}
],
accessory: {
type: 11,
media: {
url: "https://cdn.discordapp.com/attachments/123/456/discord-logo.png"
}
}
}
]
};
await interaction.update({
components: [examplesPanel, {
type: 1,
components: [
{
type: 2,
style: 2, // Secondary
label: "↩️ Volver",
custom_id: "back_to_main"
}
]
}]
});
break; break;
case "change_styles": case "change_styles":
// Panel mostrando diferentes combinaciones de estilos await handleStylesDemo(interaction);
const stylesPanel = {
type: 17,
accent_color: 0x57f287,
components: [
{
type: 10,
content: "🎨 **Galería de Estilos**"
},
{
type: 14,
divider: true,
spacing: 2
},
// Botón Primary como accesorio
{
type: 9,
components: [
{
type: 10,
content: "🔵 **Botón Primary (Azul)**\nEstilo: 1 - Para acciones principales"
}
],
accessory: {
type: 2,
style: 1, // Primary
label: "Principal",
custom_id: "style_primary"
}
},
// Botón Secondary como accesorio
{
type: 9,
components: [
{
type: 10,
content: "⚫ **Botón Secondary (Gris)**\nEstilo: 2 - Para acciones secundarias"
}
],
accessory: {
type: 2,
style: 2, // Secondary
label: "Secundario",
custom_id: "style_secondary"
}
},
// Botón Success como accesorio
{
type: 9,
components: [
{
type: 10,
content: "🟢 **Botón Success (Verde)**\nEstilo: 3 - Para confirmar acciones"
}
],
accessory: {
type: 2,
style: 3, // Success
label: "Confirmar",
custom_id: "style_success"
}
},
// Botón Danger como accesorio
{
type: 9,
components: [
{
type: 10,
content: "🔴 **Botón Danger (Rojo)**\nEstilo: 4 - Para acciones destructivas"
}
],
accessory: {
type: 2,
style: 4, // Danger
label: "Eliminar",
custom_id: "style_danger"
}
}
]
};
await interaction.update({
components: [stylesPanel, {
type: 1,
components: [
{
type: 2,
style: 2,
label: "↩️ Volver",
custom_id: "back_to_main"
}
]
}]
});
break;
case "danger_button":
case "style_primary":
case "style_secondary":
case "style_success":
case "style_danger":
await interaction.reply({
content: `🎯 **Botón ${interaction.customId.replace('style_', '').replace('_', ' ')} activado!**\n\nEste botón era un accesorio de una sección.`,
flags: 64 // Ephemeral
});
break; break;
case "back_to_main": case "back_to_main":
const mainPanel = createMainPanel(originalMessage);
const actionRow = createActionRow();
await interaction.update({ await interaction.update({
components: [mainPanel, actionRow] components: [mainPanel, actionRow]
}); });
break; break;
case "close_demo": case "close_demo":
const closedPanel = { await handleCloseDemo(interaction);
type: 17,
accent_color: 0x36393f,
components: [
{
type: 10,
content: "✅ **Demostración Finalizada**"
},
{
type: 14,
divider: true,
spacing: 1
},
{
type: 10,
content: "Gracias por probar DisplayComponents con accesorios!\n\n💡 **Recuerda:** Los accesorios son ideales para:\n• Botones de acción rápida\n• Thumbnails e íconos\n• Enlaces externos\n• Elementos decorativos"
}
]
};
await interaction.update({
components: [closedPanel]
});
collector.stop(); collector.stop();
break; break;
default:
await handleStyleButtons(interaction);
break;
} }
}); } catch (error) {
console.error("Error handling demo interaction:", error);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
content: "❌ Ocurrió un error al procesar la interacción.",
ephemeral: true
});
}
}
});
collector.on("end", async (collected: any, reason: string) => { collector.on("end", async (collected, reason) => {
if (reason === "time") { if (reason === "time") {
try { await handleDemoTimeout(demoMessage);
const timeoutPanel = { }
type: 17, });
accent_color: 0x36393f, }
components: [
{
type: 10,
content: "⏰ **Demostración Expirada**"
},
{
type: 14,
divider: true,
spacing: 1
},
{
type: 10,
content: "La demostración ha expirado por inactividad.\nUsa `!displaydemo` nuevamente para verla."
}
]
};
await demoMessage.edit({ async function handleMoreExamples(interaction: ButtonInteraction, originalMessage: Message): Promise<void> {
components: [timeoutPanel] const examplesPanel: DisplayComponentContainer = {
}); type: 17,
} catch (error) { accent_color: 0xff9500,
// Mensaje eliminado o error de edición components: [
{
type: 10,
content: "🎯 **Más Ejemplos de Accesorios**"
},
{
type: 14,
divider: true,
spacing: 2
},
// Example with user avatar
{
type: 9,
components: [
{
type: 10,
content: `👤 **Perfil de ${originalMessage.author.username}**\n\nEjemplo usando tu avatar como thumbnail accesorio.`
}
],
accessory: {
type: 11,
media: {
url: originalMessage.author.displayAvatarURL({ forceStatic: false })
}
}
},
{
type: 14,
divider: false,
spacing: 1
},
// Example with different button style
{
type: 9,
components: [
{
type: 10,
content: "🎨 **Botones con Diferentes Estilos**\n\nLos accesorios pueden tener distintos estilos y emojis personalizados."
}
],
accessory: {
type: 2,
style: ButtonStyle.Danger,
label: "Peligro",
custom_id: "danger_button",
emoji: { name: "⚠️" }
}
},
{
type: 14,
divider: false,
spacing: 1
},
// Custom image as accessory
{
type: 9,
components: [
{
type: 10,
content: "🖼️ **Imágenes Personalizadas**\n\nTambién puedes usar imágenes personalizadas, íconos de servidores invitados, etc."
}
],
accessory: {
type: 11,
media: {
url: "https://cdn.discordapp.com/attachments/1234/5678/discord-logo.png"
}
} }
} }
]
};
const backRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "↩️ Volver",
custom_id: "back_to_main"
}
]
};
await interaction.update({
components: [examplesPanel, backRow]
});
}
async function handleStylesDemo(interaction: ButtonInteraction): Promise<void> {
const stylesPanel: DisplayComponentContainer = {
type: 17,
accent_color: 0x57f287,
components: [
{
type: 10,
content: "🎨 **Galería de Estilos**"
},
{
type: 14,
divider: true,
spacing: 2
},
// Primary button as accessory
{
type: 9,
components: [
{
type: 10,
content: "🔵 **Botón Primary (Azul)**\nEstilo: 1 - Para acciones principales"
}
],
accessory: {
type: 2,
style: ButtonStyle.Primary,
label: "Principal",
custom_id: "style_primary"
}
},
// Secondary button as accessory
{
type: 9,
components: [
{
type: 10,
content: "⚫ **Botón Secondary (Gris)**\nEstilo: 2 - Para acciones secundarias"
}
],
accessory: {
type: 2,
style: ButtonStyle.Secondary,
label: "Secundario",
custom_id: "style_secondary"
}
},
// Success button as accessory
{
type: 9,
components: [
{
type: 10,
content: "🟢 **Botón Success (Verde)**\nEstilo: 3 - Para confirmar acciones"
}
],
accessory: {
type: 2,
style: ButtonStyle.Success,
label: "Confirmar",
custom_id: "style_success"
}
},
// Danger button as accessory
{
type: 9,
components: [
{
type: 10,
content: "🔴 **Botón Danger (Rojo)**\nEstilo: 4 - Para acciones destructivas"
}
],
accessory: {
type: 2,
style: ButtonStyle.Danger,
label: "Eliminar",
custom_id: "style_danger"
}
}
]
};
const backRow: ActionRowBuilder = {
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "↩️ Volver",
custom_id: "back_to_main"
}
]
};
await interaction.update({
components: [stylesPanel, backRow]
});
}
async function handleStyleButtons(interaction: ButtonInteraction): Promise<void> {
const styleMap: Record<string, string> = {
"danger_button": "Peligro",
"style_primary": "Primary",
"style_secondary": "Secondary",
"style_success": "Success",
"style_danger": "Danger"
};
const styleName = styleMap[interaction.customId];
if (styleName) {
await interaction.reply({
content: `🎯 **Botón ${styleName} activado!**\n\nEste botón era un accesorio de una sección.`,
ephemeral: true
}); });
}, }
}; }
async function handleCloseDemo(interaction: ButtonInteraction): Promise<void> {
const closedPanel: DisplayComponentContainer = {
type: 17,
accent_color: 0x36393f,
components: [
{
type: 10,
content: "✅ **Demostración Finalizada**"
},
{
type: 14,
divider: true,
spacing: 1
},
{
type: 10,
content: "Gracias por probar DisplayComponents con accesorios!\n\n💡 **Recuerda:** Los accesorios son ideales para:\n• Botones de acción rápida\n• Thumbnails e íconos\n• Enlaces externos\n• Elementos decorativos"
}
]
};
await interaction.update({
components: [closedPanel]
});
}
async function handleDemoTimeout(demoMessage: Message): Promise<void> {
try {
const timeoutPanel: DisplayComponentContainer = {
type: 17,
accent_color: 0x36393f,
components: [
{
type: 10,
content: "⏰ **Demostración Expirada**"
},
{
type: 14,
divider: true,
spacing: 1
},
{
type: 10,
content: "La demostración ha expirado por inactividad.\nUsa `!displaydemo` nuevamente para verla."
}
]
};
await demoMessage.edit({
components: [timeoutPanel]
});
} catch (error) {
// Message was deleted or other edit error - ignore
console.log("Could not edit demo message on timeout, likely deleted");
}
}

View File

@@ -0,0 +1,408 @@
import {
ButtonStyle,
ComponentType,
APIButtonComponent,
APISelectMenuComponent,
GuildMember,
Guild
} from 'discord.js';
import type {
DisplayComponent,
DisplayComponentContainer,
BlockConfig
} from './displayComponents';
import { replaceVars, isValidUrlOrVariable } from '../lib/vars';
// Editor-specific component types (how we store configuration while editing)
export interface EditorTextDisplay {
type: 10; // TextDisplay
content: string;
thumbnail?: string | null; // optional image URL
linkButton?: LinkButton | null; // optional link button accessory
}
export interface EditorSeparator {
type: 14; // Separator
divider?: boolean;
spacing?: number; // 1-3 typical
}
export interface EditorImage {
type: 12; // Image/Media
url: string; // single image URL (later rendered as items: [{ media: { url } }])
}
export type EditorComponent = EditorTextDisplay | EditorSeparator | EditorImage;
// Block state for editing/creating
export interface BlockState {
title?: string;
description?: string;
color?: number;
coverImage?: string;
components: EditorComponent[];
}
// Emoji input types
export interface CustomEmoji {
id: string;
name: string;
animated?: boolean;
}
export interface UnicodeEmoji {
name: string;
}
export type EmojiInput = CustomEmoji | UnicodeEmoji;
// Link button configuration
export interface LinkButton {
url: string;
label?: string;
emoji?: string;
}
// Action row builders
export interface EditorActionRow {
type: ComponentType.ActionRow;
components: APIButtonComponent[];
}
export interface SelectActionRow {
type: ComponentType.ActionRow;
components: APISelectMenuComponent[];
}
// Utility functions
export class DisplayComponentUtils {
/**
* Validates if a URL is valid or a system variable
*/
static isValidUrl(url: string): boolean {
return isValidUrlOrVariable(url);
}
/**
* Validates and cleans content for Discord
*/
static validateContent(content: string): string {
if (!content || typeof content !== 'string') {
return "Sin contenido";
}
const cleaned = content.trim();
if (cleaned.length === 0) {
return "Sin contenido";
}
// Truncate if exceeds Discord limit (4000 characters)
if (cleaned.length > 4000) {
return cleaned.substring(0, 3997) + "...";
}
return cleaned;
}
/**
* Parses emoji input (unicode or custom <a:name:id> / <:name:id>)
*/
static parseEmojiInput(input?: string): EmojiInput | null {
if (!input) return null;
const trimmed = input.trim();
if (!trimmed) return null;
const match = trimmed.match(/^<(a?):(\w+):(\d+)>$/);
if (match) {
const animated = match[1] === 'a';
const name = match[2];
const id = match[3];
return { id, name, animated };
}
// Assume unicode if not custom emoji format
return { name: trimmed };
}
/**
* Builds a link accessory for Display Components
*/
static async buildLinkAccessory(
link: LinkButton,
member: GuildMember,
guild: Guild
): Promise<any | null> {
if (!link || !link.url) return null;
const processedUrl = await replaceVars(link.url, member, guild);
if (!this.isValidUrl(processedUrl)) return null;
const accessory: any = {
type: 2, // Button
style: ButtonStyle.Link,
url: processedUrl
};
if (link.label && typeof link.label === 'string' && link.label.trim().length > 0) {
accessory.label = link.label.trim().slice(0, 80);
}
if (link.emoji && typeof link.emoji === 'string') {
const parsed = this.parseEmojiInput(link.emoji);
if (parsed) accessory.emoji = parsed;
}
// Must have at least label or emoji
if (!accessory.label && !accessory.emoji) {
return null;
}
return accessory;
}
/**
* Renders preview of a block
*/
static async renderPreview(
blockState: BlockState,
member: GuildMember,
guild: Guild
): Promise<DisplayComponentContainer> {
const previewComponents: DisplayComponent[] = [];
// Add cover image first if exists
if (blockState.coverImage && this.isValidUrl(blockState.coverImage)) {
const processedCoverUrl = await replaceVars(blockState.coverImage, member, guild);
if (this.isValidUrl(processedCoverUrl)) {
previewComponents.push({
type: 12,
items: [{ media: { url: processedCoverUrl } }]
} as any);
}
}
// Add title after cover - VALIDATE CONTENT
const processedTitle = await replaceVars(blockState.title ?? "Sin título", member, guild);
previewComponents.push({
type: 10,
content: this.validateContent(processedTitle)
} as any);
// Process components in order
for (const c of blockState.components) {
if (c.type === 10) {
const processedThumbnail = c.thumbnail ?
await replaceVars(c.thumbnail, member, guild) : null;
const processedContent = await replaceVars(c.content || "Sin contenido", member, guild);
const validatedContent = this.validateContent(processedContent);
// Build accessory by priority: linkButton > thumbnail
let accessory: any = null;
if (c.linkButton) {
const built = await this.buildLinkAccessory(c.linkButton, member, guild);
if (built) accessory = built;
}
if (!accessory && processedThumbnail && this.isValidUrl(processedThumbnail)) {
accessory = {
type: 11,
media: { url: processedThumbnail }
};
}
if (accessory) {
previewComponents.push({
type: 9,
components: [{
type: 10,
content: validatedContent
} as any],
accessory
} as any);
} else {
// No valid accessory
previewComponents.push({
type: 10,
content: validatedContent
} as any);
}
} else if (c.type === 14) {
// Separator
previewComponents.push({
type: 14,
divider: c.divider ?? true,
spacing: c.spacing ?? 1
} as any);
} else if (c.type === 12) {
// Image
const processedImageUrl = await replaceVars(c.url, member, guild);
if (this.isValidUrl(processedImageUrl)) {
previewComponents.push({
type: 12,
items: [{ media: { url: processedImageUrl } }]
} as any);
}
}
}
return {
type: 17,
accent_color: blockState.color || 0x5865f2,
components: previewComponents
};
}
/**
* Creates editor button rows
*/
static createEditorButtons(disabled = false): EditorActionRow[] {
return [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📝 Título",
disabled,
custom_id: "edit_title"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📄 Descripción",
disabled,
custom_id: "edit_description"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🎨 Color",
disabled,
custom_id: "edit_color"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: " Contenido",
disabled,
custom_id: "add_content"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: " Separador",
disabled,
custom_id: "add_separator"
}
]
},
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🖼️ Imagen",
disabled,
custom_id: "add_image"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🖼️ Portada",
disabled,
custom_id: "cover_image"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📎 Thumbnail",
disabled,
custom_id: "edit_thumbnail"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🔗 Crear Botón Link",
disabled,
custom_id: "edit_link_button"
},
{
type: ComponentType.Button,
style: ButtonStyle.Primary,
label: "🔄 Mover",
disabled,
custom_id: "move_block"
}
]
},
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "🎯 Variables",
disabled,
custom_id: "show_variables"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📋 Duplicar",
disabled,
custom_id: "duplicate_block"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📊 Vista Raw",
disabled,
custom_id: "show_raw"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📥 Importar",
disabled,
custom_id: "import_json"
},
{
type: ComponentType.Button,
style: ButtonStyle.Secondary,
label: "📤 Exportar",
disabled,
custom_id: "export_json"
}
]
},
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.Button,
style: ButtonStyle.Success,
label: "💾 Guardar",
disabled,
custom_id: "save_block"
},
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "❌ Cancelar",
disabled,
custom_id: "cancel_block"
},
{
type: ComponentType.Button,
style: ButtonStyle.Danger,
label: "🗑️ Eliminar",
disabled,
custom_id: "delete_block"
}
]
}
];
}
}

View File

@@ -0,0 +1,101 @@
import type {
ButtonStyle,
APIEmbed,
ComponentType
} from 'discord.js';
// Display Components V2 Types
export interface DisplayComponentContainer {
type: 17; // Container type
accent_color?: number;
components: DisplayComponent[];
}
export interface DisplayComponentSection {
type: 9; // Section type
components: DisplayComponent[];
accessory?: DisplayComponentAccessory;
}
export interface DisplayComponentText {
type: 10; // TextDisplay type
content: string;
}
export interface DisplayComponentSeparator {
type: 14; // Separator type
divider?: boolean;
spacing?: number;
}
export interface DisplayComponentThumbnail {
type: 11; // Thumbnail type
media: {
url: string;
};
}
export interface DisplayComponentButton {
type: 2; // Button type
style: ButtonStyle;
label: string;
custom_id?: string;
url?: string;
emoji?: {
name: string;
id?: string;
};
disabled?: boolean;
}
export type DisplayComponent =
| DisplayComponentContainer
| DisplayComponentSection
| DisplayComponentText
| DisplayComponentSeparator
| DisplayComponentThumbnail;
export type DisplayComponentAccessory =
| DisplayComponentButton
| DisplayComponentThumbnail;
// Block configuration types - compatible with Prisma JsonValue
export interface BlockConfig {
components?: any[]; // Use any[] to be compatible with JsonValue
coverImage?: string;
version?: string;
}
export interface Block {
id: string;
name: string;
guildId: string;
config: BlockConfig;
createdAt?: Date;
updatedAt?: Date;
}
// Pagination helpers
export interface PaginationData<T> {
items: T[];
currentPage: number;
totalPages: number;
itemsPerPage: number;
}
export interface PaginationControls {
hasPrevious: boolean;
hasNext: boolean;
currentPage: number;
totalPages: number;
}
// Component constants for runtime use
export const COMPONENT_TYPES = {
CONTAINER: 17,
SECTION: 9,
TEXT_DISPLAY: 10,
SEPARATOR: 14,
THUMBNAIL: 11,
BUTTON: 2
} as const;