国际化 (i18n)

这个国际化插件可以让你的 bot 使用多种不同的语言。

不要困惑

不要将它与 fluent 混淆。

这个插件是 fluent 的改进版,它可以在 Deno 和 Node.js 上使用。

国际化的解释

本节解释了什么是国际化,为什么需要它,它有什么复杂之处,它和本地化有什么关系,以及你为什么需要一个插件来做这些事情。 如果你已经知道这些事情,请直接滚动到 现在开始

首先,国际化(internationalization)是一个非常长的单词。 因此,人们喜欢写首字母(i)和末字母(n)。 然后他们再计算所有剩余的字母(nternationalizatio,18个字母),并将这个数字放在 i 和 n 之间,所以他们最后会写成 i18n。 不要问为什么。 所以,i18n 就是一个国际化(internationalization)的奇怪缩写。

本地化(localization)也是一样的情况,我们用 l10n 来称呼它。

什么是本地化?

本地化意味着创建一个能说多种语言的 bot。 它应该根据用户使用的语言自动调整语言。

除了语言之外,还有很多其他需要本地化的东西。 你也需要考虑文化和标准差异,比如日期和时间格式。 下面是一些例子,这些东西在全球范围内有不同的表示方式:

  1. 日期
  2. 时间
  3. 数字
  4. 单位
  5. 复数
  6. 性别
  7. 分隔符
  8. 大小写
  9. 对齐
  10. 符号和图标
  11. 排序

… 以及 更多open in new window.

所有这些东西共同定义了用户的 地区。 每个 地区 通常会有一个两个字母的代码,例如 en 表示英语,de 表示德语,以此类推。 如果你想找到你所在地区的代码,请查看 这个列表open in new window

什么是国际化?

简而言之,国际化意味着编写可以根据用户的所在地区进行调整的代码。 换句话说,国际化是实现本地化的方式(见上面)。 这意味着,虽然你的 bot 对每个人来说,它的工作逻辑基本上是相同的,但它发送的具体消息会根据用户的语言不同而不同,所以它可以说不同的语言。

如果你不是对你的 bot 发送的文本在代码中进行硬编码,而是从文件中动态地进行读取,那么你就是在做国际化。 如果你不对日期和时间进行硬编码,而是使用一个库,根据不同地标准来调整这些值,那么你就是在做国际化。 所以你应该能理解这个意思了:不要硬编码那些应该根据用户所在地区或语言来变化的东西。

你为什么需要这个插件?

