Internationalization (i18n)

The internationalization plugin makes your bot speak multiple languages.

Not to Be Confused

Don’t confuse this with fluent.

This plugin is an improved version of fluent that works on both Deno and Node.js.

Internationalization Explained

This section explains what internationalization is, why it is needed, what is complicated about it, how it relates to localization, and why you need a plugin for all of this. If you already know these things, scroll right to Getting Started.

First, internationalization is a very long word. Hence, people like to write the first letter (i) and the last letter (n). They then count all remaining letters (nternationalizatio, 18 letters) and put this number between i and the n, so they end up with i18n. Don’t ask us why. So i18n is just a weird abbreviation of the word internationalization.

The same is done to localization, which turns into l10n.

What Is Localization?

Localization means creating a bot that can speak multiple languages. It should automatically adjust its language to the language of the user.

There are more things to localize than the language. You can also account for cultural differences or other standards, such as the date and time formats. Here are a few more examples of things that are represented differently across the globe:

  1. Dates
  2. Times
  3. Numbers
  4. Units
  5. Pluralization
  6. Genders
  7. Hyphenation
  8. Capitalization
  9. Alignment
  10. Symbols and icons
  11. Sorting

… and a lot moreopen in new window.

All of these things collectively define the locale of a user. Locales often get two-letter codes, such as en for English, de for German, and so on. If you want to find the code for your locale, check out this listopen in new window.

What Is Internationalization?

In a nutshell, internationalization means writing code that can adjust to a user’s locale. In other words, internationalization is what enables localization (see above). This means that while your bot fundamentally works the same way for everybody, the exact messages it sends vary from user to user, so the bot can speak different languages.

You are doing internationalization if you don’t hard-code the texts your bot sends but instead read them from a file dynamically. You are doing internationalization if you don’t hard-code how dates and times are represented, and instead use a library that adjusts these values according to different standards. You get the idea: Don’t hard-code stuff that should change based on where the user lives or the language they speak.

Why Do You Need This Plugin?

This plugin can assist you throughout your internationalization process. It is based on Fluentopen in new window—a localization system built by Mozillaopen in new window. This system has a very powerful and elegant syntax that lets you write natural-sounding translations in an efficient way.

In essence, you can extract the things that should adjust based on the user’s locale to some text files that you put next to your code. You can then use this plugin to load these localizations. The plugin will automatically determine the user’s locale and let your bot choose the right language to speak.

Below, we will call these text files translation files. They are required to follow Fluent’s syntax.

Getting Started

This section describes setting up your project structure and where to put your translation files. If you are familiar with this, skip ahead to see how to install and use the plugin.

There are multiple ways to add more languages to your bot. The easiest way is to create a folder with your Fluent translation files. Usually, the name of that folder is going to be locales/. The translation files should have the extension .ftl (fluent).

Here is an example project structure:

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

If you’re unfamiliar with Fluent’s syntax, you can read their guide: https://projectfluent.org/fluent/guideopen in new window

Here is an example translation file for English, called 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.

The German equivalent would be called locales/de.ftl and look like this:

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.

In your bot, you can now use these translations through the plugin. It will make them available through ctx.t:

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

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

Whenever you call ctx.t, the locale of the current context object ctx is used to find the proper translation. Finding the proper translation is done using a locale negotiator. In the simplest case, it just returns ctx.from.language_code.

As a result, users with different locales will be able to read the messages, each in their language.

Usage

The plugin derives the user’s locale from many different factors. One of them is from ctx.from.language_code, which will be provided by the user’s client.

However, there are many more things that can be used to determine the user’s locale. For example, you could store the user’s locale in your session. Hence, there are two main ways to use this plugin: With Sessions and Without Sessions.

Without Sessions

It’s easier to use and set up the plugin without sessions. Its main drawback is that you can’t store the languages the users choose.

Like mentioned above, the locale to be used for the user will be decided with ctx.from.language_code, which is coming from the user’s client. But the default language will be used if you don’t have a translation of that language. Sometimes your bot might not be able to see the user’s preferred language provided by their client, and in that case the default language will be used, too.

The ctx.from.language_code will be visible only if the user previously has started a private conversation with your bot.

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

// For TypeScript and auto-completion support,
// extend the context with I18n's flavor:
type MyContext = Context & I18nFlavor;

// Create a bot as you normally would.
// Remember to extend the context.
const bot = new Bot<MyContext>(""); // <-- put your bot token here (https://t.me/BotFather)

// Create an `I18n` instance.
// Continue reading to find out how to configure the instance.
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // see below for more information
  directory: "locales", // Load all translation files from locales/.
});

