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 fromparams.countwithIntl.PluralRules.zeroalso matches an exactcount === 0in 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 thebefore('message') hooks (see
the inbound pipeline), ctx.lang is set
to the first candidate a registered locale serves:
- The user’s stored preference: the
ctx.userkey'lang'(written byctx.setLang(); change the key withuserKey). - The language the provider reports for the sender:
SenderInfo.language, orplatformData.languageCode/language/locale(e.g. Telegram’slanguage_code). Turn it off withproviderLanguage: false. An update that reports no language (Telegram’slanguage_codeis optional) reuses the last one reported for the same sender, kept in memory, so the chat does not flip to the default between turns. - Your
detect(ctx)function, if configured. defaultLocale(default'en', orBotConfig.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:
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.
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 withlocales:
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
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():
Outside handlers
Built-in messages
The core’s own messages (conversation timeout, retries and error, wait validation — including aschema 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():
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:
Next steps
- Configuration reference:
lang,i18nand the othernew Bot()options - Persistent user data: where
ctx.setLang()stores the choice - AI plugin: replies in the user’s language