Skip to content

Internationalization (i18n) ​

@botmesh/core ships a small i18n layer: register a message tree per locale with bot.locale(), then translate with ctx.t() in handlers and c.t() in conversations. The language is resolved for every message, so each user gets replies in their own language.

ts
bot.locale('es', {
  greeting: '¡Hola, {name}!',
  menu: { title: 'Menú principal' },
  cart: { zero: 'Carrito vacío', one: '{count} producto', other: '{count} productos' },
})
bot.locale('en', {
  greeting: 'Hello, {name}!',
  menu: { title: 'Main menu' },
  cart: { one: '{count} item', other: '{count} items' },
})
bot.locale({ defaultLocale: 'es' })

bot.when('hola', (ctx) => ctx.reply(ctx.t('greeting', { name: ctx.senderInfo?.displayName ?? '' })))
bot.when('carrito', (ctx) => ctx.reply(ctx.t('cart', { count: 3 })))

Messages ​

  • Keys nest (ctx.t('menu.title')); a key may also contain dots literally ({ 'menu.title': '…' }). Registering the same locale again deep-merges.
  • {param} placeholders are interpolated; unknown placeholders stay as they are.
  • A leaf { one, other, zero?, two?, few?, many? } is a plural form, picked from params.count with Intl.PluralRules. zero also matches an exact count === 0 in languages without a zero category.
  • A missing key falls back to the base language (pt-BR → pt), then to the default locale, then to the key itself (logged at debug level).

How the language is resolved ​

Inside the per-chat queue, before the before('message') hooks (see the inbound pipeline), ctx.lang is set to the first candidate a registered locale serves:

  1. The user's stored preference: the ctx.user key 'lang' (written by ctx.setLang(); change the key with userKey).
  2. The language the provider reports for the sender: SenderInfo.language, or platformData.languageCode / language / locale (e.g. Telegram's language_code). Turn it off with providerLanguage: false. An update that reports no language (Telegram's language_code is optional) reuses the last one reported for the same sender, kept in memory, so the chat does not flip to the default between turns.
  3. Your detect(ctx) function, if configured.
  4. defaultLocale (default 'en', or BotConfig.lang).

ctx.langSource tells which step won ('user', 'provider', 'detector' or 'default'; undefined while i18n is off). When a before('message') hook rewrites ctx.body — the @botmesh/voice transcript of a voice note, for instance — a detect is configured and the language came from it or from the default, it is resolved again after the hooks, so detect sees the spoken text. A ctx.lang the hook assigned itself is kept.

ctx.defaultLang is the bot's defaultLocale, whatever ctx.lang resolved to: plugins that ship texts for only a few languages (like @botmesh/ai) use it as the fallback for a language they do not cover.

Resolution is turned on by any bot.locale() call or by BotConfig.i18n. Without either, the bot answers in its default locale (English built-in messages, or the language set with lang) and reads nothing from the store.

A single-language bot: lang ​

The built-in texts are in English by default. lang picks another bundled language for them (and for ctx.lang) without turning on resolution: no ctx.user read, no provider language, no detector. It is all a bot written in one language needs:

ts
const bot = new Bot({ provider, lang: 'es' })   // Spanish built-in texts, as in the 2.0 alphas

lang and i18n.defaultLocale must agree when both are set: the same tag, or the same language with one tag more specific ('es' and 'es-MX', where the more specific one becomes the default). Different languages or regions throw ConfigValidationError. Use i18n instead when the bot serves several languages.

ts
const bot = new Bot({
  provider,
  i18n: { defaultLocale: 'en', detect: (ctx) => (/^\s*hola\b/i.test(ctx.body ?? '') ? 'es' : undefined) },
})

Pinning the bot's language ​

The provider-reported language is the user's client language, not necessarily the language of your bot. If your content (persona, flows, button titles) exists in one language only, a client in another language would get built-in texts ("About to: … Do you confirm?", slash command replies, timeouts) in its language wrapped around content in yours — and a user with two clients in different languages could see them switch from one turn to the next. Pin the languages the bot serves with locales:

