diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..d43ccdb --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/prisma/dev.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/prisma/dev.db b/prisma/dev.db new file mode 100644 index 0000000..8e532b6 Binary files /dev/null and b/prisma/dev.db differ diff --git a/prisma/migrations/20250918123305_blockv2/migration.sql b/prisma/migrations/20250918123305_blockv2/migration.sql new file mode 100644 index 0000000..6267ad8 --- /dev/null +++ b/prisma/migrations/20250918123305_blockv2/migration.sql @@ -0,0 +1,76 @@ +-- CreateTable +CREATE TABLE "Guild" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "prefix" TEXT NOT NULL DEFAULT '!' +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY +); + +-- CreateTable +CREATE TABLE "PartnershipStats" ( + "totalPoints" INTEGER NOT NULL DEFAULT 0, + "weeklyPoints" INTEGER NOT NULL DEFAULT 0, + "monthlyPoints" INTEGER NOT NULL DEFAULT 0, + "lastWeeklyReset" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastMonthlyReset" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + + PRIMARY KEY ("userId", "guildId"), + CONSTRAINT "PartnershipStats_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "PartnershipStats_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Alliance" ( + "id" TEXT NOT NULL PRIMARY KEY, + "channelId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "guildId" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + CONSTRAINT "Alliance_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Alliance_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "EmbedConfig" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "color" TEXT, + "title" TEXT, + "url" TEXT, + "authorName" TEXT, + "authorIconURL" TEXT, + "authorURL" TEXT, + "description" TEXT, + "thumbnailURL" TEXT, + "imageURL" TEXT, + "footerText" TEXT, + "footerIconURL" TEXT, + "fields" TEXT DEFAULT '[]', + "guildId" TEXT NOT NULL, + CONSTRAINT "EmbedConfig_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "BlockV2Config" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "config" JSONB NOT NULL, + "guildId" TEXT NOT NULL, + CONSTRAINT "BlockV2Config_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Alliance_messageId_key" ON "Alliance"("messageId"); + +-- CreateIndex +CREATE UNIQUE INDEX "EmbedConfig_guildId_name_key" ON "EmbedConfig"("guildId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "BlockV2Config_guildId_name_key" ON "BlockV2Config"("guildId", "name"); diff --git a/prisma/migrations/20250918165856_add_alliance_channels/migration.sql b/prisma/migrations/20250918165856_add_alliance_channels/migration.sql new file mode 100644 index 0000000..8d00131 --- /dev/null +++ b/prisma/migrations/20250918165856_add_alliance_channels/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "AllianceChannel" ( + "id" TEXT NOT NULL PRIMARY KEY, + "channelId" TEXT NOT NULL, + "blockConfigName" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "guildId" TEXT NOT NULL, + CONSTRAINT "AllianceChannel_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PointHistory" ( + "id" TEXT NOT NULL PRIMARY KEY, + "points" INTEGER NOT NULL DEFAULT 1, + "timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "messageId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + CONSTRAINT "PointHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "PointHistory_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "PointHistory_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "AllianceChannel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "AllianceChannel_channelId_key" ON "AllianceChannel"("channelId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AllianceChannel_guildId_channelId_key" ON "AllianceChannel"("guildId", "channelId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 00ad8a9..547ea94 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,6 +30,9 @@ model Guild { // ✅ CAMBIO: Ahora un Guild puede tener MÚLTIPLES configuraciones de embed. embedConfigs EmbedConfig[] BlockV2Config BlockV2Config[] + // ✅ NUEVAS RELACIONES + allianceChannels AllianceChannel[] + pointsHistory PointHistory[] } /* * ----------------------------------------------------------------------------- @@ -43,6 +46,8 @@ model User { // Relaciones partnerStats PartnershipStats[] createdAlliances Alliance[] + // ✅ NUEVA RELACIÓN + pointsHistory PointHistory[] } /* @@ -96,6 +101,62 @@ model Alliance { creatorId String } + +/* + * ----------------------------------------------------------------------------- + * Modelo para Canales de Alianza + * ----------------------------------------------------------------------------- + * Gestiona qué canales están configurados para otorgar puntos y qué bloque enviar +*/ +model AllianceChannel { + id String @id @default(cuid()) + channelId String @unique // ID del canal de Discord + + // Configuración del canal + blockConfigName String // Nombre del BlockV2Config a enviar + isActive Boolean @default(true) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // --- Relaciones --- + guild Guild @relation(fields: [guildId], references: [id]) + guildId String + + // Historial de puntos otorgados en este canal + pointsHistory PointHistory[] + + // Un canal solo puede estar en un servidor + @@unique([guildId, channelId]) +} + +/* + * ----------------------------------------------------------------------------- + * Modelo para Historial de Puntos + * ----------------------------------------------------------------------------- + * Registra cada vez que un usuario gana puntos con fecha y hora +*/ +model PointHistory { + id String @id @default(cuid()) + + // Información del punto otorgado + points Int @default(1) + timestamp DateTime @default(now()) + messageId String // ID del mensaje que generó el punto + + // --- Relaciones --- + user User @relation(fields: [userId], references: [id]) + userId String + + guild Guild @relation(fields: [guildId], references: [id]) + guildId String + + allianceChannel AllianceChannel @relation(fields: [channelId], references: [id]) + channelId String +} + + /* * ----------------------------------------------------------------------------- * Modelo para la Configuración del Embed diff --git a/src/commands/messages/alliaces/createEmbedv2.ts b/src/commands/messages/alliaces/createEmbedv2.ts index e88370b..25d7052 100644 --- a/src/commands/messages/alliaces/createEmbedv2.ts +++ b/src/commands/messages/alliaces/createEmbedv2.ts @@ -1,37 +1,47 @@ import { CommandMessage } from "../../../core/types/commands"; // @ts-ignore -import { ComponentType, ButtonStyle } from "discord.js"; +import { ComponentType, ButtonStyle, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, Message } from "discord.js"; import { replaceVars } from "../../../core/lib/vars"; /** - * Botones de edición + * Botones de edición - VERSIÓN MEJORADA */ const btns = (disabled = false) => ([ { type: 1, components: [ - { style: ButtonStyle.Secondary, type: 2, label: "Editar Título", disabled, custom_id: "edit_title" }, - { style: ButtonStyle.Secondary, type: 2, label: "Editar Descripción", disabled, custom_id: "edit_description" }, - { style: ButtonStyle.Secondary, type: 2, label: "Editar Color", disabled, custom_id: "edit_color" }, - { style: ButtonStyle.Secondary, type: 2, label: "Añadir Contenido", disabled, custom_id: "add_content" }, - { style: ButtonStyle.Secondary, type: 2, label: "Añadir Separador", disabled, custom_id: "add_separator" } + { style: ButtonStyle.Secondary, type: 2, label: "📝 Título", disabled, custom_id: "edit_title" }, + { style: ButtonStyle.Secondary, type: 2, label: "📄 Descripción", disabled, custom_id: "edit_description" }, + { style: ButtonStyle.Secondary, type: 2, label: "🎨 Color", disabled, custom_id: "edit_color" }, + { style: ButtonStyle.Secondary, type: 2, label: "➕ Contenido", disabled, custom_id: "add_content" }, + { style: ButtonStyle.Secondary, type: 2, label: "➖ Separador", disabled, custom_id: "add_separator" } ] }, { type: 1, components: [ - { style: ButtonStyle.Secondary, type: 2, label: "Añadir Imagen", disabled, custom_id: "add_image" }, - { style: ButtonStyle.Secondary, type: 2, label: "Imagen Portada", disabled, custom_id: "cover_image" }, - { style: ButtonStyle.Primary, type: 2, label: "Mover Bloque", disabled, custom_id: "move_block" }, - { style: ButtonStyle.Danger, type: 2, label: "Eliminar Bloque", disabled, custom_id: "delete_block" }, - { style: ButtonStyle.Secondary, type: 2, label: "Editar Thumbnail", disabled, custom_id: "edit_thumbnail" } + { style: ButtonStyle.Secondary, type: 2, label: "🖼️ Imagen", disabled, custom_id: "add_image" }, + { style: ButtonStyle.Secondary, type: 2, label: "🖼️ Portada", disabled, custom_id: "cover_image" }, + { style: ButtonStyle.Secondary, type: 2, label: "📎 Thumbnail", disabled, custom_id: "edit_thumbnail" }, + { style: ButtonStyle.Primary, type: 2, label: "🔄 Mover", disabled, custom_id: "move_block" }, + { style: ButtonStyle.Danger, type: 2, label: "🗑️ Eliminar", disabled, custom_id: "delete_block" } ] }, { type: 1, components: [ - { style: ButtonStyle.Success, type: 2, label: "Guardar", disabled, custom_id: "save_block" }, - { style: ButtonStyle.Danger, type: 2, label: "Cancelar", disabled, custom_id: "cancel_block" } + { style: ButtonStyle.Secondary, type: 2, label: "🎯 Variables", disabled, custom_id: "show_variables" }, + { style: ButtonStyle.Secondary, type: 2, label: "📋 Duplicar", disabled, custom_id: "duplicate_block" }, + { style: ButtonStyle.Secondary, type: 2, label: "📊 Vista Raw", disabled, custom_id: "show_raw" }, + { style: ButtonStyle.Secondary, type: 2, label: "📥 Importar", disabled, custom_id: "import_json" }, + { style: ButtonStyle.Secondary, type: 2, label: "📤 Exportar", disabled, custom_id: "export_json" } + ] + }, + { + type: 1, + components: [ + { style: ButtonStyle.Success, type: 2, label: "💾 Guardar", disabled, custom_id: "save_block" }, + { style: ButtonStyle.Danger, type: 2, label: "❌ Cancelar", disabled, custom_id: "cancel_block" } ] } ]); @@ -40,7 +50,7 @@ const btns = (disabled = false) => ([ * Validar si una URL es válida */ const isValidUrl = (url: string): boolean => { - if (!url || typeof url !== 'string') return false; + if (!url) return false; try { new URL(url); return url.startsWith('http://') || url.startsWith('https://'); @@ -49,6 +59,28 @@ const isValidUrl = (url: string): boolean => { } }; +/** + * Validar y limpiar contenido para Discord + */ +const validateContent = (content: string): string => { + if (!content || typeof content !== 'string') { + return "Sin contenido"; // Contenido por defecto + } + + // Limpiar contenido y asegurar que tenga al menos 1 carácter + const cleaned = content.trim(); + if (cleaned.length === 0) { + return "Sin contenido"; + } + + // Truncar si excede el límite de Discord (4000 caracteres) + if (cleaned.length > 4000) { + return cleaned.substring(0, 3997) + "..."; + } + + return cleaned; +}; + /** * Generar vista previa */ @@ -67,11 +99,12 @@ const renderPreview = async (blockState: any, member: any, guild: any) => { } } - // Añadir título después de la portada + // Añadir título después de la portada - VALIDAR CONTENIDO + //@ts-ignore + const processedTitle = await replaceVars(blockState.title ?? "Sin título", member, guild); previewComponents.push({ type: 10, - //@ts-ignore - content: await replaceVars(blockState.title ?? "Sin título", member, guild) + content: validateContent(processedTitle) }); // Procesar componentes en orden @@ -80,6 +113,9 @@ const renderPreview = async (blockState: any, member: any, guild: any) => { // Componente de texto con thumbnail opcional //@ts-ignore const processedThumbnail = c.thumbnail ? await replaceVars(c.thumbnail, member, guild) : null; + //@ts-ignore + const processedContent = await replaceVars(c.content || "Sin contenido", member, guild); + const validatedContent = validateContent(processedContent); if (processedThumbnail && isValidUrl(processedThumbnail)) { // Si tiene thumbnail válido, usar contenedor tipo 9 con accessory @@ -88,8 +124,7 @@ const renderPreview = async (blockState: any, member: any, guild: any) => { components: [ { type: 10, - //@ts-ignore - content: await replaceVars(c.content || " ", member, guild) + content: validatedContent } ], accessory: { @@ -101,8 +136,7 @@ const renderPreview = async (blockState: any, member: any, guild: any) => { // Sin thumbnail o thumbnail inválido, componente normal previewComponents.push({ type: 10, - //@ts-ignore - content: await replaceVars(c.content || " ", member, guild) + content: validatedContent }); } } else if (c.type === 14) { @@ -139,18 +173,23 @@ export const command: CommandMessage = { cooldown: 20, run: async (message, args, client) => { if (!message.member?.permissions.has("Administrator")) { - return message.reply("❌ No tienes permisos de Administrador."); + await message.reply("❌ No tienes permisos de Administrador."); + return; } const blockName: string | null = args[0] ?? null; if (!blockName) { - return message.reply("Debes proporcionar un nombre. Uso: `!blockcreatev2 `"); + await message.reply("Debes proporcionar un nombre. Uso: `!blockcreatev2 `"); + return; } const nameIsValid = await client.prisma.blockV2Config.findFirst({ where: { guildId: message.guild!.id, name: blockName } }); - if (nameIsValid) return message.reply("❌ Nombre ya usado!"); + if (nameIsValid) { + await message.reply("❌ Nombre ya usado!"); + return; + } // Estado inicial let blockState: any = { @@ -165,6 +204,22 @@ export const command: CommandMessage = { //@ts-ignore const editorMessage = await message.channel.send({ + content: "⚠️ **IMPORTANTE:** Prepara tus títulos, descripciones y URLs antes de empezar.\n" + + "Este editor usa **modales interactivos** y no podrás ver el chat mientras los usas.\n\n" + + "📝 **Recomendaciones:**\n" + + "• Ten preparados tus títulos y descripciones\n" + + "• Ten las URLs de imágenes listas para copiar\n" + + "• Los colores en formato HEX (#FF5733)\n" + + "• Las variables de usuario/servidor que necesites\n\n" + + "*Iniciando editor en 5 segundos...*" + }); + + // Esperar 5 segundos para que lean el mensaje + await new Promise(resolve => setTimeout(resolve, 5000)); + + //@ts-ignore + await editorMessage.edit({ + content: null, flags: 32768, components: [ await renderPreview(blockState, message.member, message.guild), @@ -173,10 +228,10 @@ export const command: CommandMessage = { }); const collector = editorMessage.createMessageComponentCollector({ - time: 300000 + time: 3600000 // 1 hora (60 minutos * 60 segundos * 1000 ms) }); - collector.on("collect", async (i) => { + collector.on("collect", async (i: any) => { if (i.user.id !== message.author.id) { await i.reply({ content: "No puedes usar este menú.", ephemeral: true }); return; @@ -184,13 +239,12 @@ export const command: CommandMessage = { // --- BOTONES --- if (i.isButton()) { - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(true)] - }); - await i.deferUpdate(); + // NO hacer deferUpdate antes de showModal + // await i.deferUpdate(); // <-- Esto causaba el error switch (i.customId) { case "save_block": { + await i.deferUpdate(); await client.prisma.blockV2Config.upsert({ where: { guildId_name: { guildId: message.guildId!, name: blockName } }, update: { config: blockState }, @@ -221,39 +275,132 @@ export const command: CommandMessage = { return; } case "cancel_block": { + await i.deferUpdate(); await editorMessage.delete(); collector.stop(); return; } case "edit_title": { - const prompt = await message.channel.send("Escribe el nuevo **título**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - blockState.title = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); + // Crear modal para editar título + const modal = new ModalBuilder() + .setCustomId('edit_title_modal') + .setTitle('📝 Editar Título del Block'); + + const titleInput = new TextInputBuilder() + .setCustomId('title_input') + .setLabel('Nuevo Título') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Escribe el nuevo título aquí...') + .setValue(blockState.title || '') + .setMaxLength(256) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(titleInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "edit_description": { + const modal = new ModalBuilder() + .setCustomId('edit_description_modal') + .setTitle('📄 Editar Descripción'); + + const descComp = blockState.components.find((c: any) => c.type === 10); + const currentDesc = descComp ? descComp.content : ''; + + const descInput = new TextInputBuilder() + .setCustomId('description_input') + .setLabel('Nueva Descripción') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Escribe la nueva descripción aquí...') + .setValue(currentDesc || '') + .setMaxLength(2000) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(descInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "edit_color": { + const modal = new ModalBuilder() + .setCustomId('edit_color_modal') + .setTitle('🎨 Editar Color del Block'); + + const currentColor = blockState.color ? `#${blockState.color.toString(16).padStart(6, '0')}` : ''; + + const colorInput = new TextInputBuilder() + .setCustomId('color_input') + .setLabel('Color en formato HEX') + .setStyle(TextInputStyle.Short) + .setPlaceholder('#FF5733 o FF5733') + .setValue(currentColor) + .setMaxLength(7) + .setRequired(false); + + const firstActionRow = new ActionRowBuilder().addComponents(colorInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "add_content": { + const modal = new ModalBuilder() + .setCustomId('add_content_modal') + .setTitle('➕ Agregar Nuevo Contenido'); + + const contentInput = new TextInputBuilder() + .setCustomId('content_input') + .setLabel('Contenido del Texto') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Escribe el contenido aquí...') + .setMaxLength(2000) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(contentInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "add_image": { + const modal = new ModalBuilder() + .setCustomId('add_image_modal') + .setTitle('🖼️ Agregar Nueva Imagen'); + + const imageUrlInput = new TextInputBuilder() + .setCustomId('image_url_input') + .setLabel('URL de la Imagen') + .setStyle(TextInputStyle.Short) + .setPlaceholder('https://ejemplo.com/imagen.png') + .setMaxLength(2000) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(imageUrlInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await i.showModal(modal); break; } case "cover_image": { if (blockState.coverImage) { // Si ya tiene portada, preguntar si editar o eliminar //@ts-ignore - const reply = await i.followUp({ - ephemeral: true, + const reply = await i.reply({ + flags: 64, // MessageFlags.Ephemeral content: "Ya tienes una imagen de portada. ¿Qué quieres hacer?", components: [ { type: 1, components: [ - { type: 2, style: ButtonStyle.Primary, label: "✏️ Editar", custom_id: "edit_cover" }, + { type: 2, style: ButtonStyle.Primary, label: "✏️ Editar", custom_id: "edit_cover_modal" }, { type: 2, style: ButtonStyle.Danger, label: "🗑️ Eliminar", custom_id: "delete_cover" } ] } @@ -270,24 +417,26 @@ export const command: CommandMessage = { }); coverCollector.on("collect", async (b: any) => { - if (b.customId === "edit_cover") { - await b.update({ content: "Escribe la nueva **URL de la imagen de portada**:", components: [] }); + if (b.customId === "edit_cover_modal") { + // Crear modal para editar portada + const modal = new ModalBuilder() + .setCustomId('edit_cover_modal') + .setTitle('🖼️ Editar Imagen de Portada'); - const prompt = await message.channel.send("Nueva URL de portada:"); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); + const coverInput = new TextInputBuilder() + .setCustomId('cover_input') + .setLabel('URL de la Imagen de Portada') + .setStyle(TextInputStyle.Short) + .setPlaceholder('https://ejemplo.com/portada.png') + .setValue(blockState.coverImage || '') + .setMaxLength(2000) + .setRequired(true); - mc.on("collect", async (collected) => { - blockState.coverImage = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); + const firstActionRow = new ActionRowBuilder().addComponents(coverInput); + modal.addComponents(firstActionRow); + + //@ts-ignore + await b.showModal(modal); } else if (b.customId === "delete_cover") { blockState.coverImage = null; await b.update({ content: "✅ Imagen de portada eliminada.", components: [] }); @@ -298,231 +447,24 @@ export const command: CommandMessage = { coverCollector.stop(); }); } else { - // No tiene portada, añadir nueva - const prompt = await message.channel.send("Escribe la **URL de la imagen de portada**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - blockState.coverImage = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - } - break; - } - case "edit_description": { - const prompt = await message.channel.send("Escribe la nueva **descripción**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - const descComp = blockState.components.find((c: any) => c.type === 10); - if (descComp) descComp.content = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - break; - } - case "edit_color": { - const prompt = await message.channel.send("Escribe el nuevo **color** en HEX (#RRGGBB)."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - const newValue = collected.content; - let parsed: number | null = null; - if (/^#?[0-9A-Fa-f]{6}$/.test(newValue)) { - parsed = parseInt(newValue.replace("#", ""), 16); - } - blockState.color = parsed; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - break; - } - case "add_content": { - const prompt = await message.channel.send("Escribe el nuevo **contenido**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - blockState.components.push({ type: 10, content: collected.content, thumbnail: null }); - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - break; - } - case "add_separator": { - //@ts-ignore - const reply = await i.followUp({ - ephemeral: true, - content: "¿El separador debe ser visible?", - components: [ - { - type: 1, - components: [ - { type: 2, style: ButtonStyle.Success, label: "✅ Visible", custom_id: "separator_visible" }, - { type: 2, style: ButtonStyle.Secondary, label: "❌ Invisible", custom_id: "separator_invisible" } - ] - } - ], - fetchReply: true - }); + // No tiene portada, crear modal para añadir nueva + const modal = new ModalBuilder() + .setCustomId('add_cover_modal') + .setTitle('🖼️ Agregar Imagen de Portada'); - //@ts-ignore - const sepCollector = reply.createMessageComponentCollector({ - componentType: ComponentType.Button, - max: 1, - time: 60000, - filter: (b: any) => b.user.id === message.author.id - }); + const coverInput = new TextInputBuilder() + .setCustomId('cover_input') + .setLabel('URL de la Imagen de Portada') + .setStyle(TextInputStyle.Short) + .setPlaceholder('https://ejemplo.com/portada.png') + .setMaxLength(2000) + .setRequired(true); - sepCollector.on("collect", async (b: any) => { - const isVisible = b.customId === "separator_visible"; - blockState.components.push({ type: 14, divider: isVisible, spacing: 1 }); - - await b.update({ - content: `✅ Separador ${isVisible ? 'visible' : 'invisible'} añadido.`, - components: [] - }); - - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - - sepCollector.stop(); - }); - break; - } - case "add_image": { - const prompt = await message.channel.send("Escribe la **URL de la imagen**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - blockState.components.push({ type: 12, url: collected.content }); - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - break; - } - case "edit_thumbnail": { - // Buscar componentes de texto para seleccionar cuál editar - const textComponents = blockState.components - .map((c: any, idx: number) => ({ component: c, index: idx })) - .filter(({ component }) => component.type === 10); - - if (textComponents.length === 0) { - //@ts-ignore - await i.followUp({ - content: "❌ No hay componentes de texto para añadir thumbnail.", - ephemeral: true - }); - break; - } - - if (textComponents.length === 1) { - // Solo un componente de texto, editarlo directamente - const prompt = await message.channel.send("Escribe la **URL del thumbnail**."); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - mc.on("collect", async (collected) => { - textComponents[0].component.thumbnail = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - } else { - // Múltiples componentes de texto, mostrar selector - const options = textComponents.map(({ component, index }) => ({ - label: `Texto: ${component.content?.slice(0, 30) || "..."}`, - value: index.toString(), - description: component.thumbnail ? "Ya tiene thumbnail" : "Sin thumbnail" - })); + const firstActionRow = new ActionRowBuilder().addComponents(coverInput); + modal.addComponents(firstActionRow); //@ts-ignore - const reply = await i.followUp({ - ephemeral: true, - content: "Selecciona el texto al que quieres añadir/editar thumbnail:", - components: [ - { - type: 1, - components: [ - { - type: 3, - custom_id: "select_text_for_thumbnail", - placeholder: "Elige un texto", - options - } - ] - } - ], - fetchReply: true - }); - - //@ts-ignore - const selCollector = reply.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - max: 1, - time: 60000, - filter: (sel: any) => sel.user.id === message.author.id - }); - - selCollector.on("collect", async (sel: any) => { - const selectedIndex = parseInt(sel.values[0]); - - await sel.update({ - content: "Escribe la **URL del thumbnail**:", - components: [] - }); - - const prompt = await message.channel.send("URL del thumbnail:"); - const mc = message.channel.createMessageCollector({ - filter: (m) => m.author.id === message.author.id, - max: 1, - time: 60000 - }); - - mc.on("collect", async (collected) => { - blockState.components[selectedIndex].thumbnail = collected.content; - await collected.delete(); - await prompt.delete(); - await editorMessage.edit({ - components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] - }); - }); - }); + await i.showModal(modal); } break; } @@ -544,8 +486,8 @@ export const command: CommandMessage = { })); //@ts-ignore - const reply = await i.followUp({ - ephemeral: true, + const reply = await i.reply({ + flags: 64, // MessageFlags.Ephemeral content: "Selecciona el bloque que quieres mover:", components: [ { @@ -653,17 +595,17 @@ export const command: CommandMessage = { }); if (options.length === 0) { + await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral //@ts-ignore - await i.followUp({ - content: "❌ No hay elementos para eliminar.", - ephemeral: true + await i.editReply({ + content: "❌ No hay elementos para eliminar." }); break; } //@ts-ignore - const reply = await i.followUp({ - ephemeral: true, + const reply = await i.reply({ + flags: 64, // MessageFlags.Ephemeral content: "Selecciona el elemento que quieres eliminar:", components: [ { @@ -705,8 +647,192 @@ export const command: CommandMessage = { break; } - default: + case "show_variables": { + await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral + //@ts-ignore + await i.editReply({ + content: "📋 **Variables Disponibles:**\n\n" + + "**👤 Usuario:**\n" + + "`{user.name}` - Nombre del usuario\n" + + "`{user.id}` - ID del usuario\n" + + "`{user.mention}` - Mención del usuario\n" + + "`{user.avatar}` - Avatar del usuario\n\n" + + "**📊 Estadísticas:**\n" + + "`{user.pointsAll}` - Puntos totales\n" + + "`{user.pointsWeekly}` - Puntos semanales\n" + + "`{user.pointsMonthly}` - Puntos mensuales\n\n" + + "**🏠 Servidor:**\n" + + "`{guild.name}` - Nombre del servidor\n" + + "`{guild.icon}` - Ícono del servidor\n\n" + + "**🔗 Invitación:**\n" + + "`{invite.name}` - Nombre del servidor invitado\n" + + "`{invite.icon}` - Ícono del servidor invitado" + }); break; + } + case "duplicate_block": { + const options = blockState.components.map((c: any, idx: number) => ({ + label: c.type === 10 ? `Texto: ${c.content?.slice(0, 30) || "..."}` + : c.type === 14 ? "Separador" + : c.type === 12 ? `Imagen: ${c.url?.slice(-30) || "..."}` + : `Componente ${c.type}`, + value: idx.toString(), + description: c.type === 10 && c.thumbnail ? "Con thumbnail" : undefined + })); + + if (options.length === 0) { + await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral + //@ts-ignore + await i.editReply({ content: "❌ No hay elementos para duplicar." }); + break; + } + + //@ts-ignore + const reply = await i.reply({ + flags: 64, // MessageFlags.Ephemeral + content: "Selecciona el elemento que quieres duplicar:", + components: [{ + type: 1, + components: [{ + type: 3, + custom_id: "duplicate_select", + placeholder: "Elige un elemento", + options + }] + }], + fetchReply: true + }); + + //@ts-ignore + const selCollector = reply.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + max: 1, + time: 60000, + filter: (sel: any) => sel.user.id === message.author.id + }); + + selCollector.on("collect", async (sel: any) => { + const idx = parseInt(sel.values[0]); + const originalComponent = blockState.components[idx]; + const duplicatedComponent = JSON.parse(JSON.stringify(originalComponent)); + + blockState.components.splice(idx + 1, 0, duplicatedComponent); + + await sel.update({ content: "✅ Elemento duplicado.", components: [] }); + await editorMessage.edit({ + components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] + }); + }); + break; + } + case "show_raw": { + const rawJson = JSON.stringify(blockState, null, 2); + const truncated = rawJson.length > 1900 ? rawJson.slice(0, 1900) + "..." : rawJson; + + //@ts-ignore + await i.reply({ + flags: 64, // MessageFlags.Ephemeral + content: `\`\`\`json\n${truncated}\`\`\`` + }); + break; + } + case "import_json": { + const modal = new ModalBuilder() + .setCustomId('import_json_modal') + .setTitle('📥 Importar JSON'); + + const jsonInput = new TextInputBuilder() + .setCustomId('json_input') + .setLabel('Pega tu configuración JSON aquí') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('{"title": "...", "components": [...]}') + .setMaxLength(4000) + .setRequired(true); + + const firstRow = new ActionRowBuilder().addComponents(jsonInput); + modal.addComponents(firstRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "export_json": { + const exportJson = JSON.stringify(blockState, null, 2); + + // Truncar si es muy largo para evitar problemas con Discord + const truncatedJson = exportJson.length > 1800 ? exportJson.slice(0, 1800) + "\n..." : exportJson; + + //@ts-ignore + await i.reply({ + flags: 64, // MessageFlags.Ephemeral + content: `📤 **JSON Exportado:**\n\`\`\`json\n${truncatedJson}\`\`\`\n\n💡 **Tip:** Copia el JSON de arriba manualmente y pégalo donde necesites.` + }); + break; + } + case "add_separator": { + const modal = new ModalBuilder() + .setCustomId('add_separator_modal') + .setTitle('➖ Agregar Separador'); + + const visibleInput = new TextInputBuilder() + .setCustomId('separator_visible') + .setLabel('¿Separador visible? (true/false)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('true o false') + .setValue('true') + .setMaxLength(5) + .setRequired(true); + + const spacingInput = new TextInputBuilder() + .setCustomId('separator_spacing') + .setLabel('Espaciado (1-3)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('1, 2 o 3') + .setValue('1') + .setMaxLength(1) + .setRequired(false); + + const firstRow = new ActionRowBuilder().addComponents(visibleInput); + const secondRow = new ActionRowBuilder().addComponents(spacingInput); + modal.addComponents(firstRow, secondRow); + + //@ts-ignore + await i.showModal(modal); + break; + } + case "edit_thumbnail": { + // Buscar el primer componente de texto para añadir/editar thumbnail + const textComp = blockState.components.find((c: any) => c.type === 10); + + if (!textComp) { + await i.deferReply({ flags: 64 }); // MessageFlags.Ephemeral + //@ts-ignore + await i.editReply({ + content: "❌ Necesitas al menos un componente de texto para añadir thumbnail." + }); + break; + } + + const modal = new ModalBuilder() + .setCustomId('edit_thumbnail_modal') + .setTitle('📎 Editar Thumbnail'); + + const thumbnailInput = new TextInputBuilder() + .setCustomId('thumbnail_input') + .setLabel('URL del Thumbnail') + .setStyle(TextInputStyle.Short) + .setPlaceholder('https://ejemplo.com/thumbnail.png o dejar vacío para eliminar') + .setValue(textComp.thumbnail || '') + .setMaxLength(2000) + .setRequired(false); + + const firstRow = new ActionRowBuilder().addComponents(thumbnailInput); + modal.addComponents(firstRow); + + //@ts-ignore + await i.showModal(modal); + break; + } } await editorMessage.edit({ @@ -715,6 +841,150 @@ export const command: CommandMessage = { } }); + // Agregar manejo de modales + //@ts-ignore + client.on('interactionCreate', async (interaction) => { + if (!interaction.isModalSubmit()) return; + if (interaction.user.id !== message.author.id) return; + if (!interaction.customId.endsWith('_modal')) return; + + try { + switch (interaction.customId) { + case 'edit_title_modal': { + blockState.title = interaction.fields.getTextInputValue('title_input'); + await interaction.reply({ content: '✅ Título actualizado.', ephemeral: true }); + break; + } + case 'edit_description_modal': { + const newDescription = interaction.fields.getTextInputValue('description_input'); + const descComp = blockState.components.find((c: any) => c.type === 10); + if (descComp) { + descComp.content = newDescription; + } else { + blockState.components.push({ type: 10, content: newDescription, thumbnail: null }); + } + await interaction.reply({ content: '✅ Descripción actualizada.', ephemeral: true }); + break; + } + case 'edit_color_modal': { + const colorInput = interaction.fields.getTextInputValue('color_input'); + if (colorInput.trim() === '') { + blockState.color = null; + } else { + let hexColor = colorInput.replace('#', ''); + if (/^[0-9A-F]{6}$/i.test(hexColor)) { + blockState.color = parseInt(hexColor, 16); + } else { + await interaction.reply({ content: '❌ Color inválido. Usa formato HEX (#FF5733)', ephemeral: true }); + return; + } + } + await interaction.reply({ content: '✅ Color actualizado.', ephemeral: true }); + break; + } + case 'add_content_modal': { + const newContent = interaction.fields.getTextInputValue('content_input'); + blockState.components.push({ type: 10, content: newContent, thumbnail: null }); + await interaction.reply({ content: '✅ Contenido añadido.', ephemeral: true }); + break; + } + case 'add_image_modal': { + const imageUrl = interaction.fields.getTextInputValue('image_url_input'); + if (isValidUrl(imageUrl)) { + blockState.components.push({ type: 12, url: imageUrl }); + await interaction.reply({ content: '✅ Imagen añadida.', ephemeral: true }); + } else { + await interaction.reply({ content: '❌ URL de imagen inválida.', ephemeral: true }); + return; + } + break; + } + case 'add_cover_modal': + case 'edit_cover_modal': { + const coverUrl = interaction.fields.getTextInputValue('cover_input'); + if (isValidUrl(coverUrl)) { + blockState.coverImage = coverUrl; + await interaction.reply({ content: '✅ Imagen de portada actualizada.', ephemeral: true }); + } else { + await interaction.reply({ content: '❌ URL de portada inválida.', ephemeral: true }); + return; + } + break; + } + case 'add_separator_modal': { + const visibleStr = interaction.fields.getTextInputValue('separator_visible').toLowerCase(); + const spacingStr = interaction.fields.getTextInputValue('separator_spacing') || '1'; + + const divider = visibleStr === 'true' || visibleStr === '1' || visibleStr === 'si' || visibleStr === 'sí'; + const spacing = Math.min(3, Math.max(1, parseInt(spacingStr) || 1)); + + blockState.components.push({ type: 14, divider, spacing }); + await interaction.reply({ content: '✅ Separador añadido.', ephemeral: true }); + break; + } + case 'edit_thumbnail_modal': { + const thumbnailUrl = interaction.fields.getTextInputValue('thumbnail_input'); + const textComp = blockState.components.find((c: any) => c.type === 10); + + if (textComp) { + if (thumbnailUrl.trim() === '' || !isValidUrl(thumbnailUrl)) { + textComp.thumbnail = null; + await interaction.reply({ content: '✅ Thumbnail eliminado.', ephemeral: true }); + } else { + textComp.thumbnail = thumbnailUrl; + await interaction.reply({ content: '✅ Thumbnail actualizado.', ephemeral: true }); + } + } + break; + } + case 'import_json_modal': { + try { + const jsonString = interaction.fields.getTextInputValue('json_input'); + const importedData = JSON.parse(jsonString); + + // Validar estructura básica + if (importedData && typeof importedData === 'object') { + blockState = { + title: importedData.title || blockState.title, + color: importedData.color || blockState.color, + coverImage: importedData.coverImage || blockState.coverImage, + components: Array.isArray(importedData.components) ? importedData.components : blockState.components + }; + + await interaction.reply({ content: '✅ JSON importado correctamente.', ephemeral: true }); + } else { + await interaction.reply({ content: '❌ Estructura JSON inválida.', ephemeral: true }); + return; + } + } catch (error) { + await interaction.reply({ content: '❌ JSON inválido. Verifica el formato.', ephemeral: true }); + return; + } + break; + } + default: + return; + } + + // Actualizar la vista previa después de cada cambio en el modal + setTimeout(async () => { + try { + await editorMessage.edit({ + components: [await renderPreview(blockState, message.member, message.guild), ...btns(false)] + }); + } catch (error) { + console.error('Error actualizando preview:', error); + } + }, 1000); + + } catch (error) { + console.error('Error en modal:', error); + try { + await interaction.reply({ content: '❌ Error procesando el modal.', ephemeral: true }); + } catch {} + } + }); + //@ts-ignore collector.on("end", async (_, reason) => { if (reason === "time") { @@ -726,4 +996,4 @@ export const command: CommandMessage = { } }); } -}; \ No newline at end of file +}; diff --git a/src/commands/messages/alliaces/setupChannel.ts b/src/commands/messages/alliaces/setupChannel.ts new file mode 100644 index 0000000..6e14993 --- /dev/null +++ b/src/commands/messages/alliaces/setupChannel.ts @@ -0,0 +1,93 @@ +import { CommandMessage } from "../../../core/types/commands"; + +export const command: CommandMessage = { + name: "setchannel-alliance", + type: "message", + aliases: ["alchannel", "channelally"], + cooldown: 10, + //@ts-ignore + run: async (message, args, client) => { + if (!message.member?.permissions.has("Administrator")) { + return message.reply("❌ No tienes permisos de Administrador."); + } + + // Validar argumentos + if (args.length < 2) { + return message.reply("❌ Uso correcto: `!setchannel-alliance <#canal|ID> `"); + } + + const channelInput = args[0]; + const blockConfigName = args[1]; + + // Extraer ID del canal + let channelId: string; + + // Si es una mención de canal (#canal) + if (channelInput.startsWith('<#') && channelInput.endsWith('>')) { + channelId = channelInput.slice(2, -1); + } + // Si es solo un ID + else if (/^\d+$/.test(channelInput)) { + channelId = channelInput; + } + else { + return message.reply("❌ Formato de canal inválido. Usa `#canal` o el ID del canal."); + } + + try { + // Verificar que el canal existe en el servidor + const channel = await message.guild?.channels.fetch(channelId); + if (!channel) { + return message.reply("❌ El canal especificado no existe en este servidor."); + } + + // Verificar que el canal es un canal de texto + if (!channel.isTextBased()) { + return message.reply("❌ El canal debe ser un canal de texto."); + } + + // Verificar que existe el blockConfig + const blockConfig = await client.prisma.blockV2Config.findFirst({ + where: { + guildId: message.guildId, + name: blockConfigName + } + }); + + if (!blockConfig) { + return message.reply(`❌ No se encontró el bloque de configuración \`${blockConfigName}\`. Asegúrate de que exista.`); + } + + // Configurar el canal de alianzas + const allianceChannel = await client.prisma.allianceChannel.upsert({ + where: { + guildId_channelId: { + guildId: message.guildId, + channelId: channelId + } + }, + create: { + guildId: message.guildId, + channelId: channelId, + blockConfigName: blockConfigName, + isActive: true + }, + update: { + blockConfigName: blockConfigName, + isActive: true, + updatedAt: new Date() + } + }); + + return message.reply(`✅ Canal de alianzas configurado correctamente!\n\n` + + `**Canal:** <#${channelId}>\n` + + `**Configuración:** \`${blockConfigName}\`\n` + + `**Estado:** Activo\n\n` + + `Los enlaces de Discord válidos en este canal ahora otorgarán puntos de alianza.`); + + } catch (error) { + console.error('Error configurando canal de alianzas:', error); + return message.reply("❌ Ocurrió un error al configurar el canal de alianzas. Inténtalo de nuevo."); + } + } +} diff --git a/src/core/lib/vars.ts b/src/core/lib/vars.ts index ab0cc69..40005bd 100644 --- a/src/core/lib/vars.ts +++ b/src/core/lib/vars.ts @@ -1,8 +1,17 @@ -import {Guild, User} from "discord.js"; +import {Guild, Invite, User} from "discord.js"; -export async function replaceVars(text: string, user: User | undefined, guild: Guild | undefined, stats?: any): Promise { +//@ts-ignore +export async function replaceVars(text: string, user: User | undefined, guild: Guild | undefined, stats?: any, invite: Invite | undefined): Promise { if(!text) return ''; + // Crear inviteObject solo si invite existe y tiene guild + const inviteObject = invite?.guild ? { + guild: { + //@ts-ignore + icon: `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.webp?size=256` + } + } : null; + return text /** * USER INFO @@ -12,9 +21,23 @@ export async function replaceVars(text: string, user: User | undefined, guild: G .replace(/(user\.mention)/g, user ? `<@${user.id}>` : '') .replace(/(user\.avatar)/g, user?.displayAvatarURL({ forceStatic: false }) ?? '') + /** + * USER STATS + */ + .replace(/(user\.pointsAll)/g, stats?.totalPoints?.toString() ?? '0') + .replace(/(user\.pointsWeekly)/g, stats?.weeklyPoints?.toString() ?? '0') + .replace(/(user\.pointsMonthly)/g, stats?.monthlyPoints?.toString() ?? '0') + /** * GUILD INFO */ .replace(/(guild\.name)/g, guild?.name ?? '') - .replace(/(guild\.icon)/g, guild?.iconURL({ forceStatic: false }) ?? ''); + .replace(/(guild\.icon)/g, guild?.iconURL({ forceStatic: false }) ?? '') + + /** + * INVITE INFO + */ + .replace(/(invite\.name)/g, invite?.guild?.name ?? "") + .replace(/(invite\.icon)/g, inviteObject?.guild.icon ?? '0') + } \ No newline at end of file diff --git a/src/events/extras/alliace.ts b/src/events/extras/alliace.ts index e69de29..28246d5 100644 --- a/src/events/extras/alliace.ts +++ b/src/events/extras/alliace.ts @@ -0,0 +1,424 @@ +import { + Message +} from "discord.js"; +// Se agrega ts +//@ts-ignore +import { PrismaClient } from "@prisma/client"; +import { replaceVars } from "../../core/lib/vars"; + +const prisma = new PrismaClient(); + +// Regex para detectar URLs válidas (corregido) +const URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/gi; + +// Dominios de Discord válidos para invitaciones +const DISCORD_DOMAINS = [ + 'discord.gg', + 'discord.com/invite', + 'discordapp.com/invite' +]; + +export async function alliance(message: Message) { + try { + // Verificar que el mensaje tenga contenido + if (!message.content || message.content.trim() === '') { + return; + } + + // Buscar enlaces en el mensaje + const links = extractValidLinks(message.content); + + if (links.length === 0) { + return; // No hay enlaces válidos + } + + // Verificar si el canal está configurado para alianzas + const allianceChannel = await prisma.allianceChannel.findFirst({ + where: { + guildId: message.guild!.id, + channelId: message.channel.id, + isActive: true + } + }); + + if (!allianceChannel) { + return; // Canal no configurado para alianzas + } + + // Verificar permisos del usuario (corregido para evitar errores con tipos de canal) + const member = await message.guild!.members.fetch(message.author.id); + + // Verificar que es un canal de texto antes de verificar permisos + if (!message.channel.isTextBased()) { + return; // No es un canal de texto + } + //@ts-ignore + const permissions = message.channel.permissionsFor(member); + if (!permissions?.has('SendMessages')) { + return; // Usuario sin permisos + } + + // Validar que los enlaces sean de Discord (invitaciones) + const validDiscordLinks = validateDiscordLinks(links); + + if (validDiscordLinks.length === 0) { + return; // No hay enlaces válidos de Discord + } + + // Procesar cada enlace válido + for (const link of validDiscordLinks) { + await processValidLink(message, allianceChannel, link); + } + + } catch (error) { + console.error('Error en función alliance:', error); + } +} + +function extractValidLinks(content: string): string[] { + const matches = content.match(URL_REGEX); + return matches || []; +} + +function validateDiscordLinks(links: string[]): string[] { + return links.filter(link => { + return DISCORD_DOMAINS.some(domain => link.includes(domain)); + }); +} + +async function processValidLink(message: Message, allianceChannel: any, link: string) { + try { + // Verificar si el enlace de Discord es válido (opcional: hacer fetch) + const inviteData = await validateDiscordInvite(link); + + if (!inviteData) { + return; // Enlace inválido o expirado + } + + // Asegurar que el usuario existe en la base de datos + await prisma.user.upsert({ + where: { id: message.author.id }, + update: {}, + create: { id: message.author.id } + }); + + // Asegurar que el guild existe en la base de datos + await prisma.guild.upsert({ + where: { id: message.guild!.id }, + update: {}, + create: { + id: message.guild!.id, + name: message.guild!.name + } + }); + + // Registrar el punto en el historial + await prisma.pointHistory.create({ + data: { + userId: message.author.id, + guildId: message.guild!.id, + channelId: allianceChannel.id, + messageId: message.id, + points: 1 + } + }); + + // Actualizar estadísticas del usuario + await updateUserStats(message.author.id, message.guild!.id); + + // Obtener estadísticas para reemplazar variables + const userStats = await getUserAllianceStats(message.author.id, message.guild!.id); + + // Enviar el bloque configurado usando Display Components + await sendBlockConfigV2(message, allianceChannel.blockConfigName, message.guild!.id, link, userStats, inviteData); + + console.log(`✅ Punto otorgado a ${message.author.tag} por enlace válido: ${link}`); + + } catch (error) { + console.error('Error procesando enlace válido:', error); + } +} + +async function validateDiscordInvite(link: string): Promise { + try { + // Extraer el código de invitación del enlace + const inviteCode = extractInviteCode(link); + if (!inviteCode) return null; + + // Hacer una solicitud a la API de Discord para validar la invitación + const response = await fetch(`https://discord.com/api/v10/invites/${inviteCode}?with_counts=true`, { + method: 'GET', + headers: { + 'User-Agent': 'DiscordBot (https://github.com/discord/discord-api-docs, 1.0)' + } + }); + + if (response.status === 200) { + const inviteData = await response.json(); + // Verificar que la invitación tenga un servidor válido + if (inviteData.guild && inviteData.guild.id) { + return inviteData; // Retornar datos completos de la invitación + } + } + + return null; + } catch (error) { + console.error('Error validando invitación de Discord:', error); + return null; // En caso de error, considerar como inválido + } +} + +function extractInviteCode(link: string): string | null { + // Patrones para extraer códigos de invitación + const patterns = [ + /discord\.gg\/([a-zA-Z0-9]+)/, + /discord\.com\/invite\/([a-zA-Z0-9]+)/, + /discordapp\.com\/invite\/([a-zA-Z0-9]+)/ + ]; + + for (const pattern of patterns) { + const match = link.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + return null; +} + +async function updateUserStats(userId: string, guildId: string) { + const now = new Date(); + + // Obtener o crear las estadísticas del usuario + let userStats = await prisma.partnershipStats.findFirst({ + where: { + userId: userId, + guildId: guildId + } + }); + + if (!userStats) { + await prisma.partnershipStats.create({ + data: { + userId: userId, + guildId: guildId, + totalPoints: 1, + weeklyPoints: 1, + monthlyPoints: 1, + lastWeeklyReset: now, + lastMonthlyReset: now + } + }); + return; + } + + // Verificar si necesita reset semanal (7 días) + const weeksPassed = Math.floor((now.getTime() - userStats.lastWeeklyReset.getTime()) / (7 * 24 * 60 * 60 * 1000)); + const needsWeeklyReset = weeksPassed >= 1; + + // Verificar si necesita reset mensual (30 días) + const daysPassed = Math.floor((now.getTime() - userStats.lastMonthlyReset.getTime()) / (24 * 60 * 60 * 1000)); + const needsMonthlyReset = daysPassed >= 30; + + // Actualizar estadísticas + await prisma.partnershipStats.update({ + where: { + userId_guildId: { + userId: userId, + guildId: guildId + } + }, + data: { + totalPoints: { increment: 1 }, + weeklyPoints: needsWeeklyReset ? 1 : { increment: 1 }, + monthlyPoints: needsMonthlyReset ? 1 : { increment: 1 }, + lastWeeklyReset: needsWeeklyReset ? now : userStats.lastWeeklyReset, + lastMonthlyReset: needsMonthlyReset ? now : userStats.lastMonthlyReset + } + }); +} + +async function sendBlockConfigV2(message: Message, blockConfigName: string, guildId: string, validLink: string, userStats?: any, inviteObject?: any) { + try { + // Obtener la configuración del bloque + const blockConfig = await prisma.blockV2Config.findFirst({ + where: { + guildId: guildId, + name: blockConfigName + } + }); + + if (!blockConfig) { + console.error(`❌ Bloque "${blockConfigName}" no encontrado para guild ${guildId}`); + return; + } + + // Procesar las variables en la configuración usando la función unificada + const processedConfig = await processConfigVariables(blockConfig.config, message.author, message.guild!, userStats, inviteObject); + + // Convertir el JSON plano a la estructura de Display Components correcta + const displayComponent = await convertConfigToDisplayComponent(processedConfig, message.author, message.guild!); + + // Enviar usando Display Components con la flag correcta + // Usar la misma estructura que el editor: flag 32768 y type 17 + //@ts-ignore + await message.reply({ + flags: 32768, // Equivalente a MessageFlags.IsComponentsV2 + components: [displayComponent] + }); + + } catch (error) { + console.error('❌ Error enviando bloque de configuración V2:', error); + console.log('Detalles del error:', error); + + // Fallback: usar mensaje simple + try { + await message.reply({ + content: '✅ ¡Enlace de alianza procesado correctamente!' + }); + } catch (fallbackError) { + console.error('❌ Error en fallback:', fallbackError); + } + } +} + +async function convertConfigToDisplayComponent(config: any, user: any, guild: any): Promise { + try { + const previewComponents = []; + + // Añadir imagen de portada primero si existe + if (config.coverImage && isValidUrl(config.coverImage)) { + const processedCoverUrl = await replaceVars(config.coverImage, user, guild); + if (isValidUrl(processedCoverUrl)) { + previewComponents.push({ + type: 12, + items: [{ media: { url: processedCoverUrl } }] + }); + } + } + + // Añadir título después de la portada + if (config.title) { + previewComponents.push({ + type: 10, + content: await replaceVars(config.title, user, guild) + }); + } + + // Procesar componentes en orden (igual que el editor) + if (config.components && Array.isArray(config.components)) { + for (const c of config.components) { + if (c.type === 10) { + // Componente de texto con thumbnail opcional + const processedThumbnail = c.thumbnail ? await replaceVars(c.thumbnail, user, guild) : null; + + if (processedThumbnail && isValidUrl(processedThumbnail)) { + // Si tiene thumbnail válido, usar contenedor tipo 9 con accessory + previewComponents.push({ + type: 9, + components: [ + { + type: 10, + content: await replaceVars(c.content || " ", user, guild) + } + ], + accessory: { + type: 11, + media: { url: processedThumbnail } + } + }); + } else { + // Sin thumbnail o thumbnail inválido, componente normal + previewComponents.push({ + type: 10, + content: await replaceVars(c.content || " ", user, guild) + }); + } + } else if (c.type === 14) { + // Separador + previewComponents.push({ + type: 14, + divider: c.divider ?? true, + spacing: c.spacing ?? 1 + }); + } else if (c.type === 12) { + // Imagen - validar URL también + const processedImageUrl = await replaceVars(c.url, user, guild); + + if (isValidUrl(processedImageUrl)) { + previewComponents.push({ + type: 12, + items: [{ media: { url: processedImageUrl } }] + }); + } + } + } + } + + // Retornar la estructura exacta que usa el editor + return { + type: 17, // Container type + accent_color: config.color ?? null, + components: previewComponents + }; + + } catch (error) { + console.error('Error convirtiendo configuración a Display Component:', error); + + // Fallback: crear un componente básico + return { + type: 17, + accent_color: null, + components: [ + { type: 10, content: 'Error al procesar la configuración del bloque.' } + ] + }; + } +} + +// Función helper para validar URLs +function isValidUrl(url: string): boolean { + if (!url || typeof url !== 'string') return false; + try { + new URL(url); + return url.startsWith('http://') || url.startsWith('https://'); + } catch { + return false; + } +} + +async function processConfigVariables(config: any, user: any, guild: any, userStats?: any, inviteObject?: any): Promise { + if (typeof config === 'string') { + // Usar la función unificada replaceVars con todos los parámetros + return await replaceVars(config, user, guild, userStats, inviteObject); + } + + if (Array.isArray(config)) { + const processedArray = []; + for (const item of config) { + processedArray.push(await processConfigVariables(item, user, guild, userStats, inviteObject)); + } + return processedArray; + } + + if (config && typeof config === 'object') { + const processedObject: any = {}; + for (const [key, value] of Object.entries(config)) { + processedObject[key] = await processConfigVariables(value, user, guild, userStats, inviteObject); + } + return processedObject; + } + + return config; +} + + +// Función auxiliar para obtener estadísticas +export async function getUserAllianceStats(userId: string, guildId: string) { + return prisma.partnershipStats.findFirst({ + where: { + userId: userId, + guildId: guildId + } + }); +} \ No newline at end of file diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 4311b64..7da1a79 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -2,10 +2,12 @@ import {bot} from "../main"; import {Events} from "discord.js"; import {redis} from "../core/redis"; import {commands} from "../core/loader"; +import {alliance} from "./extras/alliace"; bot.on(Events.MessageCreate, async (message) => { if (message.author.bot) return; + await alliance(message); const server = await bot.prisma.guild.upsert({ where: { id: message.guildId