Internacionalización
🌍 Internacionalización (i18n)
Sistema completo de traducciones y soporte multiidioma para hacer el bot accesible a comunidades globales.
✨ Características
- Idiomas soportados: Español (es) e Inglés (en)
- Traducciones dinámicas en tiempo real
- Variables en traducciones para contenido personalizado
- Configuración por servidor independiente
- Fallback automático a idioma por defecto
- Fácil extensión para nuevos idiomas
🏗️ Arquitectura del Sistema
Estructura de Archivos
src/locales/├── es.json # Español (idioma por defecto)├── en.json # Inglés└── [futuro] ├── fr.json # Francés ├── pt.json # Portugués └── de.json # AlemánGestor de Traducciones
const fs = require('fs');const path = require('path');const db = require('./database');
class I18nManager { constructor() { this.translations = new Map(); this.defaultLocale = 'es'; this.loadTranslations(); }
loadTranslations() { const localesPath = path.join(__dirname, '..', 'locales'); const files = fs.readdirSync(localesPath);
for (const file of files) { if (file.endsWith('.json')) { const locale = file.replace('.json', ''); const filePath = path.join(localesPath, file);
try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); this.translations.set(locale, content); console.log(`✅ Idioma cargado: ${locale}`); } catch (error) { console.error(`❌ Error cargando idioma ${locale}:`, error); } } } }
get(locale, key, replacements = {}) { // Obtener traducción del idioma solicitado let translation = this.getTranslation(locale, key);
// Fallback al idioma por defecto si no existe if (!translation && locale !== this.defaultLocale) { translation = this.getTranslation(this.defaultLocale, key); }
// Fallback a la clave si no hay traducción if (!translation) { return key; }
// Reemplazar variables return this.replacePlaceholders(translation, replacements); }
getTranslation(locale, key) { const translations = this.translations.get(locale); if (!translations) return null;
// Navegar por claves anidadas (ej: "commands.ping.description") const keys = key.split('.'); let value = translations;
for (const k of keys) { if (value && typeof value === 'object' && k in value) { value = value[k]; } else { return null; } }
return typeof value === 'string' ? value : null; }
replacePlaceholders(text, replacements) { return text.replace(/{(\w+)}/g, (match, key) => { if (key in replacements) { return replacements[key]; } return match; }); }
getAvailableLocales() { return Array.from(this.translations.keys()); }
async getGuildLocale(guildId) { const config = await db.getGuildConfig(guildId); return config?.language || this.defaultLocale; }}
const i18n = new I18nManager();
// Función helper para obtener traduccionesasync function getTranslation(guildId) { const locale = await i18n.getGuildLocale(guildId);
return function t(key, replacements = {}) { return i18n.get(locale, key, replacements); };}
module.exports = { i18n, getTranslation };📝 Estructura de Traducciones
Archivo Español (es.json)
{ "commands": { "ping": { "description": "Muestra la latencia del bot", "response": "🏓 Pong! Latencia: {latency}ms" }, "help": { "description": "Muestra la lista de comandos disponibles", "title": "Comandos Disponibles", "footer": "Usa /{command} para más información" }, "language": { "description": "Cambia el idioma del bot para este servidor", "current": "Idioma actual: **{language}**", "changed": "Idioma cambiado a **{language}** ✅", "select": "Selecciona el nuevo idioma:" } }, "systems": { "welcome": { "setup": { "title": "Configuración de Bienvenida", "description": "Configura el sistema de bienvenida para nuevos miembros", "status": { "enabled": "✅ Habilitado", "disabled": "❌ Deshabilitado", "channel": "Canal: {channel}", "noChannel": "Sin canal configurado", "message": "Mensaje: {message}", "defaultMessage": "Mensaje por defecto", "image": "Imagen: {url}", "noImage": "Sin imagen configurada" }, "actions": { "setChannel": "📢 Establecer Canal", "configMessage": "💬 Configurar Mensaje", "configImage": "🖼️ Configurar Imagen", "test": "🧪 Probar Bienvenida", "toggle": "🔄 Alternar Sistema" } } }, "tickets": { "setup": { "title": "Configuración de Tickets", "description": "Sistema profesional de soporte técnico", "panel": { "title": "🎫 Sistema de Soporte", "description": "Selecciona el tipo de ayuda que necesitas:", "footer": "El staff te atenderá lo antes posible" } } } }, "errors": { "noPermission": "❌ No tienes permisos para usar este comando", "userNotFound": "❌ Usuario no encontrado", "invalidChannel": "❌ Canal inválido", "botMissingPermissions": "❌ No tengo permisos suficientes", "commandCooldown": "⏰ Debes esperar {time} segundos antes de usar este comando nuevamente", "unexpectedError": "❌ Ocurrió un error inesperado" }, "success": { "configUpdated": "✅ Configuración actualizada correctamente", "userBanned": "✅ Usuario baneado exitosamente", "userKicked": "✅ Usuario expulsado exitosamente", "messagesSent": "✅ {count} mensajes enviados" }, "info": { "loading": "⏳ Cargando...", "processing": "🔄 Procesando...", "completed": "✅ Completado", "cancelled": "❌ Cancelado" }, "time": { "seconds": "segundo{s}", "minutes": "minuto{s}", "hours": "hora{s}", "days": "día{s}", "weeks": "semana{s}", "months": "mes{es}", "years": "año{s}", "ago": "hace {time}", "in": "en {time}" }, "moderation": { "ban": { "description": "Banea a un usuario del servidor", "reason": "Baneado por {moderator}", "success": "Usuario {user} baneado por: {reason}", "dmMessage": "Has sido baneado de **{server}** por: {reason}" }, "kick": { "description": "Expulsa a un usuario del servidor", "reason": "Expulsado por {moderator}", "success": "Usuario {user} expulsado por: {reason}", "dmMessage": "Has sido expulsado de **{server}** por: {reason}" } }, "levels": { "rank": { "description": "Muestra tu nivel actual o el de otro usuario", "title": "🎮 Nivel de {user}", "level": "Nivel Actual: **{level}**", "xp": "XP Total: **{xp}**", "nextLevel": "XP para Nivel {next}: **{needed}**", "progress": "Progreso: **{percentage}%**", "ranking": "🏆 Ranking: **#{position}** en el servidor" }, "levelUp": { "title": "🎉 ¡Level Up!", "description": "¡Felicidades {user}! Has alcanzado el nivel **{level}**" } }}Archivo Inglés (en.json)
{ "commands": { "ping": { "description": "Shows the bot's latency", "response": "🏓 Pong! Latency: {latency}ms" }, "help": { "description": "Shows the list of available commands", "title": "Available Commands", "footer": "Use /{command} for more information" }, "language": { "description": "Changes the bot language for this server", "current": "Current language: **{language}**", "changed": "Language changed to **{language}** ✅", "select": "Select the new language:" } }, "systems": { "welcome": { "setup": { "title": "Welcome Setup", "description": "Configure the welcome system for new members", "status": { "enabled": "✅ Enabled", "disabled": "❌ Disabled", "channel": "Channel: {channel}", "noChannel": "No channel configured", "message": "Message: {message}", "defaultMessage": "Default message", "image": "Image: {url}", "noImage": "No image configured" }, "actions": { "setChannel": "📢 Set Channel", "configMessage": "💬 Configure Message", "configImage": "🖼️ Configure Image", "test": "🧪 Test Welcome", "toggle": "🔄 Toggle System" } } }, "tickets": { "setup": { "title": "Ticket Setup", "description": "Professional technical support system", "panel": { "title": "🎫 Support System", "description": "Select the type of help you need:", "footer": "Staff will assist you as soon as possible" } } } }, "errors": { "noPermission": "❌ You don't have permission to use this command", "userNotFound": "❌ User not found", "invalidChannel": "❌ Invalid channel", "botMissingPermissions": "❌ I don't have sufficient permissions", "commandCooldown": "⏰ You must wait {time} seconds before using this command again", "unexpectedError": "❌ An unexpected error occurred" }, "success": { "configUpdated": "✅ Configuration updated successfully", "userBanned": "✅ User banned successfully", "userKicked": "✅ User kicked successfully", "messagesSent": "✅ {count} messages sent" }, "info": { "loading": "⏳ Loading...", "processing": "🔄 Processing...", "completed": "✅ Completed", "cancelled": "❌ Cancelled" }, "time": { "seconds": "second{s}", "minutes": "minute{s}", "hours": "hour{s}", "days": "day{s}", "weeks": "week{s}", "months": "month{s}", "years": "year{s}", "ago": "{time} ago", "in": "in {time}" }, "moderation": { "ban": { "description": "Bans a user from the server", "reason": "Banned by {moderator}", "success": "User {user} banned for: {reason}", "dmMessage": "You have been banned from **{server}** for: {reason}" }, "kick": { "description": "Kicks a user from the server", "reason": "Kicked by {moderator}", "success": "User {user} kicked for: {reason}", "dmMessage": "You have been kicked from **{server}** for: {reason}" } }, "levels": { "rank": { "description": "Shows your current level or another user's level", "title": "🎮 {user}'s Level", "level": "Current Level: **{level}**", "xp": "Total XP: **{xp}**", "nextLevel": "XP to Level {next}: **{needed}**", "progress": "Progress: **{percentage}%**", "ranking": "🏆 Ranking: **#{position}** in server" }, "levelUp": { "title": "🎉 Level Up!", "description": "Congratulations {user}! You've reached level **{level}**" } }}🔧 Uso en Comandos
Ejemplo en Comando Slash
const { SlashCommandBuilder } = require('discord.js');const { getTranslation } = require('../../utils/i18n');
module.exports = { data: new SlashCommandBuilder() .setName('ping') .setDescription('Shows bot latency'),
async execute(interaction) { const t = await getTranslation(interaction.guild.id);
const latency = Date.now() - interaction.createdTimestamp;
await interaction.reply( t('commands.ping.response', { latency }) ); }};Ejemplo en Sistema de Bienvenida
const { getTranslation } = require('../../utils/i18n');const { EmbedBuilder } = require('discord.js');
class WelcomeHandler { static async handleNewMember(member) { const t = await getTranslation(member.guild.id); const config = await ConfigManager.getGuildConfig(member.guild.id);
if (!config.welcome.enabled) return;
const embed = new EmbedBuilder() .setTitle(t('systems.welcome.title')) .setDescription(t('systems.welcome.message', { user: member.user, server: member.guild.name, memberCount: member.guild.memberCount })) .setColor('#00FF00') .setTimestamp();
const channel = member.guild.channels.cache.get(config.welcome.channel_id); if (channel) { await channel.send({ embeds: [embed] }); } }}🎯 Variables en Traducciones
Tipos de Variables
// Variables simplest('commands.ping.response', { latency: 45 })// Resultado: "🏓 Pong! Latencia: 45ms"
// Variables con objetos Discordt('levels.rank.title', { user: interaction.user })// Resultado: "🎮 Nivel de @Usuario"
// Variables múltiplest('moderation.ban.success', { user: targetUser, reason: 'Spam'})// Resultado: "Usuario @Target baneado por: Spam"
// Variables condicionales (plurales)t('time.minutes', { s: count !== 1 ? 's' : '' })// Resultado: "minuto" o "minutos"Funciones Helper para Variables
class I18nHelpers { static formatUser(user) { return user.toString(); // <@123456> }
static formatChannel(channel) { return channel.toString(); // <#123456> }
static formatTime(seconds, locale = 'es') { const units = { es: ['segundo', 'minuto', 'hora', 'día'], en: ['second', 'minute', 'hour', 'day'] };
// Lógica de formateo de tiempo return formatTimeString(seconds, units[locale]); }
static formatPlural(count, key, locale = 'es') { const pluralRules = { es: count !== 1 ? 's' : '', en: count !== 1 ? 's' : '' };
return pluralRules[locale] || ''; }}🌐 Comando de Idioma
Implementación del Comando /language
const { SlashCommandBuilder, StringSelectMenuBuilder, ActionRowBuilder } = require('discord.js');const { getTranslation, i18n } = require('../../utils/i18n');const ConfigManager = require('../../utils/config');
module.exports = { data: new SlashCommandBuilder() .setName('language') .setDescription('Change bot language for this server'),
async execute(interaction) { const t = await getTranslation(interaction.guild.id); const config = await ConfigManager.getGuildConfig(interaction.guild.id);
const languageNames = { es: 'Español', en: 'English' };
const selectMenu = new StringSelectMenuBuilder() .setCustomId('language_select') .setPlaceholder(t('commands.language.select')) .addOptions( i18n.getAvailableLocales().map(locale => ({ label: languageNames[locale] || locale, value: locale, default: locale === config.language })) );
const row = new ActionRowBuilder().addComponents(selectMenu);
await interaction.reply({ content: t('commands.language.current', { language: languageNames[config.language] }), components: [row], ephemeral: true }); }};Handler del Select Menu
const { getTranslation } = require('../../utils/i18n');const ConfigManager = require('../../utils/config');
module.exports = { customId: 'language_select',
async execute(interaction) { const newLanguage = interaction.values[0]; const config = await ConfigManager.getGuildConfig(interaction.guild.id);
// Actualizar configuración config.language = newLanguage; await ConfigManager.updateGuildConfig(interaction.guild.id, config);
// Obtener nueva función de traducción const t = await getTranslation(interaction.guild.id);
const languageNames = { es: 'Español', en: 'English' };
await interaction.update({ content: t('commands.language.changed', { language: languageNames[newLanguage] }), components: [] }); }};🔧 Herramientas de Desarrollo
Validador de Traducciones
const fs = require('fs');const path = require('path');
class TranslationValidator { static validateFiles() { const localesPath = path.join(__dirname, '..', 'src', 'locales'); const files = fs.readdirSync(localesPath);
const translations = {}; const errors = [];
// Cargar todos los archivos for (const file of files) { if (file.endsWith('.json')) { const locale = file.replace('.json', ''); const content = JSON.parse(fs.readFileSync(path.join(localesPath, file), 'utf8')); translations[locale] = content; } }
// Validar consistencia const locales = Object.keys(translations); const baseLocale = locales[0]; const baseKeys = this.extractKeys(translations[baseLocale]);
for (const locale of locales.slice(1)) { const keys = this.extractKeys(translations[locale]);
// Claves faltantes const missingKeys = baseKeys.filter(key => !keys.includes(key)); if (missingKeys.length > 0) { errors.push(`${locale}: Missing keys - ${missingKeys.join(', ')}`); }
// Claves extra const extraKeys = keys.filter(key => !baseKeys.includes(key)); if (extraKeys.length > 0) { errors.push(`${locale}: Extra keys - ${extraKeys.join(', ')}`); } }
return errors; }
static extractKeys(obj, prefix = '') { const keys = [];
for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null) { keys.push(...this.extractKeys(value, fullKey)); } else if (typeof value === 'string') { keys.push(fullKey); } }
return keys; }}
// Ejecutar validaciónconst errors = TranslationValidator.validateFiles();if (errors.length > 0) { console.error('❌ Translation validation errors:'); errors.forEach(error => console.error(` ${error}`)); process.exit(1);} else { console.log('✅ All translations are valid');}Extractor de Claves
const fs = require('fs');const path = require('path');
class KeyExtractor { static extractFromCode() { const srcPath = path.join(__dirname, '..', 'src'); const keys = new Set();
this.walkDirectory(srcPath, (filePath) => { if (filePath.endsWith('.js')) { const content = fs.readFileSync(filePath, 'utf8');
// Buscar patrones t('key') y t("key") const matches = content.match(/t\(['"`]([^'"`]+)['"`]\)/g); if (matches) { matches.forEach(match => { const key = match.match(/['"`]([^'"`]+)['"`]/)[1]; keys.add(key); }); } } });
return Array.from(keys).sort(); }
static walkDirectory(dir, callback) { const files = fs.readdirSync(dir);
files.forEach(file => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath);
if (stat.isDirectory()) { this.walkDirectory(filePath, callback); } else { callback(filePath); } }); }}
// Generar reporte de claves usadasconst usedKeys = KeyExtractor.extractFromCode();console.log('Used translation keys:');usedKeys.forEach(key => console.log(` ${key}`));🚀 Extensión a Nuevos Idiomas
Agregar Nuevo Idioma
-
Crear archivo de traducción:
Ventana de terminal cp src/locales/es.json src/locales/fr.json -
Traducir contenido:
{"commands": {"ping": {"description": "Affiche la latence du bot","response": "🏓 Pong! Latence: {latency}ms"}}} -
Agregar al comando de idioma:
const languageNames = {es: 'Español',en: 'English',fr: 'Français' // ← Nuevo idioma}; -
Validar traducciones:
Ventana de terminal node scripts/validate-translations.js
Mejores Prácticas
- Mantener consistencia en nombres de claves
- Usar variables para contenido dinámico
- Agrupar traducciones por funcionalidad
- Validar regularmente la completitud
- Documentar contexto cuando sea necesario
El sistema de internacionalización está diseñado para ser extensible y mantenible, facilitando la adición de nuevos idiomas y la gestión de traducciones complejas.