Internacionalización (i18n)

El plugin de internacionalización hace que tu bot hable varios idiomas.

No se debe confundir

No confundas esto con fluent.

Este plugin es una versión mejorada de fluent que funciona tanto en Deno como en Node.js.

La internacionalización explicada

Esta sección explica qué es la internacionalización, por qué es necesaria, qué es lo complicado, cómo se relaciona con la localización, y por qué necesitas un plugin para todo esto. Si ya sabes estas cosas, desplázate a la derecha hasta Cómo empezar.

Primero, internacionalización es una palabra muy larga. Por eso, a la gente le gusta escribir la primera letra (i) y la última (n). A continuación, cuentan todas las letras restantes (nternationalizatio, 18 letras) y ponen este número entre la i y la n, por lo que terminan con i18n. No nos preguntes por qué. Así que i18n no es más que una extraña abreviatura de la palabra internacionalización.

Lo mismo se hace con localización, que se convierte en l10n.

¿Qué es la localización?

La localización significa crear un bot que pueda hablar varios idiomas. Debe ajustar automáticamente su idioma al del usuario.

Hay más cosas que localizar que el idioma. También puedes tener en cuenta las diferencias culturales u otros estándares, como los formatos de fecha y hora. Aquí hay algunos ejemplos más de cosas que se representan de manera diferente en todo el mundo:

  1. Fechas
  2. Horas
  3. Números
  4. Unidades
  5. Pluralización
  6. Géneros
  7. Guión
  8. Mayúsculas
  9. Alineación
  10. Símbolos e iconos
  11. Clasificación

… y mucho másopen in new window.

Todas estas cosas definen colectivamente la localidad de un usuario. Las localidades suelen tener códigos de dos letras, como en para el inglés, de para el alemán, etc. Si quieres encontrar el código de tu localidad, consulta esta listaopen in new window.

¿Qué es la internacionalización?

En pocas palabras, la internacionalización significa escribir código que pueda ajustarse a la configuración regional del usuario. En otras palabras, la internacionalización es lo que permite la localización (ver arriba. Esto significa que mientras tu bot funciona fundamentalmente de la misma manera para todo el mundo, los mensajes exactos que envía varían de un usuario a otro, por lo que el bot puede hablar diferentes idiomas.

Estás haciendo internacionalización si no codificas los textos que envía tu bot, sino que los lees de un archivo dinámicamente. Estás haciendo internacionalización si no codificas la forma en que se representan las fechas y las horas, y en su lugar utilizas una biblioteca que ajusta estos valores de acuerdo con diferentes estándares.

Ya entiendes la idea: No codifiques cosas que deberían cambiar según el lugar donde vive el usuario o el idioma que habla.

¿Por qué necesita este plugin?

Este plugin puede ayudarle en su proceso de internacionalización. Está basado en Fluentopen in new window-un sistema de localización construido por Mozillaopen in new window. Este sistema tiene una sintaxis muy potente y elegante que te permite escribir traducciones que suenen naturales de una manera eficiente.

En esencia, puedes extraer las cosas que deben ajustarse en función de la configuración regional del usuario a unos archivos de texto que pones junto a tu código. Luego puedes usar este plugin para cargar estas localizaciones. El plugin determinará automáticamente la configuración regional del usuario y dejará que tu bot elija el idioma correcto para hablar.

A continuación, llamaremos a estos archivos de texto, archivos de traducción. Normalmente se almacenan en la estructura de su bot como archivos yaml (pero este plugin soporta otras formas de manejarlos). Se requiere que sigan la sintaxis de Fluent.

Cómo empezar

Esta sección describe la configuración de la estructura del proyecto y dónde colocar los archivos de traducción. Si estás familiarizado con esto, salta hacia adelante para ver cómo instalar y usar el plugin. Hay múltiples maneras de añadir más idiomas a tu bot. La forma más fácil es crear una carpeta con tus archivos de traducción de Fluent. Normalmente, el nombre de esa carpeta será locales/. Los archivos de traducción deben tener la extensión .ftl (fluent).

Este es un ejemplo de la estructura del proyecto:

.
├── bot.ts
└── locales/
    ├── de.ftl
    ├── es.ftl
    ├── it.ftl
    └── ru.ftl

Si no estás familiarizado con la sintaxis de Fluent, puedes leer su guía: https://projectfluent.org/fluent/guideopen in new window

Aquí hay un archivo de traducción de ejemplo para el inglés, llamado locales/en.ftl:

start = Hi, how can I /help you?
help =
    Send me some text, and I can make it bold for you.
    You can change my language using the /language command.

El equivalente en alemán se llamaría locales/de.ftl y tendría el siguiente aspecto

start = Hallo, wie kann ich dir helfen? /help
help =
    Schick eine Textnachricht, die ich für dich fett schreiben soll.
    Du kannst mit dem Befehl /language die Spache ändern.

En tu bot, ahora puedes usar estas traducciones a través del plugin. Estarán disponibles a través de ctx.t:

bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start"));
});
bot.c;
ommand("help", async (ctx) => {
  await ctx.reply(ctx.t("help"));
});

