Saltearse al contenido

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án

Gestor de Traducciones

src/utils/i18n.js
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 traducciones
async 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

src/commands/info/ping.js
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

src/handlers/welcome/welcome-handler.js
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 simples
t('commands.ping.response', { latency: 45 })
// Resultado: "🏓 Pong! Latencia: 45ms"
// Variables con objetos Discord
t('levels.rank.title', { user: interaction.user })
// Resultado: "🎮 Nivel de @Usuario"
// Variables múltiples
t('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

src/utils/i18n-helpers.js
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

src/commands/admin/language.js
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

src/events/interaction/languageSelect.js
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

scripts/validate-translations.js
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ón
const 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

scripts/extract-keys.js
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 usadas
const usedKeys = KeyExtractor.extractFromCode();
console.log('Used translation keys:');
usedKeys.forEach(key => console.log(` ${key}`));

🚀 Extensión a Nuevos Idiomas

Agregar Nuevo Idioma

  1. Crear archivo de traducción:

    Ventana de terminal
    cp src/locales/es.json src/locales/fr.json
  2. Traducir contenido:

    {
    "commands": {
    "ping": {
    "description": "Affiche la latence du bot",
    "response": "🏓 Pong! Latence: {latency}ms"
    }
    }
    }
  3. Agregar al comando de idioma:

    const languageNames = {
    es: 'Español',
    en: 'English',
    fr: 'Français' // ← Nuevo idioma
    };
  4. Validar traducciones:

    Ventana de terminal
    node scripts/validate-translations.js

Mejores Prácticas

  1. Mantener consistencia en nombres de claves
  2. Usar variables para contenido dinámico
  3. Agrupar traducciones por funcionalidad
  4. Validar regularmente la completitud
  5. 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.