From b218dd45014e2dd79755c5268e31cee7d91f06e1 Mon Sep 17 00:00:00 2001 From: shnimlz Date: Sat, 11 Oct 2025 15:51:15 -0500 Subject: [PATCH] =?UTF-8?q?Refactor=20y=20mejora=20de=20la=20funci=C3=B3n?= =?UTF-8?q?=20'alliance'=20para=20validar=20enlaces=20de=20Discord=20y=20o?= =?UTF-8?q?ptimizar=20el=20manejo=20de=20errores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/extras/alliace.ts | 947 ++++++++++++++++++++--------------- 1 file changed, 550 insertions(+), 397 deletions(-) diff --git a/src/events/extras/alliace.ts b/src/events/extras/alliace.ts index e26bd55..2668f14 100644 --- a/src/events/extras/alliace.ts +++ b/src/events/extras/alliace.ts @@ -1,485 +1,638 @@ -import { - Message -} from "discord.js"; +import { Message } from "discord.js"; // Reemplaza instancia local -> usa singleton import { prisma } from "../../core/database/prisma"; import { replaceVars } from "../../core/lib/vars"; import logger from "../../core/lib/logger"; import { sendComponentsV2Message } from "../../core/api/discordAPI"; - // 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; +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' + "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) { - logger.error({ err: error }, 'Error en función alliance'); + 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) { + logger.error({ err: error }, "Error en función alliance"); + } } function extractValidLinks(content: string): string[] { - const matches = content.match(URL_REGEX); - return matches || []; -} + const matches = content.match(URL_REGEX); + if (!matches) return []; -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) { + const results: string[] = []; + for (const raw of matches) { try { - // Verificar si el enlace de Discord es válido (opcional: hacer fetch) - const inviteData = await validateDiscordInvite(link); + const url = new URL(raw); - if (!inviteData) { - return; // Enlace inválido o expirado - } + // Sólo http/https + if (url.protocol !== "http:" && url.protocol !== "https:") continue; - // 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 } - }); + // Rechazar URLs con query string o fragment (ej: ?event=...) + if (url.search || url.hash) continue; - // 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 - } - }); + // Normalizar hostname y pathname + const host = url.hostname.toLowerCase(); + const pathname = url.pathname.replace(/\/+/g, "/"); - // 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 - } - }); + // Validar formatos de invitación de Discord estrictos + if (host === "discord.gg") { + // debe ser / + if (!/^\/[A-Za-z0-9]+$/.test(pathname)) continue; + } else if ( + host === "discord.com" || + host === "www.discord.com" || + host === "discordapp.com" || + host === "www.discordapp.com" + ) { + // debe ser /invite/ + if (!/^\/invite\/[A-Za-z0-9]+$/.test(pathname)) continue; + } - // 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); - - logger.info(`✅ Punto otorgado a ${message.author.tag} por enlace válido: ${link}`); - - } catch (error) { - logger.error({ err: error }, 'Error procesando enlace válido'); + // Si llegó hasta aquí, es aceptable + results.push(url.toString()); + } catch { + // ignorar coincidencias inválidas + continue; } + } + + return results; +} +// ...existing code... +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 + ); + + logger.info( + `✅ Punto otorgado a ${message.author.tag} por enlace válido: ${link}` + ); + } catch (error) { + logger.error({ err: error }, "Error procesando enlace válido"); + } } async function validateDiscordInvite(link: string): Promise { - try { - // Extraer el código de invitación del enlace - const inviteCode = extractInviteCode(link); - if (!inviteCode) return null; + 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)' - } - }); + // 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) { - logger.error({ err: error }, 'Error validando invitación de Discord'); - 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]; - } + 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) { + logger.error({ err: error }, "Error validando invitación de Discord"); + 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(); + const now = new Date(); - // Obtener o crear las estadísticas del usuario - let userStats = await prisma.partnershipStats.findFirst({ - where: { - userId: userId, - guildId: guildId - } + // 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; + } - 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 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; - // 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 - } - }); + // 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 - } - }); +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) { - logger.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!); - - // Construir adjuntos desde la config si existen - const attachments = buildAttachmentsFromConfig(processedConfig); - - // Enviar usando Display Components con la flag correcta a través del cliente REST tipado - await sendComponentsV2Message(message.channel.id, { - components: [displayComponent], - replyToMessageId: message.id, - attachments: attachments.length ? attachments : undefined, - }); - - } catch (error) { - logger.error({ err: error }, '❌ Error enviando bloque de configuración V2'); - - // Fallback: usar mensaje simple - try { - await message.reply({ - content: '✅ ¡Enlace de alianza procesado correctamente!' - }); - } catch (fallbackError) { - logger.error({ err: fallbackError }, '❌ Error en fallback'); - } + if (!blockConfig) { + logger.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! + ); + + // Construir adjuntos desde la config si existen + const attachments = buildAttachmentsFromConfig(processedConfig); + + // Enviar usando Display Components con la flag correcta a través del cliente REST tipado + await sendComponentsV2Message(message.channel.id, { + components: [displayComponent], + replyToMessageId: message.id, + attachments: attachments.length ? attachments : undefined, + }); + } catch (error) { + logger.error( + { err: error }, + "❌ Error enviando bloque de configuración V2" + ); + + // Fallback: usar mensaje simple + try { + await message.reply({ + content: "✅ ¡Enlace de alianza procesado correctamente!", + }); + } catch (fallbackError) { + logger.error({ err: fallbackError }, "❌ Error en fallback"); + } + } } // Extrae adjuntos desde la config (base64) para usar attachment:// function buildAttachmentsFromConfig(config: any) { - const results: { name: string; data: Buffer; description?: string; spoiler?: boolean }[] = []; - if (!config || typeof config !== 'object') return results; + const results: { + name: string; + data: Buffer; + description?: string; + spoiler?: boolean; + }[] = []; + if (!config || typeof config !== "object") return results; - const arr = Array.isArray(config.attachments) ? config.attachments : []; - for (const item of arr) { - if (!item || typeof item !== 'object') continue; - const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null; - const description = typeof item.description === 'string' ? item.description : undefined; - const spoiler = Boolean(item.spoiler); - const raw = typeof item.dataBase64 === 'string' ? item.dataBase64 : (typeof item.data === 'string' ? item.data : null); - if (!name || !raw) continue; - const buf = decodeBase64Payload(raw); - if (!buf) continue; - results.push({ name, data: buf, description, spoiler }); - } - return results; + const arr = Array.isArray(config.attachments) ? config.attachments : []; + for (const item of arr) { + if (!item || typeof item !== "object") continue; + const name = + typeof item.name === "string" && item.name.trim() + ? item.name.trim() + : null; + const description = + typeof item.description === "string" ? item.description : undefined; + const spoiler = Boolean(item.spoiler); + const raw = + typeof item.dataBase64 === "string" + ? item.dataBase64 + : typeof item.data === "string" + ? item.data + : null; + if (!name || !raw) continue; + const buf = decodeBase64Payload(raw); + if (!buf) continue; + results.push({ name, data: buf, description, spoiler }); + } + return results; } function decodeBase64Payload(raw: string): Buffer | null { - try { - let base64 = raw.trim(); - // Soportar formatos: "base64:..." o data URLs "data:mime/type;base64,...." - if (base64.startsWith('base64:')) { - base64 = base64.slice('base64:'.length); - } else if (base64.startsWith('data:')) { - const comma = base64.indexOf(','); - if (comma !== -1) base64 = base64.slice(comma + 1); - } - return Buffer.from(base64, 'base64'); - } catch { - return null; + try { + let base64 = raw.trim(); + // Soportar formatos: "base64:..." o data URLs "data:mime/type;base64,...." + if (base64.startsWith("base64:")) { + base64 = base64.slice("base64:".length); + } else if (base64.startsWith("data:")) { + const comma = base64.indexOf(","); + if (comma !== -1) base64 = base64.slice(comma + 1); } + return Buffer.from(base64, "base64"); + } catch { + return null; + } } // Helper: URLs http/https únicamente function isHttpUrl(url: unknown): url is string { - if (typeof url !== 'string' || !url) return false; - try { - const u = new URL(url); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } + if (typeof url !== "string" || !url) return false; + try { + const u = new URL(url); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } } // Helper: permitir http/https y attachment:// para medios (thumbnail/media/file) function isMediaUrl(url: unknown): boolean { - if (typeof url !== 'string' || !url) return false; - if (isHttpUrl(url)) return true; - const s = url as string; - return s.startsWith('attachment://'); + if (typeof url !== "string" || !url) return false; + if (isHttpUrl(url)) return true; + const s = url as string; + return s.startsWith("attachment://"); } // Helper: construir accessory de Link Button para Display Components async function buildLinkAccessory(link: any, user: any, guild: any) { - try { - if (!link || !link.url) return null; - // @ts-ignore - const processedUrl = await replaceVars(link.url, user, guild); - // En botones de enlace solo se permite http/https - if (!isHttpUrl(processedUrl)) return null; - const accessory: any = { type: 2, style: 5, url: processedUrl }; - if (link.label && typeof link.label === 'string' && link.label.trim()) { - accessory.label = link.label.trim().slice(0, 80); - } - if (link.emoji && typeof link.emoji === 'string') { - const parsed = parseEmojiInput(link.emoji); - if (parsed) accessory.emoji = parsed; - } - // Debe tener al menos label o emoji - if (!accessory.label && !accessory.emoji) return null; - return accessory; - } catch { - return null; + try { + if (!link || !link.url) return null; + // @ts-ignore + const processedUrl = await replaceVars(link.url, user, guild); + // En botones de enlace solo se permite http/https + if (!isHttpUrl(processedUrl)) return null; + const accessory: any = { type: 2, style: 5, url: processedUrl }; + if (link.label && typeof link.label === "string" && link.label.trim()) { + accessory.label = link.label.trim().slice(0, 80); } + if (link.emoji && typeof link.emoji === "string") { + const parsed = parseEmojiInput(link.emoji); + if (parsed) accessory.emoji = parsed; + } + // Debe tener al menos label o emoji + if (!accessory.label && !accessory.emoji) return null; + return accessory; + } catch { + return null; + } } -async function convertConfigToDisplayComponent(config: any, user: any, guild: any): Promise { - try { - const previewComponents: any[] = []; +async function convertConfigToDisplayComponent( + config: any, + user: any, + guild: any +): Promise { + try { + const previewComponents: any[] = []; - // Añadir imagen de portada primero si existe - if (config.coverImage) { - // @ts-ignore - const processedCoverUrl = await replaceVars(config.coverImage, user, guild); - if (isMediaUrl(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, - // @ts-ignore - 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) { - // Texto con accessory opcional: priorizar linkButton > thumbnail - // @ts-ignore - const processedContent = await replaceVars(c.content || " ", user, guild); - // @ts-ignore - const processedThumbnail = c.thumbnail ? await replaceVars(c.thumbnail, user, guild) : null; - - let accessory: any = null; - if (c.linkButton) { - accessory = await buildLinkAccessory(c.linkButton, user, guild); - } - if (!accessory && processedThumbnail && isMediaUrl(processedThumbnail)) { - accessory = { type: 11, media: { url: processedThumbnail } }; - } - - if (accessory) { - previewComponents.push({ type: 9, components: [{ type: 10, content: processedContent }], accessory }); - } else { - previewComponents.push({ type: 10, content: processedContent }); - } - } else if (c.type === 14) { - previewComponents.push({ type: 14, divider: c.divider ?? true, spacing: c.spacing ?? 1 }); - } else if (c.type === 12) { - // Imagen - validar http/https o attachment:// - // @ts-ignore - const processedImageUrl = await replaceVars(c.url, user, guild); - if (isMediaUrl(processedImageUrl)) { - previewComponents.push({ type: 12, items: [{ media: { url: processedImageUrl } }] }); - } - } - } - } - - // Retornar la estructura exacta que usa el editor - return { type: 17, accent_color: config.color ?? null, components: previewComponents }; - - } catch (error) { - logger.error({ err: error }, 'Error convirtiendo configuración a Display Component'); - return { type: 17, accent_color: null, components: [ { type: 10, content: 'Error al procesar la configuración del bloque.' } ] }; + // Añadir imagen de portada primero si existe + if (config.coverImage) { + // @ts-ignore + const processedCoverUrl = await replaceVars( + config.coverImage, + user, + guild + ); + if (isMediaUrl(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, + // @ts-ignore + 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) { + // Texto con accessory opcional: priorizar linkButton > thumbnail + // @ts-ignore + const processedContent = await replaceVars( + c.content || " ", + user, + guild + ); + // @ts-ignore + const processedThumbnail = c.thumbnail + ? await replaceVars(c.thumbnail, user, guild) + : null; + + let accessory: any = null; + if (c.linkButton) { + accessory = await buildLinkAccessory(c.linkButton, user, guild); + } + if ( + !accessory && + processedThumbnail && + isMediaUrl(processedThumbnail) + ) { + accessory = { type: 11, media: { url: processedThumbnail } }; + } + + if (accessory) { + previewComponents.push({ + type: 9, + components: [{ type: 10, content: processedContent }], + accessory, + }); + } else { + previewComponents.push({ type: 10, content: processedContent }); + } + } else if (c.type === 14) { + previewComponents.push({ + type: 14, + divider: c.divider ?? true, + spacing: c.spacing ?? 1, + }); + } else if (c.type === 12) { + // Imagen - validar http/https o attachment:// + // @ts-ignore + const processedImageUrl = await replaceVars(c.url, user, guild); + if (isMediaUrl(processedImageUrl)) { + previewComponents.push({ + type: 12, + items: [{ media: { url: processedImageUrl } }], + }); + } + } + } + } + + // Retornar la estructura exacta que usa el editor + return { + type: 17, + accent_color: config.color ?? null, + components: previewComponents, + }; + } catch (error) { + logger.error( + { err: error }, + "Error convirtiendo configuración a Display Component" + ); + return { + type: 17, + accent_color: null, + components: [ + { type: 10, content: "Error al procesar la configuración del bloque." }, + ], + }; + } } // Helper: parsear emojis (unicode o personalizados <:name:id> / ) function parseEmojiInput(input?: string): any | 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 }; - } - // Asumimos unicode si no es formato de emoji personalizado - return { name: trimmed }; + 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 }; + } + // Asumimos unicode si no es formato de emoji personalizado + return { name: trimmed }; } // Función helper para validar URLs (http/https y attachment:// para medios) -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); - } +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) { - // @ts-ignore - processedArray.push(await processConfigVariables(item, user, guild, userStats, inviteObject)); - } - return processedArray; + if (Array.isArray(config)) { + const processedArray: any[] = []; + for (const item of config) { + // @ts-ignore + 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; + 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; + 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 + return prisma.partnershipStats.findFirst({ + where: { + userId: userId, + guildId: guildId, + }, + }); +}