Cada vez que se llama a ctx.t, se utiliza la configuración regional del objeto de contexto actual ctx para encontrar la traducción adecuada. La búsqueda de la traducción adecuada se realiza mediante un negociador de configuración regional. En el caso más sencillo, sólo devuelve ctx.from.language_code.

Como resultado, los usuarios con diferentes locales podrán leer los mensajes, cada uno en su idioma.

Uso

El plugin obtiene la configuración regional del usuario a partir de muchos factores diferentes. Uno de ellos es de ctx.from.language_code, que será proporcionado por el cliente del usuario.

Sin embargo, hay muchas más cosas que se pueden utilizar para determinar la configuración regional del usuario. Por ejemplo, se puede almacenar la configuración regional del usuario en su sesión. Por lo tanto, hay dos formas principales de utilizar este plugin: Con sesiones y Sin sesiones.

Sin sesiones

Es más fácil utilizar y configurar el plugin sin sesiones. Su principal inconveniente es que no se pueden almacenar los idiomas que eligen los usuarios.

Como se mencionó anteriormente, la configuración regional que se utilizará para el usuario se decidirá con ctx.from.language_code, que proviene del cliente del usuario. Pero se utilizará el idioma por defecto si no tienes una traducción de ese idioma.

A veces, tu bot puede no ser capaz de ver el idioma preferido del usuario proporcionado por su cliente, y en ese caso se utilizará también el idioma por defecto.

El ctx.from.language_code será visible sólo si el usuario ha iniciado previamente una conversación privada con tu bot.

import { Bot, Context } from "grammy";
import { I18n, I18nFlavor } from "@grammyjs/i18n";

// Para soporte de TypeScript y autocompletado,
// extiende el contexto con el flavor de I18n:
type MyContext = Context & I18nFlavor;

// Crea un bot como lo harías normalmente.
// Recuerda extender el contexto.
const bot = new Bot<MyContext>(""); // <-- pon tu token de bot aquí (https://t.me/BotFather)

// Crea una instancia `I18n`.
// Sigue leyendo para saber cómo configurar la instancia.
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // ver más abajo para más información

  // Cargar todos los archivos de traducción de locales/.
  directory: "locales",
});

// Finalmente, registra la instancia i18n en el bot,
// ¡para que los mensajes se traduzcan en su camino!
bot.use(i18n);

// Ya está todo configurado.
// Puedes acceder a las traducciones con `t` o `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});
const { Bot } = require("grammy");
const { I18n } = require("@grammyjs/i18n");

// Crea un bot como lo harías normalmente.
const bot = new Bot(""); // <-- pon tu token de bot aquí (https://t.me/BotFather)