这个插件可以在国际化的过程中帮助你。 它基于 [Fluent](https://projectfluent.org/)——一个由 Mozillaopen in new window 开发的本地化系统。 这个系统有一个非常强大的和优雅的语法,可以让你以高效的方式写出自然的翻译。

从本质上讲,你可以把这些应该根据用户所在地区或语言来变化的东西提取到一些文本文件中,并将这些文件放在和代码相同的目录中。 然后使用这个插件来加载这些本地化的内容。 这个插件会自动确定用户的地区,并让你的 bot 选择正确的语言来说话。

下面,我们将这些文本文件称为 翻译文件。 它们需要遵循 Fluent 的语法。

现在开始

本节描述了设置项目结构和放置翻译文件的位置。 如果你熟悉这些,跳到这里,看看如何安装和使用插件。

这里有 很多种方法 来为你的 bot 添加更多的语言。 最佳但的方法是为你的 Fluent 翻译文件创建一个文件夹。 通常情况下,这个文件夹的名字为 locales/。 翻译文件的扩展名为 .ftl(fluent)。

以下是一个项目结构的例子:

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

如果你不熟悉 Fluent 的语法,你可以阅读他们的指南:https://projectfluent.org/fluent/guideopen in new window

这是一个英语的示例翻译文件,名为 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.

德语的等效翻译文件名为 locales/de.ftl,并且看起来像这样:

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.

在你的 bot 中,你可以通过插件使用这些翻译。 它们将可以通过 ctx.t 访问:

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

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

每当你调用 ctx.t,它将使用当前上下文对象 ctx 的语言来查找正确的翻译。 我们是通过一个 地区协商者(locale negotiator) 来查找正确的翻译。 在最简单的情况下,它只是返回 ctx.from.language_code

因此,不同地区的用户将能够看到他们各自语言的消息。

使用方式

这个插件从许多不同的因素中推导出用户所在的地区。 其中一个因素是来着 ctx.from.language_code,这将由用户的客户端提供。

然而,还有很多东西可以确定用户的地区。 例如,你可以将用户的地区保存在你的 会话 中。 因此,有两种方法可以使用这个插件:使用会话不使用会话.

不使用会话

不使用会话可以更简单的使用和配置这个插件。 但它主要的缺点是,你不能存储用户选择的语言。

像上面提到的,用户使用的地区将由 ctx.from.language_code 来决定,这是来自用户的客户端的。 但如果你没有这种语言的翻译,就会将使用默认语言。 有时候你的 bot 可能看不到用户的客户端提供的首选语言,在这种情况下,也会使用默认语言。

仅当用户以前和你的 bot 进行过私人对话,ctx.from.language_code 才会可见

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

// 对于 TypeScript 和自动补全支持,
// 用 I18n 的调味剂扩展上下文:
type MyContext = Context & I18nFlavor;

// 像平常一样创建一个 bot。
// 请记得扩展上下文。
const bot = new Bot<MyContext>(""); // <-- 把你的 bot 令牌放在这里 (https://t.me/BotFather)

// 创建一个 `I18n` 实例。
// 继续阅读以了解如何配置实例。
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // 更多信息请见下文
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

// 最后,将 i18n 实例注册到 bot 中,
// 这样消息就能被翻译了!
bot.use(i18n);

// 所有设置都已经完成了。
// 你可以使用 `t` 或 `translate` 来访问翻译。
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});
const { Bot } = require("grammy");
const { I18n } = require("@grammyjs/i18n");

// 像平常一样创建一个 bot。
const bot = new Bot(""); // <-- 把你的 bot 令牌放在这里 (https://t.me/BotFather)

// 创建一个 `I18n` 实例。
// 继续阅读以了解如何配置实例。
const i18n = new I18n({
  defaultLocale: "en", // 更多信息请见下文
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

// 最后,将 i18n 实例注册到 bot 中,
// 这样消息就能被翻译了!
bot.use(i18n);

// 所有设置都已经完成了。
// 你可以使用 `t` 或 `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";

// 对于 TypeScript 和自动补全支持,
// 用 I18n 的调味剂扩展上下文:
type MyContext = Context & I18nFlavor;

// 像平常一样创建一个 bot。
// 请记得扩展上下文。
const bot = new Bot<MyContext>(""); // <-- 把你的 bot 令牌放在这里 (https://t.me/BotFather)

// 创建一个 `I18n` 实例。
// 继续阅读以了解如何配置实例。
const i18n = new I18n<MyContext>({
  defaultLocale: "en", // 更多信息请见下文
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

// 最后,将 i18n 实例注册到 bot 中,
// 这样消息就能被翻译了!
bot.use(i18n);

// 所有设置都已经完成了。
// 你可以使用 `t` 或 `translate` 来访问翻译。
bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("start-msg"));
});

ctx.t 返回指定 key 的翻译消息。 你不需要担心语言,因为它们将被插件自动选择。

恭喜! 你的 bot 现在可以说多种语言了! 🌍🎉

使用会话

让我们假设你的 bot 有一个 /language 命令。 一般来说,在 grammY 中,我们可以使用 会话 来存储每次聊天的用户数据。 为了让你的国际化实例知道启用了会话,你必须在 I18n 的选项中把 useSession 设置为 true

下面是一个包含一个简单的 /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, // 是否在会话中存储用户的语言
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

const bot = new Bot<MyContext>(""); // <-- 把你的 bot 令牌放在这里

// 请记得在注册 i18n 实例的中间件之前
// 先注册 `session` 中间件。
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// 注册 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.locale` 包含所有已注册的地区。
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` 返回当前使用的地区。
  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, // 是否在会话中存储用户的语言
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

const bot = new Bot(""); // <-- 把你的 bot 令牌放在这里

// 请记得在注册 i18n 实例的中间件之前
// 先注册 `session` 中间件。
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// 注册 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.locale` 包含所有已注册的地区。
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` 返回当前使用的地区。
  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, // 是否在会话中存储用户的语言
  directory: "locales", // 从 locales/ 加载所有翻译文件
});

const bot = new Bot<MyContext>(""); // <-- 把你的 bot 令牌放在这里

// 请记得在注册 i18n 实例的中间件之前
// 先注册 `session` 中间件。
bot.use(
  session({
    initial: () => {
      return {};
    },
  }),
);

// 注册 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` 包含所有已注册的地区
  if (!i18n.locales.includes(ctx.match)) {
    return await ctx.reply(ctx.t("language.invalid-locale"));
  }

  // `ctx.i18n.getLocale` 返回当前使用的地区。
  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"));
});