ts
const bot = new Bot({ provider, i18n: { defaultLocale: 'en', locales: ['en'] } })

A stored preference, provider-reported or detected language outside locales is ignored and resolution falls back to defaultLocale (always served); ctx.setLang() refuses it. Regional variants follow their base ('en' serves 'en-GB'). Precedence, in short: the languages the bot serves (locales) bound everything; within them, the user's explicit choice → their client language → detect → defaultLocale. Without locales every registered locale is served (the built-in es and en included). To make a bot bilingual, list both (locales: ['en', 'es']) and translate your own content too, e.g. by building the persona from ctx.lang.

Switching language ​

ts
bot.command('lang', async (ctx, [code]) => {
  if (!code || !bot.locales().includes(code.toLowerCase())) return ctx.reply(bot.locales().join(', '))
  await ctx.setLang(code)               // persisted in ctx.user; ctx.lang changes right away
  await ctx.reply(ctx.t('menu.title'))
})

ctx.setLang(code) stores the registered locale that serves code ('en-GB' → 'en' when only en exists). It rejects with TypeError for an empty code or one no locale serves, and with Error while i18n is off.

Conversations ​

c.t() and c.lang follow the latest message of the conversation, so a language switched mid-conversation applies to the next step, and also to the target of c.goto():

ts
bot.conversation('menu', async (c) => {
  await c.say(c.t('menu.title'))
})
bot.use(flow('menu').say((_ctx, c) => c.t('menu.title')))

Outside handlers ​

ts
const lang = await bot.langOf(userId)          // stored preference or the default
await bot.send(chatId, bot.t('greeting', { name: 'Ana' }, lang))
bot.locales()                                  // ['es', 'en', ...]

Built-in messages ​

The core's own messages (conversation timeout, retries and error, wait validation — including a schema rejection without its own message —, slashCommandsPlugin replies, and the flow().form() prompts, hints, skip word and validation errors under core.form.*) live under core.* with es and en defaults. Their keys are exported as CORE_MESSAGES. Override them, or translate them for a new locale, with bot.locale():

ts
bot.locale('en', { core: { conversation: { timeout: '⏰ Time is up.' } } })
bot.locale('pt', { core: { commands: { ping: 'pong 🏓' } } })
bot.locale('en', { core: { form: { skipWord: 'none', min: '❌ {label} must be at least {min}.' } } })

A conversation.timeoutMessage / retriesMessage / errorMessage set in BotConfig still wins over the locale.

@botmesh/ai does the same under ai.* (keys exported as AI_MESSAGES, es and en defaults in AI_LOCALES, both frozen): the autoreply's fallback replies when the provider fails or answers nothing (ai.error) or refuses (ai.refusal), unless errorMessage / refusalMessage are set, the tool confirmation prompt (ai.tools.confirm, {tool} / {args}; ai.tools.confirmButtons and the ai.tools.button.yes / ai.tools.button.no labels on channels with buttons, ai.tools.confirmAnswered / ai.tools.confirmExpired notices where the buttons cannot be edited away) and its pieces (ai.tools.confirmRepeat, ai.tools.ago.*, ai.tools.value.*, ai.tools.more), the system prompt notes (ai.tools.confirmNote, ai.tools.doneNote), the maxSteps reply (ai.tools.maxSteps), the reply when the last step's tool calls were cut off by the output-token cap (ai.tools.truncated) and the appendSources line (ai.rag.sources, {sources}). A key your locales do not define falls back to the built-in text in ctx.lang; for a language without built-in texts (anything but es and en), in the bot's default locale (ctx.defaultLang) when it has them, else in English:

ts
bot.locale('en', { ai: { rag: { sources: '📚 {sources}' } } })

Released under the MIT License.