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:
- Dates
- Times
- Numbers
- Units
- Pluralization
- Genders
- Hyphenation
- Capitalization
- Alignment
- Symbols and icons
- Sorting
… and a lot more.
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 list.
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 Fluent—a localization system built by Mozilla. 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://
Here is an example translation file for English, called locales
:
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.
2
3
4
The German equivalent would be called locales
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.
2
3
4
In your bot, you can now use these translations through the plugin. It will make them available through ctx
:
bot.command("start", async (ctx) => {
await ctx.reply(ctx.t("start"));
});
bot.command("help", async (ctx) => {
await ctx.reply(ctx.t("help"));
});
2
3
4
5
6
7
Whenever you call ctx
, 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
.
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
, 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
, 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
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>("");
// 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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const { Bot } = require("grammy");
const { I18n } = require("@grammyjs/i18n");
// Create a bot as you normally would.
const bot = new Bot("");
// 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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Bot, Context } from "https://deno.land/x/grammy@v1.31.1/mod.ts";
import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n@v1.1.0/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>("");
// 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
// Load all translation files from locales/. (Not working in Deno Deploy.)
directory: "locales",
});
// Translation files loaded this way works in Deno Deploy, too.
// await i18n.loadLocalesDir("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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ctx
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 use
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 bot = new Bot<MyContext>("");
const i18n = new I18n<MyContext>({
defaultLocale: "en",
useSession: true, // whether to store user language in session
directory: "locales", // Load all translation files from locales/.
});
// 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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const { Bot, session } = require("grammy");
const { I18n } = require("@grammyjs/i18n");
const bot = new Bot("");
const i18n = new I18n({
defaultLocale: "en",
useSession: true, // whether to store user language in session
directory: "locales", // Load all translation files from locales/.
});
// 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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import {
Bot,
Context,
session,
SessionFlavor,
} from "https://deno.land/x/grammy@v1.31.1/mod.ts";
import { I18n, I18nFlavor } from "https://deno.land/x/grammy_i18n@v1.1.0/mod.ts";
interface SessionData {
__language_code?: string;
}
type MyContext = Context & SessionFlavor<SessionData> & I18nFlavor;
const bot = new Bot<MyContext>("");
const i18n = new I18n<MyContext>({
defaultLocale: "en",
useSession: true, // whether to store user language in session
// DOES NOT work in Deno Deploy
directory: "locales",
});
// Translation files loaded this way works in Deno Deploy, too.
// await i18n.loadLocalesDir("locales");
// 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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
When sessions are enabled, the _
property in the session will be used instead of ctx
(provided by the Telegram client) during language selection. When your bot sends messages, the locale is selected from ctx
.
There is a set
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();
2
Renegotiating the Locale
When you are using sessions or something else—apart from ctx
—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 renegotiate
method for these cases.
ctx.session.__language_code = "de";
await ctx.i18n.renegotiateLocale();
2
Afterwards, whenever we use the method t
, the bot will try to reply with the German translation of that message (specified in locales
).
Also, remember that when you use built-in sessions, you can achieve the same result using the set
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 use
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 locale
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:
If sessions are enabled, try to read
_
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._language _code Try to read from
ctx
. If it returns a valid locale, it is used. If it returns nothing or a non-registered locale, move on to step 3..from .language _code Note that
ctx
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.from .language _code ctx
..from .language _code 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.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.If all the above things fail, use
{key}
instead of a translation. We highly recommend setting a locale that exists in your translations asdefault
in theLocale I18n
options.
Locale Negotiation
Locale negotiation happens typically only once during Telegram update processing. However, you can run ctx
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 locale
where we use locale
from session instead of _
. In a case like this, you don’t have to set use
to true
in the options of I18n
.
const i18n = new I18n<MyContext>({
localeNegotiator: (ctx) =>
ctx.session.locale ?? ctx.from?.language_code ?? "en",
});
2
3
4
const i18n = new I18n({
localeNegotiator: (ctx) =>
ctx.session.locale ?? ctx.from?.language_code ?? "en",
});
2
3
4
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"));
});
2
3
4
5
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 }));
});
2
3
4
The object { items:
is called the translation context of the cart
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 documentation.
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:
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"));
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Potential Formatting Issues
By default, Fluent uses Unicode isolation marks for interpolations.
If you use placeables inside tags or entities, having the isolation marks might result in incorrect formatting (e.g., plain text instead of an expected link or a cashtag).
To fix this, use the following options:
const i18n = new I18n({
fluentBundleOptions: { useIsolating: false },
});
2
3
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",
});
2
3
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
2
3
4
Note that certain environments require you to use the
async
version. For example, Deno Deploy does not support synchronous file operations.
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
2
3
4
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!`,
});
2
3
4
5
6
7
8
9
10
11
12
13
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
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
handler, we use bot
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"));
});
2
3
4
5
const { hears } = require("@grammyjs/i18n");
bot.filter(hears("back-to-menu-btn"), async (ctx) => {
await ctx.reply(ctx.t("main-menu-msg"));
});
2
3
4
5
import { hears } from "https://deno.land/x/grammy_i18n@v1.1.0/mod.ts";
bot.filter(hears("back-to-menu-btn"), async (ctx) => {
await ctx.reply(ctx.t("main-menu-msg"));
});
2
3
4
5
The hears
helper function allows your bot to listen for a message that is written in the locale of the user.
Further Steps
- Complete reading the Fluent documentation, especially the syntax guide.
- Check out proper examples of this plugin for Deno and Node.js.
Plugin Summary
- Name:
i18n
- Source
- API Reference