// Crea una instancia `I18n`.
// Sigue leyendo para saber cómo configurar la instancia.
const i18n = new I18n({
  defaultLocale: "en", // ver más abajo para más información

  // Cargar todos los archivos de traducción de locales/.
  directory: "locales",
});

// Finalmente, registra la instancia i18n en el bot,
// ¡para que los mensajes se traduzcan en su camino!
bot.use(i18n);

// Ya está todo configurado.
// Puedes acceder a las traducciones con `t` o `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});
import { Bot, Context } from "https://deno.land/x/grammy@v1.13.1/mod.ts";
import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n@v1.0.1/mod.ts";

// Para soporte de TypeScript y autocompletado,
// extiende el contexto con el flavor de I18n:
type MyContext = Context & I18nFlavor;

// Crea un bot como lo harías normalmente.
// Recuerda extender el contexto.
const bot = new Bot<MyContext>(""); // <-- pon tu token de bot aquí (https://t.me/BotFather)

// Crea una instancia `I18n`.
// Sigue leyendo para saber cómo configurar la instancia.
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // ver más abajo para más información

  // Cargar todos los archivos de traducción desde locales/. (No funciona en Deno Deploy.)
  directory: "locales",
});

// Los archivos de traducción cargados de esta manera también funcionan en Deno Deploy.
// await i18n.loadLocalesDir("locales");

// Finalmente, registra la instancia i18n en el bot,
// ¡para que los mensajes se traduzcan en su camino!
bot.use(i18n);

// Ya está todo configurado.
// Puedes acceder a las traducciones con `t` o `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});

ctx.t devuelve el mensaje traducido para la clave especificada. No tienes que preocuparte por los idiomas, ya que serán elegidos automáticamente por el plugin.

¡Enhorabuena! ¡Tu bot ahora habla varios idiomas! 🌍 🎉

Con Sesiones

Supongamos que tu bot tiene un comando /language. Generalmente, en grammY podemos utilizar sessions para almacenar los datos del usuario por chat. Para que tu instancia de internacionalización sepa que las sesiones están habilitadas, tienes que establecer useSession a true en las opciones de I18n.

Aquí hay un ejemplo que incluye un simple comando /language:

import { Bot, Context, session, SessionFlavor } from "grammy";
import { I18n, I18nFlavor } from "@grammyjs/i18n";

interface SessionData {
  __language_code?: string;
}

type MyContext = Context & SessionFlavor<SessionData> & I18nFlavor;

const i18n = new I18n<MyContext>({
  defaultLocale: "en",
  useSession: true, // si se almacena el idioma del usuario en la sesión

  // Cargar locales desde el directorio `locales`.
  directory: "locales",
});

const bot = new Bot<MyContext>(""); // <-- pon tu token de bot aquí

// Recuerda registrar el middleware `session` antes de
// registrar el middleware de la instancia i18n.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Registrar el middleware i18n
bot.use(i18n);

bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("greeting"));
});

bot.command("language", async (ctx) => {
  if (ctx.match === "") {
    return await ctx.reply(ctx.t("language.specify-a-locale"));
  }

  // `i18n.locales` contiene todas las locales que se han registrado
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` devuelve la configuración regional que se está utilizando.
  if ((await ctx.i18n.getLocale()) === ctx.match) {
    return await ctx.reply(ctx.t("language.already-set"));
  }

  await ctx.i18n.setLocale(ctx.match);
  await ctx.reply(ctx.t("language.language-set"));
});
const { Bot, session } = require("grammy");
const { I18n } = require("@grammyjs/i18n");

const i18n = new I18n({
  defaultLocale: "en",
  useSession: true, // si se almacena el idioma del usuario en la sesión
  directory: "locales", // Cargar locales desde el directorio `locales`.
});

const bot = new Bot(""); // <-- pon tu token de bot aquí

// Recuerda registrar el middleware `session` antes de
// registrar el middleware de la instancia i18n.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Registrar el middleware i18n
bot.use(i18n);

bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("greeting"));
});

bot.command("language", async (ctx) => {
  if (ctx.match === "") {
    return await ctx.reply(ctx.t("language.specify-a-locale"));
  }

  // `i18n.locales` contiene todas las locales que se han registrado
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` devuelve la configuración regional que se está utilizando.
  if ((await ctx.i18n.getLocale()) === ctx.match) {
    return await ctx.reply(ctx.t("language.already-set"));
  }

  await ctx.i18n.setLocale(ctx.match);
  await ctx.reply(ctx.t("language.language-set"));
});
import {
  Bot,
  Context,
  session,
  SessionFlavor,
} from "https://deno.land/x/grammy@v1.13.1/mod.ts";
import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n@v1.0.1/mod.ts";