当启用会话时,会话中的 __language_code 属性将被用来代替 ctx.from.language_code (由 Telegram 客户端提供),在语言选择期间。 当你的 bot 发送消息时,会使用 ctx.session.__language_code 来选择语言。

有一个 setLocale 方法可以用来设置你想要的语言。 它将在你的会话中保存这个值。

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

这相当于在会话中手动设置,然后再重新协商地区:

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

重新协商地区

当你使用会话或者其他东西—除了 ctx.from.language_code—来为用户选择一个自定义地区时,有一些情况下你可能会再处理 update 时改变语言。 例如,看一下上面使用会话的例子。

当你只做了

ctx.session.__language_code = "de";

它将不会更新 I18n 实例中当前使用的地区。 相反,它只会更新会话。 因此,变化将只会在 下一个 update 中生效。

如果你不能等到下一个 update,你可能需要在更新用户语言后刷新变化。 在这种情况下,请使用 renegotiateLocale 方法。

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

此后,当我们使用 t 方法时,bot 就会尝试使用这个消息的德语翻译(在 locales/de.ftl 中指定)来回复。

另外,请记住,当你使用内置会话时,你可以使用 setLocale 方法来实现相同的结果。

在不适用会话时设置地区

当你 不使用会话 时,如果需要为用户设置地区,你可以使用 useLocale 方法。

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

它设置了指定的地区,用于后续翻译。 这个效果只会在当前的 update 中生效,不会被保留。 你可以使用这个方法来在处理 update 的过程中(例如,当用户更改语言)更改翻译地区。

自定义地区协商

你可以使用 localeNegotiator 选项来指定一个自定义的地区协商器。 如果你想根据外部来源(例如数据库)或其他情况使用控制地区,这个选项是非常有用的。

下面时这个插件的地区选择的默认顺序:

  1. 如果会话被启用,尝试从会话中读取 __language_code。 如果它返回一个有效的地区,它将被使用。 如果它返回空或一个未注册的地区,则继续到步骤 2。

  2. 尝试从 ctx.from.language_code 读取。 如果它返回一个有效的地区,它将被使用。 如果它返回空或一个未注册的地区,则继续到步骤 3。

    请注意,ctx.from.language_code 只有在用户已经开始了 bot 时才可用。 这意味着,如果 bot 在某个群组或某个地方看到用户,而这个用户之前没有启动过 bot,它将看不到 ctx.from.language_code

  3. 尝试使用 I18n 的选项中配置的默认语言。 如果它被设置为一个有效的地区,它将被使用。 如果它没有被指定或设置为一个未注册的地区,则继续到步骤 4。

  4. 尝试使用英语(en)。 这个插件将英语设置为最终的后备地区。 尽管它是一个后备地区,并且我们建议提供一个翻译,但它不是必须的。 如果没有英语地区,则继续到步骤 5。

  5. 如果上面的所有方法都失败,则使用 {key} 来代替翻译。 我们 强烈建议I18n 的选项中设置一个在翻译中存在的地区作为 defaultLocale

