@yaebal/i18n

per-chat locale with ctx.t / ctx.locale / ctx.changeLanguage. missing keys fall back to the default locale, then to the key itself. powers useTranslation() in the morda jsx layer.

install

terminal
pnpm add @yaebal/i18n

usage

pass your locale dictionaries to i18n() and install it with .install(). the plugin reads the stored locale for the current chat before your handlers run, so ctx.t is ready immediately.

bot.ts
import { Bot } from "@yaebal/core";
import { i18n } from "@yaebal/i18n";

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(i18n({
    defaultLocale: "en",
    locales: {
      en: {
        welcome: "Hello {name}!",
        bye: "Goodbye",
      },
      ru: {
        welcome: "Привет {name}!",
        bye: "До свидания",
      },
    },
  }));

bot.command("start", async (ctx) => {
  await ctx.reply(ctx.t("welcome", { name: ctx.from!.first_name }));
});

bot.command("lang", async (ctx) => {
  await ctx.changeLanguage("ru");
  await ctx.reply(ctx.t("welcome", { name: ctx.from!.first_name }));
});

bot.start();

api

exportsignaturedescription
i18n(options: I18nOptions<L>) => Plugin<Context, I18nControls>creates the i18n plugin
I18nOptionsinterfaceoptions passed to i18n()
I18nControlsinterfacewhat the plugin adds to the context (t, locale, changeLanguage)
DictRecord<string, string>a single locale's key → template map
TFn(key: string, params?: Record<string, unknown>) => stringthe type of ctx.t

I18nOptions<L>

fieldtyperequireddescription
defaultLocaleLyeslocale used when no stored locale exists and for fallback lookups
localesRecord<L, Dict>yesall translation dictionaries keyed by locale code
storageStorageAdapter<string>nowhere to persist each chat's locale. defaults to MemoryStorage
getKey(ctx: Context) => string | undefinednostorage key for the update. defaults to ctx.chat?.id?.toString()

I18nControls (added to ctx)

propertytypedescription
tTFntranslate a key; {placeholder} tokens are replaced from params
localestringthe active locale code for this update
changeLanguage(locale: string) => Promise<void>switch locale for this update and persist it; subsequent ctx.t calls in the same handler use the new locale immediately

persistent storage

the default MemoryStorage is lost on restart. pass any StorageAdapter<string> (same interface as @yaebal/session) to persist locales across restarts.

redis-locale.ts
import { i18n } from "@yaebal/i18n";
import { type StorageAdapter } from "@yaebal/session";

// any StorageAdapter<string> keeps locale across restarts
class RedisLocaleStorage implements StorageAdapter<string> {
  async get(key: string): Promise<string | undefined> { /* ... */ }
  async set(key: string, value: string): Promise<void> { /* ... */ }
  async delete(key: string): Promise<void> { /* ... */ }
}

bot.install(i18n({
  defaultLocale: "en",
  locales: { en: { hi: "Hi" }, ru: { hi: "Привет" } },
  storage: new RedisLocaleStorage(),
}));

per-user locale

override getKey to partition by user instead of chat.

per-user.ts
bot.install(i18n({
  defaultLocale: "en",
  locales: { en: { hi: "Hi" }, ru: { hi: "Привет" } },
  // use the user id as the key instead of the chat id
  getKey: (ctx) => ctx.from?.id?.toString(),
}));

fallback behaviour

fallback.ts
// "ru" locale has no "bye" key → falls back to "en"
// "en" has no "missing" key → returns the key itself

ctx.t("bye");      // → "Goodbye"  (en fallback)
ctx.t("missing");  // → "missing"  (key fallback)
interpolation uses {placeholder} syntax, not template literals. every {key} token in the string is replaced with the matching entry from params via String(value).

changeLanguage takes effect immediately within the current handler — calls to ctx.t after await ctx.changeLanguage("ru") in the same middleware use the new locale. the next update loads the persisted locale from storage.

updates without a chat (e.g. poll updates) where getKey returns undefined always use the defaultLocale and never persist.