// ============================================================ // BOT DE TICKETS — XITERKING STORE // ------------------------------------------------------------ // Este bot es multi-servidor: puede usarse en varios servidores // de Discord a la vez, cada uno con su propia configuración. // // - La config del admin (para el servidor principal de la tienda) // se edita desde admin.html → pestaña "Bot Tickets" y se guarda // en el documento config/ticketbot. // - Cada usuario registrado y aprobado puede cargar SU propia // configuración desde mibot.html (después de iniciar sesión en // ticketbot.html), guardada en la colección ticketbotConfigs, // un documento por usuario. // // Cuando alguien escribe "!panel" o toca el botón/menú de un // ticket, el bot busca automáticamente cuál de esas configuraciones // corresponde según el ID del servidor de Discord donde ocurrió // (campo "guildId" de cada configuración). Por eso cada quien // tiene que cargar el ID de SU servidor en su panel. // // NOVEDADES de esta versión: // - Un panel puede tener "un solo botón" (como antes) o "varios // tipos de ticket" (cfg.modoPanel === 'multiple'), mostrados como // varios botones o como un menú desplegable (cfg.estiloMultiple). // Cada tipo puede apuntar a su propia categoría/rol/bienvenida. // - Más variables para los textos: {usuario}, {USER_ID}, // {GUILD_NAME}, {TICKET_NUMBER}, {CANAL}, {FECHA}, {HORA}. // - El número de ticket se lleva con un contador atómico guardado // en el mismo documento de configuración (contadorTickets). // - Los emojis personalizados del servidor se pegan tal cual como // "<:nombre:id>" (Discord los muestra así al escribir "\:nombre:" // en un mensaje) — discord.js los interpreta solo, no hace falta // ningún código extra para eso. // ============================================================ require('dotenv').config(); const { Client, GatewayIntentBits, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, EmbedBuilder, ChannelType, } = require('discord.js'); const { initializeApp } = require('firebase/app'); const { getFirestore, doc, getDoc, collection, query, where, limit, getDocs, runTransaction, addDoc, serverTimestamp, } = require('firebase/firestore'); const firebaseConfig = require('./firebase-config.js'); const firebaseApp = initializeApp(firebaseConfig); const db = getFirestore(firebaseApp); const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], }); /* ============================================================ TICKETS ACTIVOS (en memoria) — se usa para: - el cierre automático por inactividad (cuánto hace que nadie escribe en el canal). - saber si hay que pedir calificación al tocar "Salir" y quién es el dueño del ticket. Se pierde si el bot se reinicia, pero no pasa nada: en el peor caso el contador de inactividad de un ticket abierto antes del reinicio arranca de nuevo. El límite de "tickets activos por usuario" NO depende de esto — se calcula leyendo el topic de los canales, que sí es permanente. ============================================================ */ const ticketsActivos = new Map(); const GRACIA_AVISO_MIN = 5; // minutos extra desde el aviso hasta el cierre const INTERVALO_CHEQUEO_MS = 60 * 1000; // Cuenta cuántos tickets tiene abiertos ahora mismo un usuario en el // servidor, leyendo el "topic" (tema) del canal — así el límite // funciona aunque el bot se haya reiniciado entre medio. function contarTicketsActivos(guild, usuarioId) { const marcador = `owner:${usuarioId}|`; return guild.channels.cache.filter( (c) => c.type === ChannelType.GuildText && typeof c.topic === 'string' && c.topic.includes(marcador) ).size; } /* ============================================================ CONFIGURACIÓN — multi-servidor: cada usuario tiene su propia config en Firestore (colección "ticketbotConfigs"), y hay una config general del admin en "config/ticketbot". El bot elige cuál usar según el ID del servidor (guild) donde se lo invoca. Devuelve { cfg, ref } — "ref" es la referencia al documento de Firestore que ganó la búsqueda, para poder incrementar el contador de tickets de ESE documento en particular. ============================================================ */ async function leerConfigPorGuild(guildId) { // 1) buscar entre las configuraciones que cargó cada usuario // registrado desde su propio panel (mibot.html) — pero SOLO // las que el admin aprobó a mano desde admin.html. const q = query( collection(db, 'ticketbotConfigs'), where('guildId', '==', String(guildId)), where('estadoAprobacion', '==', 'aprobado'), limit(1) ); const snap = await getDocs(q); if (!snap.empty) { return { cfg: snap.docs[0].data(), ref: snap.docs[0].ref }; } // 2) si ningún usuario aprobado configuró ese servidor, probar con la // configuración general del admin (la de admin.html). const adminRef = doc(db, 'config', 'ticketbot'); const adminSnap = await getDoc(adminRef); if (adminSnap.exists() && String(adminSnap.data().guildId || '') === String(guildId)) { return { cfg: adminSnap.data(), ref: adminRef }; } throw new Error( 'Este servidor todavía no tiene un bot de tickets autorizado. Si ya cargaste tu configuración en mibot.html, esperá a que el admin la apruebe desde admin.html.' ); } /* ============================================================ VARIABLES DE TEXTO — se pueden usar en el panel, la bienvenida y el nombre del canal. "usuario" se mantiene con su forma vieja ({usuario}) por compatibilidad con configuraciones ya guardadas. ============================================================ */ function reemplazarVariables(texto, ctx) { if (!texto) return ''; const mapa = { '{usuario}': ctx.usuarioMencion || '', '{USER_ID}': ctx.usuarioId || '', '{GUILD_NAME}': ctx.guildName || '', '{TICKET_NUMBER}': ctx.numeroTicket != null ? String(ctx.numeroTicket) : '', '{CANAL}': ctx.canalMencion || '', '{FECHA}': ctx.fecha || '', '{HORA}': ctx.hora || '', }; let resultado = String(texto); for (const [clave, valor] of Object.entries(mapa)) { resultado = resultado.split(clave).join(valor); } return resultado; } function colorANumero(hex) { if (!hex) return 0xe10600; const limpio = String(hex).replace('#', ''); const n = parseInt(limpio, 16); return isNaN(n) ? 0xe10600 : n; } // Arma un nombre de canal seguro para Discord a partir de una plantilla. function nombreCanalSeguro(plantilla, ctx) { return reemplazarVariables(plantilla || 'ticket-{usuario}', ctx) .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .slice(0, 90); } /* ============================================================ PUBLICAR EL PANEL ------------------------------------------------------------ - Modo clásico ("boton"): un solo botón, como antes. - Modo "multiple": varios tipos de ticket, mostrados como varios botones (hasta 5 por fila) o como un menú desplegable, según cfg.estiloMultiple. ============================================================ */ async function publicarPanel(canal, cfg) { const embed = new EmbedBuilder() .setTitle(cfg.panelTitulo || 'Soporte') .setDescription(cfg.panelDescripcion || 'Tocá el botón para abrir un ticket.') .setColor(colorANumero(cfg.panelColorHex)); if (cfg.panelImagen) embed.setImage(cfg.panelImagen); const esMultiple = cfg.modoPanel === 'multiple' && Array.isArray(cfg.tiposTicket) && cfg.tiposTicket.length > 0; if (!esMultiple) { // ---------- un solo botón (comportamiento clásico) ---------- const boton = new ButtonBuilder() .setCustomId('xk_crear_ticket') .setLabel(cfg.botonTexto || 'Abrir ticket') .setStyle(ButtonStyle.Danger); if (cfg.botonEmoji) boton.setEmoji(cfg.botonEmoji); const row = new ActionRowBuilder().addComponents(boton); await canal.send({ embeds: [embed], components: [row] }); return; } if (cfg.estiloMultiple === 'menu') { // ---------- menú desplegable ---------- const menu = new StringSelectMenuBuilder() .setCustomId('xk_crear_ticket_menu') .setPlaceholder(cfg.menuPlaceholder || 'Elegí el motivo de tu ticket') .addOptions( cfg.tiposTicket.slice(0, 25).map((t) => { const opcion = { label: (t.nombre || 'Ticket').slice(0, 100), value: t.id }; if (t.emoji) opcion.emoji = t.emoji; return opcion; }) ); const row = new ActionRowBuilder().addComponents(menu); await canal.send({ embeds: [embed], components: [row] }); return; } // ---------- varios botones ---------- const filas = []; const tipos = cfg.tiposTicket.slice(0, 25); // Discord permite máx. 5 filas x 5 botones for (let i = 0; i < tipos.length; i += 5) { const row = new ActionRowBuilder(); tipos.slice(i, i + 5).forEach((t) => { const boton = new ButtonBuilder() .setCustomId(`xk_crear_ticket::${t.id}`) .setLabel((t.nombre || 'Ticket').slice(0, 80)) .setStyle(ButtonStyle.Danger); if (t.emoji) boton.setEmoji(t.emoji); row.addComponents(boton); }); filas.push(row); } await canal.send({ embeds: [embed], components: filas }); } /* ============================================================ COMANDO "!panel" — lo escribe un admin en cualquier canal para publicar (o volver a publicar) el panel de tickets. ============================================================ */ client.on('messageCreate', async (mensaje) => { if (mensaje.author.bot) return; // marca actividad en el ticket (resetea el contador de inactividad) if (ticketsActivos.has(mensaje.channel.id)) { const info = ticketsActivos.get(mensaje.channel.id); info.ultimaActividad = Date.now(); info.avisado = false; } if (!mensaje.guild) return; if (mensaje.content.trim().toLowerCase() !== '!panel') return; if (!mensaje.member?.permissions.has(PermissionFlagsBits.ManageGuild)) { mensaje.reply('No tenés permiso para usar este comando.'); return; } try { const { cfg } = await leerConfigPorGuild(mensaje.guild.id); if (!cfg.canalPanelId) { mensaje.reply('Todavía no configuraste el ID del canal del panel.'); return; } const canal = await mensaje.guild.channels.fetch(cfg.canalPanelId).catch(() => null); if (!canal) { mensaje.reply('No encontré ese canal. Revisá el ID en tu panel.'); return; } await publicarPanel(canal, cfg); mensaje.reply(`Panel publicado en <#${cfg.canalPanelId}>.`); } catch (err) { console.error('Error al publicar el panel:', err); mensaje.reply(err.message || 'Hubo un problema al leer la configuración o publicar el panel.'); } }); /* ============================================================ INTERACCIONES: botones (uno o varios tipos), menú desplegable, y el botón de cerrar ticket. ============================================================ */ client.on('interactionCreate', async (interaccion) => { if (interaccion.isStringSelectMenu()) { if (interaccion.customId === 'xk_crear_ticket_menu') return crearTicket(interaccion, interaccion.values[0]); if (interaccion.customId.startsWith('xk_calificar::')) return calificarYCerrar(interaccion); } if (interaccion.isButton()) { if (interaccion.customId === 'xk_crear_ticket') return crearTicket(interaccion, null); if (interaccion.customId.startsWith('xk_crear_ticket::')) { return crearTicket(interaccion, interaccion.customId.split('::')[1]); } if (interaccion.customId === 'xk_cerrar_ticket') return cerrarTicket(interaccion); if (interaccion.customId === 'xk_salir_ticket') return salirTicket(interaccion); } }); // Incrementa (de forma atómica) el contador de tickets guardado en // el documento de configuración, y devuelve el número que le tocó // a ESTE ticket. Así {TICKET_NUMBER} nunca se repite ni se pisa // aunque se abran dos tickets al mismo tiempo. async function siguienteNumeroTicket(configRef) { return runTransaction(db, async (transaccion) => { const snap = await transaccion.get(configRef); const actual = (snap.exists() && Number(snap.data().contadorTickets)) || 0; const siguiente = actual + 1; transaccion.update(configRef, { contadorTickets: siguiente }); return siguiente; }); } async function crearTicket(interaccion, tipoId) { await interaccion.deferReply({ ephemeral: true }); let cfg; let configRef; try { const resultado = await leerConfigPorGuild(interaccion.guild.id); cfg = resultado.cfg; configRef = resultado.ref; } catch (err) { console.error(err); await interaccion.editReply(err.message || 'No se pudo leer la configuración del bot. Avisale al admin.'); return; } // Si el panel es de "varios tipos", busca los datos propios de ESE tipo; // si no lo encuentra (o el panel es de un solo botón), usa la config general. const tipo = (tipoId && Array.isArray(cfg.tiposTicket)) ? cfg.tiposTicket.find((t) => t.id === tipoId) : null; const categoriaId = tipo?.categoriaId || cfg.categoriaId; if (!categoriaId) { await interaccion.editReply('Todavía no se configuró la categoría donde se crean los tickets.'); return; } const guild = interaccion.guild; const usuario = interaccion.user; // límite de tickets activos por usuario (0 = sin límite) const maxActivos = Number(cfg.maxTicketsActivos) || 0; if (maxActivos > 0) { await guild.channels.fetch().catch(() => {}); const activos = contarTicketsActivos(guild, usuario.id); if (activos >= maxActivos) { await interaccion.editReply( `Ya tenés ${activos} ${activos === 1 ? 'ticket abierto' : 'tickets abiertos'} (máximo permitido: ${maxActivos}). Cerrá alguno antes de abrir uno nuevo.` ); return; } } let numeroTicket = null; try { numeroTicket = await siguienteNumeroTicket(configRef); } catch (err) { console.error('Error al generar el número de ticket (se sigue igual, sin número):', err); } const ahora = new Date(); const ctxVariables = { usuarioMencion: `<@${usuario.id}>`, usuarioId: usuario.id, guildName: guild.name, numeroTicket, canalMencion: '', // se completa después de crear el canal fecha: ahora.toLocaleDateString('es-AR'), hora: ahora.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' }), }; const nombreCanal = nombreCanalSeguro(tipo?.nombreCanal || cfg.nombreCanal, ctxVariables); // evita que la misma persona tenga dos tickets abiertos a la vez (del mismo tipo) const yaExiste = guild.channels.cache.find( (c) => c.name === nombreCanal && c.parentId === categoriaId ); if (yaExiste) { await interaccion.editReply(`Ya tenés un ticket abierto: ${yaExiste}`); return; } const overwrites = [ { id: guild.roles.everyone.id, type: 0, deny: [PermissionFlagsBits.ViewChannel] }, { id: usuario.id, type: 1, // 1 = member allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }, // el bot también necesita permiso explícito, si no queda bloqueado // afuera de su propio canal (por eso el error "Missing Access"). // Se usa client.user.id (el ID del bot) en vez de guild.members.me.id // porque es siempre confiable, sin depender de que el member esté cacheado. { id: client.user.id, type: 1, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageChannels, ], }, ]; const rolesStaff = String(tipo?.rolStaffId || cfg.rolStaffId || '') .split(',') .map((s) => s.trim()) .filter(Boolean); rolesStaff.forEach((rolId) => { overwrites.push({ id: rolId, type: 0, // 0 = role allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }); }); let canalTicket; try { canalTicket = await guild.channels.create({ name: nombreCanal, type: ChannelType.GuildText, parent: categoriaId, permissionOverwrites: overwrites, // el "topic" guarda quién es el dueño del ticket, así se puede // contar cuántos tickets tiene abiertos aunque el bot se reinicie. topic: `xk-ticket|owner:${usuario.id}|num:${numeroTicket != null ? numeroTicket : ''}`, }); } catch (err) { console.error('Error al crear el canal del ticket:', err); await interaccion.editReply( 'No se pudo crear el canal. Revisá que el ID de categoría sea correcto y que el bot tenga permiso de "Gestionar canales" en el servidor.' ); return; } ticketsActivos.set(canalTicket.id, { ownerId: usuario.id, guildId: guild.id, numeroTicket, cierreInactividadMin: Number(cfg.cierreInactividadMinutos) || 0, pedirCalificacion: cfg.pedirCalificacion === 'si', ultimaActividad: Date.now(), avisado: false, avisadoEn: null, }); ctxVariables.canalMencion = `${canalTicket}`; const bienvenidaTitulo = tipo?.bienvenidaTitulo || cfg.bienvenidaTitulo || '¡Gracias por tu consulta!'; const bienvenidaDescripcion = tipo?.bienvenidaDescripcion || cfg.bienvenidaDescripcion || 'En breve te van a responder.'; const embed = new EmbedBuilder() .setTitle(reemplazarVariables(bienvenidaTitulo, ctxVariables)) .setDescription(reemplazarVariables(bienvenidaDescripcion, ctxVariables)) .setColor(colorANumero(tipo?.colorHex || cfg.panelColorHex)); if (numeroTicket != null) { embed.setFooter({ text: `Ticket #${numeroTicket}` }); } const botonCerrar = new ButtonBuilder() .setCustomId('xk_cerrar_ticket') .setLabel(cfg.cerrarTexto || 'Cerrar ticket') .setStyle(ButtonStyle.Secondary); // "Salir" lo usa quien abrió el ticket: si está activada la // calificación, primero pide que puntúe la atención (1 a 5) y // recién ahí cierra el canal. const botonSalir = new ButtonBuilder() .setCustomId('xk_salir_ticket') .setLabel(cfg.salirTexto || 'Salir') .setStyle(ButtonStyle.Danger); const row = new ActionRowBuilder().addComponents(botonCerrar, botonSalir); try { await canalTicket.send({ content: [`<@${usuario.id}>`, ...rolesStaff.map((r) => `<@&${r}>`)].join(' '), embeds: [embed], components: [row], }); await interaccion.editReply(`Listo, tu ticket es ${canalTicket}`); } catch (err) { console.error('Error al mandar el mensaje de bienvenida:', err); await interaccion.editReply( `Se creó el canal ${canalTicket} pero no se pudo mandar el mensaje de bienvenida. Revisá los permisos del bot (necesita "Ver canal" y "Enviar mensajes" ahí adentro).` ); } } async function cerrarTicket(interaccion) { ticketsActivos.delete(interaccion.channel.id); await interaccion.reply('Cerrando este ticket en 5 segundos…'); setTimeout(() => { interaccion.channel?.delete().catch((err) => console.error('Error al borrar el canal del ticket:', err)); }, 5000); } /* ============================================================ BOTÓN "SALIR" — lo toca quien abrió el ticket. Si el panel tiene activada la calificación, primero pide que puntúe la atención (1 a 5 estrellas) y recién ahí cierra; si no, cierra directo, igual que "Cerrar ticket". ============================================================ */ async function salirTicket(interaccion) { const info = ticketsActivos.get(interaccion.channel.id); // si sabemos quién es el dueño del ticket, solo esa persona puede "salir" if (info?.ownerId && interaccion.user.id !== info.ownerId) { await interaccion.reply({ content: 'Solo quien abrió este ticket puede usar el botón "Salir". El staff puede usar "Cerrar ticket".', ephemeral: true, }); return; } if (info?.pedirCalificacion) { const menu = new StringSelectMenuBuilder() .setCustomId(`xk_calificar::${interaccion.channel.id}`) .setPlaceholder('¿Cómo calificarías la atención?') .addOptions([ { label: '⭐ 1 — Mala', value: '1' }, { label: '⭐⭐ 2 — Regular', value: '2' }, { label: '⭐⭐⭐ 3 — Buena', value: '3' }, { label: '⭐⭐⭐⭐ 4 — Muy buena', value: '4' }, { label: '⭐⭐⭐⭐⭐ 5 — Excelente', value: '5' }, ]); const row = new ActionRowBuilder().addComponents(menu); await interaccion.reply({ content: 'Antes de cerrar, contanos cómo fue tu experiencia:', components: [row], ephemeral: true, }); return; } return cerrarTicket(interaccion); } // Guarda la calificación en Firestore (colección "calificaciones") y // recién ahí cierra el canal. async function calificarYCerrar(interaccion) { const channelId = interaccion.customId.split('::')[1]; const valor = Number(interaccion.values[0]); const info = ticketsActivos.get(channelId); try { await addDoc(collection(db, 'calificaciones'), { guildId: interaccion.guild?.id || info?.guildId || '', canalId: channelId, numeroTicket: info?.numeroTicket ?? null, usuarioId: interaccion.user.id, usuarioTag: interaccion.user.tag, valor, fecha: serverTimestamp(), }); } catch (err) { console.error('Error al guardar la calificación del ticket:', err); } ticketsActivos.delete(channelId); await interaccion.update({ content: '¡Gracias por tu calificación! Cerrando el ticket en 5 segundos…', components: [] }); setTimeout(() => { interaccion.channel?.delete().catch((err) => console.error('Error al borrar el canal del ticket:', err)); }, 5000); } /* ============================================================ CIERRE AUTOMÁTICO POR INACTIVIDAD ------------------------------------------------------------ Cada minuto revisa los tickets activos: si nadie escribió durante "cierreInactividadMinutos" (configurable por panel), manda un aviso; si pasan otros 5 minutos más sin actividad, cierra el canal solo. ============================================================ */ setInterval(() => { const ahora = Date.now(); for (const [channelId, info] of ticketsActivos.entries()) { if (!info.cierreInactividadMin || info.cierreInactividadMin <= 0) continue; const minutosInactivo = (ahora - info.ultimaActividad) / 60000; if (!info.avisado && minutosInactivo >= info.cierreInactividadMin) { info.avisado = true; info.avisadoEn = ahora; const canal = client.channels.cache.get(channelId); canal ?.send( `⚠️ Este ticket lleva ${info.cierreInactividadMin} minutos sin actividad. Se va a cerrar automáticamente en ${GRACIA_AVISO_MIN} minutos si nadie responde.` ) .catch(() => {}); continue; } if (info.avisado && (ahora - info.avisadoEn) / 60000 >= GRACIA_AVISO_MIN) { ticketsActivos.delete(channelId); const canal = client.channels.cache.get(channelId); if (canal) { canal.send('🔒 Cerrando este ticket automáticamente por inactividad…').catch(() => {}); setTimeout(() => { canal.delete().catch((err) => console.error('Error al cerrar ticket por inactividad:', err)); }, 4000); } } } }, INTERVALO_CHEQUEO_MS); // si alguien borra el canal a mano (sin usar los botones), lo sacamos // del mapa para no dejar basura en memoria. client.on('channelDelete', (canal) => { ticketsActivos.delete(canal.id); }); /* ============================================================ Que un error puntual (ej: falta de permisos en un canal) no tumbe el proceso entero — solo lo logueamos y seguimos online. ============================================================ */ client.on('error', (err) => console.error('Error del cliente de Discord:', err)); process.on('unhandledRejection', (err) => console.error('Promesa sin manejar:', err)); /* ============================================================ */ client.once('ready', () => { console.log(`Bot de tickets conectado como ${client.user.tag}`); }); if (!process.env.DISCORD_TOKEN) { console.error('Falta DISCORD_TOKEN. Creá un archivo .env (mirá .env.example) con el token del bot.'); process.exit(1); } client.login(process.env.DISCORD_TOKEN);