interface SessionData {
  __language_code?: string;
}

type MyContext = Context & SessionFlavor<SessionData> & I18nFlavor;

const i18n = new I18n<MyContext>({
  defaultLocale: "en",
  useSession: true, // si se almacena el idioma del usuario en la sesión

  // NO funciona en Deno Deploy
  directory: "locales",
});

// Los archivos de traducción cargados de esta manera también funcionan en Deno Deploy.
// await i18n.loadLocalesDir("locales");

// Recuerda registrar el middleware `session` antes de
// registrar el middleware de la instancia i18n.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Registrar el middleware i18n
bot.use(i18n);

bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("greeting"));
});

bot.command("language", async (ctx) => {
  if (ctx.match === "") {
    return await ctx.reply(ctx.t("language.specify-a-locale"));
  }

  // `i18n.locales` contiene todas las locales que se han registrado
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` devuelve la configuración regional que se está utilizando.
  if ((await ctx.i18n.getLocale()) === ctx.match) {
    return await ctx.reply(ctx.t("language.already-set"));
  }

  await ctx.i18n.setLocale(ctx.match);
  await ctx.reply(ctx.t("language.language-set"));
});

Cuando las sesiones están habilitadas, la propiedad __language_code de la sesión se utilizará en lugar de ctx.from.language_code (proporcionada por el cliente de Telegram) durante la selección del idioma. Cuando tu bot envía mensajes, la configuración regional se selecciona desde ctx.session.__language_code.

Hay un método setLocale que puedes utilizar para establecer el idioma deseado. Este valor se guardará en tu sesión.

await ctx.i18n.setLocale("de");

Esto es equivalente a establecerlo manualmente en la sesión, y luego renegociar la configuración regional:

ctx.session.__language_code = "de";
await ctx.i18n.renegotiateLocale();

Renegociación de la configuración regional

Cuando se utilizan sesiones o alguna otra cosa—aparte de ctx.from.language_code—para seleccionar una configuración regional personalizada para el usuario, hay algunas situaciones en las que se puede cambiar el idioma mientras se maneja una actualización. Por ejemplo, eche un vistazo al ejemplo anterior utilizando sesiones.

Cuando sólo se hace

ctx.session.__language_code = "de";

no se actualizará la configuración regional utilizada actualmente en la instancia I18n. En su lugar, sólo actualiza la sesión. Por lo tanto, los cambios sólo tendrán lugar en la próxima actualización.

Si no puedes esperar hasta la siguiente actualización, puede que tengas que refrescar los cambios después de actualizar el idioma del usuario. Utilice el método renegotiateLocale para estos casos.

ctx.session.__language_code = "de";
await ctx.i18n.renegotiateLocale();

Después, cada vez que usemos el método t, el bot intentará responder con la traducción al alemán de ese mensaje (especificada en locales/de.ftl).

Además, recuerda que cuando utilices sesiones incorporadas, puedes conseguir el mismo resultado utilizando el método setLocale.

Para establecer la configuración regional cuando no se utilizan sesiones

Cuando no se usan sesiones, si hay un caso en el que necesitas establecer la configuración regional para un usuario, puedes hacerlo usando el método useLocale.

await ctx.i18n.useLocale("de");

Establece la configuración regional especificada para ser utilizada en futuras traducciones. El efecto dura sólo para la actualización actual y no se conserva. Puedes utilizar este método para cambiar la configuración regional de la traducción en medio de la actualización (por ejemplo, cuando el usuario cambia el idioma).

Negociación personalizada de la configuración regional

Puede utilizar la opción localeNegotiator para especificar un negociador de configuración regional personalizado. Esta opción es útil si quiere seleccionar la configuración regional basándose en fuentes externas (como bases de datos) o en otras situaciones en las que quiera controlar qué configuración regional se utiliza.

Este es el orden por defecto de cómo el plugin elige su configuración regional:

  1. Si las sesiones están habilitadas, intenta leer __language_code de la sesión. Si devuelve una configuración regional válida, se utiliza. Si no devuelve nada o una configuración regional no registrada, se pasa al paso 2.

  2. Intente leer de ctx.from.language_code. Si devuelve una configuración regional válida, se utiliza. Si no devuelve nada o devuelve una configuración regional no registrada, pasa al paso 3.

    Ten en cuenta que ctx.from.language_code sólo está disponible si el usuario ha iniciado el bot. Eso significa que si el bot ve al usuario en un grupo o en algún lugar sin que el usuario haya iniciado previamente el bot, no podrá ver ctx.from.language_code.

  3. Prueba a utilizar el idioma por defecto configurado en las opciones de I18n. Si está configurado en una configuración regional válida, se utiliza. Si no se especifica o se establece a una configuración regional no registrada, pasa al paso 4.

  4. Pruebe a utilizar el inglés (en). El propio plugin establece esta configuración regional como la última alternativa. Aunque es una configuración regional alternativa, y recomendamos tener una traducción, no es un requisito. Si no se proporciona ninguna configuración regional en inglés, vaya al paso 5.

  5. Si todo lo anterior falla, utilice {key} en lugar de una traducción. Recomendamos** establecer una configuración regional que exista en sus traducciones como defaultLocale en las opciones I18n.

Negociación de la configuración regional

La negociación de la configuración regional ocurre normalmente sólo una vez durante el proceso de actualización de Telegram. Sin embargo, puedes ejecutar ctx.i18n.renegotiateLocale() para llamar al negociador de nuevo y determinar la nueva localización. Es útil si la configuración regional cambia durante el procesamiento de una sola actualización.

Este es un ejemplo de localeNegotiator donde usamos locale de la sesión en lugar de __language_code. En un caso como este, no es necesario establecer useSession a true en las opciones de I18n.

const i18n = new I18n<MyContext>({
  localeNegotiator: (ctx) =>
    ctx.session.locale ?? ctx.from?.language_code ?? "en",
});
const i18n = new I18n({
  localeNegotiator: (ctx) =>
    ctx.session.locale ?? ctx.from?.language_code ?? "en",
});

Si el negociador de configuración regional personalizado devuelve una configuración regional no válida, volverá a elegir una configuración regional, siguiendo el orden anterior.

Renderización de los mensajes traducidos

Echemos un vistazo a la representación de los mensajes.

bot.command("start", async (ctx) => {
  // Llama al helper "translate" o "t" para renderizar el
  // mensaje especificando su ID y parámetros adicionales:
  await ctx.reply(ctx.t("welcome"));
});

Ahora puedes /start tu bot. Debería mostrar el siguiente mensaje:

¡Hola!

Placeables

A veces, puede querer colocar valores como números y nombres dentro de las cadenas. Puedes hacer esto con placeables.

bot.command("cart", async (ctx) => {
  // Puedes pasar placeables como segundo objeto.
  await ctx.reply(ctx.t("cart-msg", { items: 10 }));
});

El objeto { items: 10 } se llama el contexto de traducción del string cart-msg.

Ahora, con el comando /cart:

Actualmente tienes 10 artículos en tu carrito.

Intenta cambiar el valor de la variable items para ver cómo cambia el mensaje renderizado. También, revisa la documentación de Fluent, especialmente la documentación de placeablesopen in new window.

Placeables globales

Puede ser útil especificar un número de placeables que deben estar disponibles para todas las traducciones. Por ejemplo, si se reutiliza el nombre del usuario en muchos mensajes, puede ser tedioso pasar el contexto de traducción { nombre: ctx.from.first_name } a todas partes.

¡Los placeables globales vienen al rescate! Considere esto:

const i18n = new I18n<MyContext>({
  defaultLocale: "en",
  directory: "locales",
  // Definir las localizaciones disponibles globalmente:
  globalTranslationContext(ctx) {
    return { nombre: ctx.from?.first_name ?? "" };
  },
});

bot.use(i18n);

bot.command("start", async (ctx) => {
  // ¡Puede usar `nombre` sin especificarlo de nuevo!
  await ctx.reply(ctx.t("welcome"));
});

Añadir traducciones

Hay tres métodos principales para cargar traducciones.

Cargar locales usando la opción directory

La forma más sencilla de añadir traducciones a la instancia I18n es teniendo todas las traducciones en un directorio y especificando el nombre del directorio en las opciones.

const i18n = new I18n({
  directory: "locales",
});

Cargar locales desde un directorio

Este método es lo mismo que especificar directory en las opciones. Sólo hay que ponerlas todas en una carpeta y cargarlas así

const i18n = new I18n();

await i18n.loadLocalesDir("locales"); // versión asíncrona
i18n.loadLocalesDirSync("locales-2"); // versión síncrona

Tenga en cuenta que ciertos entornos requieren que utilice la versión async. Por ejemplo, Deno Deploy no admite operaciones de archivo síncronas.

Cargar un solo locale

También es posible añadir una única traducción a la instancia. Puede especificar la ruta del archivo de la traducción utilizando

const i18n = new I18n();

await i18n.loadLocale("en", { filePath: "locales/en.ftl" }); // versión asíncrona
i18n.loadLocaleSync("de", { filePath: "locales/de.ftl" }); // versión síncrona

o puedes cargar directamente los datos de la traducción como una cadena, así

const i18n = new I18n();

// versión asíncrona
await i18n.loadLocale("en", {
  source: `greeting = Hello { $name }!
language-set = Language has been set to English!`,
});

// versión síncrona
i18n.loadLocaleSync("de", {
  source: `greeting = Hallo { $name }!
language-set = Die Sprache wurde zu Deutsch geändert!`,
});

Escuchando por texto localizado

Hemos conseguido enviar mensajes localizados al usuario. Ahora, veamos cómo escuchar los mensajes enviados por el usuario. En grammY, normalmente usamos el manejador bot.hears para escuchar los mensajes entrantes. Pero ya que hemos estado hablando de la internacionalización, en esta sección veremos cómo escuchar los mensajes entrantes localizados.

Esta función es muy útil cuando tu bot tiene teclados personalizados que contienen texto localizado.

Este es un breve ejemplo de cómo escuchar un mensaje de texto localizado enviado con un teclado personalizado. En lugar de usar el manejador bot.hears, usamos bot.filter combinado con el middleware hears proporcionado por este plugin.

import { hears } from "@grammyjs/i18n";

bot.filter(hears("back-to-menu-btn"), async (ctx) => {
  await ctx.reply(ctx.t("main-menu-msg"));
});
const { hears } = require("@grammyjs/i18n");

bot.filter(hears("back-to-menu-btn"), async (ctx) => {
  await ctx.reply(ctx.t("main-menu-msg"));
});
import { hears } from "https://deno.land/x/grammy_i18n@v1.0.1/mod.ts";

bot.filter(hears("back-to-menu-btn"), async (ctx) => {
  await ctx.reply(ctx.t("main-menu-msg"));
});

La función helper hears permite a tu bot escuchar un mensaje que está escrito en la configuración regional del usuario.

Pasos adicionales

Resumen del plugin