// Finally, register the i18n instance in the bot,
// so the messages get translated on their way!
bot.use(i18n);

// Everything is set up now.
// You can access translations with `t` or `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});
const { Bot } = require("grammy");
const { I18n } = require("@grammyjs/i18n");

// Create a bot as you normally would.
const bot = new Bot(""); // <-- put your bot token here (https://t.me/BotFather)

// Create an `I18n` instance.
// Continue reading to find out how to configure the instance.
const i18n = new I18n({
  defaultLocale: "en", // see below for more information
  directory: "locales", // Load all translation files from locales/.
});

// Finally, register the i18n instance in the bot,
// so the messages get translated on their way!
bot.use(i18n);

// Everything is set up now.
// You can access translations with `t` or `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});
import { Bot, Context } from "https://deno.land/x/grammy@v1.11.2/mod.ts";
import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n@v1.0.1/mod.ts";

// For TypeScript and auto-completion support,
// extend the context with I18n's flavor:
type MyContext = Context & I18nFlavor;

// Create a bot as you normally would.
// Remember to extend the context.
const bot = new Bot<MyContext>(""); // <-- put your bot token here (https://t.me/BotFather)

// Create an `I18n` instance.
// Continue reading to find out how to configure the instance.
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // see below for more information
  directory: "locales", // Load all translation files from locales/.
});

// Finally, register the i18n instance in the bot,
// so the messages get translated on their way!
bot.use(i18n);

// Everything is set up now.
// You can access translations with `t` or `translate`.
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});

ctx.t returns the translated message for the specified key. You don’t have to worry about languages, as they will be picked automatically by the plugin.

Congratulations! Your bot now speaks multiple languages! 🌍🎉

With Sessions

Let’s assume that your bot has a /language command. Generally, in grammY we can use sessions to store user data per chat. To let your internationalization instance know that sessions are enabled, you have to set useSession to true in the options of I18n.

Here is an example including a simple /language command:

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, // whether to store user language in session
  directory: "locales", // Load all translation files from locales/.
});

const bot = new Bot<MyContext>(""); // <-- put your bot token here

// Remember to register `session` middleware before
// registering middleware of the i18n instance.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Register i18n middleware
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` contains all the locales that have been registered
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` returns the locale currently using.
  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, // whether to store user language in session
  directory: "locales", // Load all translation files from locales/.
});

const bot = new Bot(""); // <-- put your bot token here

// Remember to register `session` middleware before
// registering middleware of the i18n instance.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Register i18n middleware
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` contains all the locales that have been registered
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` returns the locale currently using.
  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.11.2/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, // whether to store user language in session
  directory: "locales", // Load all translation files from locales/.
});

const bot = new Bot<MyContext>(""); // <-- put your bot token here

// Remember to register `session` middleware before
// registering middleware of the i18n instance.
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// Register the i18n middleware
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` contains all the locales that have been registered
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` returns the locale currently using.
  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"));
});

When sessions are enabled, the __language_code property in the session will be used instead of ctx.from.language_code (provided by the Telegram client) during language selection. When your bot sends messages, the locale is selected from ctx.session.__language_code.

There is a setLocale method that you can use to set the desired language. It will save this value in your session.

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

This is equivalent to manually setting it in session, and then renegotiating the locale:

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

Renegotiating the Locale

When you are using sessions or something else—apart from ctx.from.language_code—for selecting a custom locale for the user, there are some situations where you might change the language while handling an update. For instance, take a look at the above example using sessions.

When you only do

ctx.session.__language_code = "de";

it will not update the currently used locale in the I18n instance. Instead, it only updates the session. Thus, the changes will only take place for the next update.

If you cannot wait until the next update, you might need to refresh the changes after updating the user language. Use the renegotiateLocale method for these cases.

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

Afterwards, whenever we use the method t, the bot will try to reply with the German translation of that message (specified in locales/de.ftl).

Also, remember that when you use built-in sessions, you can achieve the same result using the setLocale method.

Setting the Locale When Not Using Sessions

When not using sessions, if there is a case where you need to set the locale for a user, you can do that by using the useLocale method.

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

It sets the specified locale to be used for future translations. The effect lasts only for the current update and is not preserved. You can use this method to change the translation locale in the middle of the update (e.g., when the user changes the language).

Custom Locale Negotiation

You can use the option localeNegotiator to specify a custom locale negotiator. This option is helpful if you want to select the locale based on external sources (such as databases) or in other situations where you want to control which locale is used.