地区协商

在 Telegram 的 update 处理过程中,地区协商通常只会发生一次。 然而,你可以通过调用 ctx.i18n.renegotiateLocale() 来调用协商器再次调用并确定新的地区。 这可以帮助你处理地区在单个 update 处理过程中发生了改变的情况。

下面是一个 localeNegotiator 示例,其中我们使用会话中的 locale 来代替 __language_code。 在这种情况下,你不需要在 I18n 的选项中设置 useSessiontrue

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",
});

如果自定义的地区协商返回一个无效的地区,它将以上面的顺序回退并选择一个地区。

渲染翻译后的消息

让我们仔细看看消息渲染。

bot.command("start", async (ctx) => {
  // 调用 "translate" 或 "t" 帮助来渲染消息,指定其 ID 和额外参数:
  await ctx.reply(ctx.t("welcome"));
});

现在你可以使用 /start 来启动你的 bot,它应该呈现以下消息:

Hi there!

占位符

有时候你可能想在字符串中放置数字和名字之类的值。 你可以通过使用占位符来实现。

bot.command("cart", async (ctx) => {
  // 你可以把占位符作为第二个对象传递。
  await ctx.reply(ctx.t("cart-msg", { items: 10 }));
});

对象 { items: 10 } 被称为 cart-msg 字符串的 翻译上下文

现在,使用 /cart 命令:

You currently have 10 items in your cart.

尝试更改 items 变量的值,看看渲染的消息会有什么变化! 另外,请参阅 Fluent 文档,特别是 占位符文档open in new window

全局占位符

通过设置所有翻译中都会用到的全局占位符,可以极大的减少翻译工作量。 例如,如果你在很多消息中会重复使用用户的名字,那么在每个地方都传入 { name: ctx.from.first_name } 就会变得很繁琐。

全局占位符就是救星! 可以考虑这样使用:

const i18n = new I18n<MyContext>({
  defaultLocale: "en",
  directory: "locales",
  // 定义全局可用的占位符
  globalTranslationContext(ctx) {
    return { name: ctx.from?.first_name ?? "" };
  },
});
bot.use(i18n);
bot.command("start", async (ctx) => {
  // 可以不需要指定,直接使用 `name`
  await ctx.reply(ctx.t("welcome"));
});

添加翻译

这里有三中主要的方法来加载翻译。

使用 directory 选项加载翻译

I18n 实例添加翻译的最简单方法是将所有翻译都放在一个目录中,并在选项中指定目录名称。

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

从目录中加载翻译

这个方法和在选项中指定 directory 是相同的。 只要把它们都放在一个文件夹里,就可以像这样进行加载:

const i18n = new I18n();

await i18n.loadLocalesDir("locales"); // 异步
i18n.loadLocalesDirSync("locales-2"); // 同步

加载单一翻译文件

你也可以在实例中添加单个翻译。 使用以下方法指定翻译文件的路劲:

const i18n = new I18n();

await i18n.loadLocale("en", { filePath: "locales/en.ftl" }); // 异步
i18n.loadLocaleSync("de", { filePath: "locales/de.ftl" }); // 同步

或者你也可以像这样直接加载翻译数据:

const i18n = new I18n();

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

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

监听本地化文本

我们设法向用户发送了本地化的消息。 现在,我们来看看如何监听来自用户的消息。 在 grammY 中,我们通常使用 bot.hears 处理程序来监听来自用户的消息。 但是,因为我们在这一章节中讨论了国际化,所以在这一节中我们来看看如何监听来自用户的本地化消息。

当你的 bot 有自定义 Keyboards,并且它包含本地化的文本时,这个特性就很有用了。

下面是一个监听使用自定义 Keyboards发送的本地化文本的简短示例。 我们没有使用 bot.hears,而是使用 bot.filter 和这个插件提供的 hears 中间件来监听。

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"));
});

hears 辅助函数允许你的 bot 监听来自用户的本地化消息。

更进一步

插件概述