Here is the default order of how the plugin chooses its locale:

  1. If sessions are enabled, try to read __language_code from the session. If it returns a valid locale, it is used. If it returns nothing or a non-registered locale, move on to step 2.

  2. Try to read from ctx.from.language_code. If it returns a valid locale, it is used. If it returns nothing or a non-registered locale, move on to step 3.

    Note that ctx.from.language_code is only available if the user has started the bot. That means if the bot sees the user in a group or somewhere without the user previously having started the bot, it won’t be able to see ctx.from.language_code.

  3. Try using the default language configured in the options of I18n. If it is set to a valid locale, it is used. If it isn’t specified or set to a non-registered locale, move on to step 4.

  4. Try using English (en). The plugin itself sets this as the ultimate fallback locale. Even though it is a fallback locale, and we recommend having a translation, it is not a requirement. If no English locale is provided, move on to step 5.

  5. If all the above things fail, use {key} instead of a translation. We highly recommend setting a locale that exists in your translations as defaultLocale in the I18n options.

Locale Negotiation

Locale negotiation happens typically only once during Telegram update processing. However, you can run ctx.i18n.renegotiateLocale() to call the negotiator again and determine the new locale. It is helpful if the locale changes during single update processing.

Here is an example of localeNegotiator where we use locale from session instead of __language_code. In a case like this, you don’t have to set useSession to true in the options of 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",
});

If the custom locale negotiator returns an invalid locale, it will fall back and choose a locale, following the above order.

Rendering Translated Messages

Let’s take a closer look at rendering messages.

bot.command("start", async (ctx) => {
  // Call the "translate" or "t" helper to render the
  // message by specifying its ID and additional parameters:
  await ctx.reply(ctx.t("welcome"));
});

Now you can /start your bot. It should render the following message:

Hi there!

Placeables

Sometimes, you may want to place values such as numbers and names inside the strings. You can do this with placeables.

bot.command("cart", async (ctx) => {
  // You can pass placeables as the second object.
  await ctx.reply(ctx.t("cart-msg", { items: 10 }));
});

The object { items: 10 } is called the translation context of the cart-msg string.

Now, with the /cart command:

You currently have 10 items in your cart.

Try to change the value of the items variable to see how the rendered message would change! Also, check out the Fluent documentation, especially the placeables documentationopen in new window.

Global Placeables

It can be useful to specify a number of placeables that should be available to all translations. For example, if you reuse the name of the user in many messages, it can be tedious to pass the translation context { name: ctx.from.first_name } everywhere.

Global placeables come to rescue! Consider this:

const i18n = new I18n<MyContext>({
  defaultLocale: "en",
  directory: "locales",
  // Define globally available placeables:
  globalTranslationContext(ctx) {
    return { name: ctx.from?.first_name ?? "" };
  },
});

bot.use(i18n);

bot.command("start", async (ctx) => {
  // Can use `name` without specifying it again!
  await ctx.reply(ctx.t("welcome"));
});

Adding Translations

There are three main methods to load translations.

Loading Locales Using the directory Option

The simplest way to add translations to the I18n instance is by having all of your translations in a directory and specifying the directory name in the options.

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

Loading Locales From a Directory

This method is the same thing as specifying directory in options. Just put them all in a folder and load them like this:

const i18n = new I18n();

await i18n.loadLocalesDir("locales"); // async version
i18n.loadLocalesDirSync("locales-2"); // sync version

Loading a Single Locale

It is also possible to add a single translation to the instance. You can either specify the file path to the translation using

const i18n = new I18n();

await i18n.loadLocale("en", { filePath: "locales/en.ftl" }); // async version
i18n.loadLocaleSync("de", { filePath: "locales/de.ftl" }); // sync version

or you can directly load the translation data as a string like this:

const i18n = new I18n();

// async version
await i18n.loadLocale("en", {
  source: `greeting = Hello { $name }!
language-set = Language has been set to English!`,
});

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

Listening for Localized Text

We managed to send localized messages to the user. Now, let’s take a look at how to listen for messages sent by the user. In grammY, we usually use the bot.hears handler for listening to incoming messages. But since we’ve been talking about internationalization, in this section we will see how to listen for localized incoming messages.

This feature comes in handy when your bot has custom keyboards containing localized text.

Here is a short example of listening to a localized text message sent using a custom keyboard. Instead of using bot.hears handler, we use bot.filter combined with the hears middleware provided by this 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"));
});

The hears helper function allows your bot to listen for a message that is written in the locale of the user.

Further Steps

Plugin Summary