Skip to content

Internacionalización (i18n) ​

@botmesh/core trae una capa de i18n pequeña: registra un árbol de mensajes por idioma con bot.locale() y traduce con ctx.t() en los handlers y con c.t() en las conversaciones. El idioma se resuelve en cada mensaje, así que cada usuario recibe las respuestas en el suyo.

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

Mensajes ​

  • Las claves se anidan (ctx.t('menu.title')); una clave también puede llevar puntos literales ({ 'menu.title': '…' }). Registrar otra vez el mismo idioma hace merge profundo.
  • Los placeholders {param} se interpolan; los desconocidos se dejan tal cual.
  • Una hoja { one, other, zero?, two?, few?, many? } es una forma plural que se elige con params.count e Intl.PluralRules. zero también aplica a un count === 0 exacto en idiomas sin categoría cero.
  • Una clave que falta cae al idioma base (pt-BR → pt), luego al idioma por defecto y por último a la propia clave (con un log de nivel debug).

Cómo se resuelve el idioma ​

Dentro de la cola por chat, antes de los hooks before('message') (ver el pipeline de entrada), ctx.lang toma el primer candidato que sirva un idioma registrado:

  1. La preferencia guardada del usuario: la clave 'lang' de ctx.user (la escribe ctx.setLang(); cambia la clave con userKey).
  2. El idioma que el provider informa del remitente: SenderInfo.language o platformData.languageCode / language / locale (p. ej. el language_code de Telegram). Se desactiva con providerLanguage: false. Una actualización que no informa idioma (el language_code de Telegram es opcional) reutiliza el último informado para el mismo remitente, guardado en memoria, para que el chat no salte al idioma por defecto entre turnos.
  3. Tu función detect(ctx), si la configuras.
  4. defaultLocale (por defecto 'en', o BotConfig.lang).

ctx.langSource dice qué paso ganó ('user', 'provider', 'detector' o 'default'; undefined con el i18n apagado). Si un hook before('message') reescribe ctx.body (por ejemplo, la transcripción de una nota de voz de @botmesh/voice), hay un detect configurado y el idioma salió de él o del valor por defecto, se vuelve a resolver después de los hooks para que detect vea el texto hablado. Se respeta un ctx.lang que el hook haya puesto a mano.

ctx.defaultLang es el defaultLocale del bot, sea cual sea el ctx.lang resuelto: los plugins que traen textos solo para algunos idiomas (como @botmesh/ai) lo usan de reserva para un idioma que no cubren.

La resolución se activa con cualquier llamada a bot.locale() o con BotConfig.i18n. Sin ninguna de las dos, el bot responde en su idioma por defecto (mensajes internos en inglés, o en el idioma de lang) y no lee nada del store.

Un bot en un solo idioma: lang ​

Los textos internos están en inglés por defecto. lang elige otro idioma incluido para ellos (y para ctx.lang) sin activar la resolución: no lee ctx.user, no usa el idioma del provider ni un detector. Es todo lo que necesita un bot escrito en un solo idioma:

ts
const bot = new Bot({ provider, lang: 'es' })   // textos internos en español, como en las alphas de 2.0

Si pones lang e i18n.defaultLocale a la vez, deben coincidir: la misma etiqueta, o el mismo idioma con una etiqueta más específica ('es' y 'es-MX', y la más específica queda como predeterminada). Idiomas o regiones distintos lanzan ConfigValidationError. Usa i18n cuando el bot atienda varios idiomas.

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

Fijar el idioma del bot ​

El idioma que informa el provider es el del cliente del usuario, no necesariamente el de tu bot. Si tu contenido (persona, flujos, títulos de botones) existe en un solo idioma, un cliente en otro idioma recibiría los textos internos ("Voy a: … ¿Confirmas?", respuestas de los comandos slash, timeouts) en su idioma alrededor de contenido en el tuyo, y un usuario con dos clientes en idiomas distintos podría verlos cambiar de un turno a otro. Fija los idiomas que sirve el bot con locales:

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

Una preferencia guardada, un idioma del provider o uno detectado que no esté en locales se ignora y la resolución cae a defaultLocale (que siempre se sirve); ctx.setLang() lo rechaza. Las variantes regionales siguen a su base ('en' sirve 'en-GB'). En resumen, la precedencia: los idiomas que sirve el bot (locales) acotan todo; dentro de ellos, la elección explícita del usuario → el idioma de su cliente → detect → defaultLocale. Sin locales se sirve cualquier idioma registrado (incluidos los es y en internos). Para un bot bilingüe, lista ambos (locales: ['en', 'es']) y traduce también tu contenido, por ejemplo construyendo la persona según ctx.lang.

Cambiar de idioma ​

ts
bot.command('lang', async (ctx, [code]) => {
  if (!code || !bot.locales().includes(code.toLowerCase())) return ctx.reply(bot.locales().join(', '))
  await ctx.setLang(code)               // se guarda en ctx.user; ctx.lang cambia en el acto
  await ctx.reply(ctx.t('menu.title'))
})

ctx.setLang(code) guarda el idioma registrado que sirve a code ('en-GB' → 'en' si solo existe en). Rechaza con TypeError si el código está vacío o ningún idioma lo sirve, y con Error si el i18n está desactivado.

Conversaciones ​

c.t() y c.lang siguen el último mensaje de la conversación: un cambio de idioma a mitad de conversación se aplica al siguiente paso y también al destino de 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')))

Fuera de los handlers ​

ts
const lang = await bot.langOf(userId)          // preferencia guardada o el idioma por defecto
await bot.send(chatId, bot.t('greeting', { name: 'Ana' }, lang))
bot.locales()                                  // ['es', 'en', ...]

Mensajes internos ​

Los mensajes propios del core (timeout, reintentos y error de conversación, validación de esperas —incluido el rechazo de un schema sin mensaje propio—, respuestas de slashCommandsPlugin y las preguntas, pistas, palabra para saltar y errores de validación de flow().form() bajo core.form.*) viven bajo core.* con valores por defecto en es y en. Sus claves se exportan como CORE_MESSAGES. Para cambiarlos, o traducirlos a un idioma nuevo, usa bot.locale():

ts
bot.locale('es', { core: { conversation: { timeout: '⏰ Se acabó el tiempo.' } } })
bot.locale('pt', { core: { commands: { ping: 'pong 🏓' } } })
bot.locale('es', { core: { form: { skipWord: 'ninguno', min: '❌ {label} debe ser al menos {min}.' } } })

Un conversation.timeoutMessage / retriesMessage / errorMessage configurado en BotConfig sigue ganando al idioma.

@botmesh/ai hace lo mismo bajo ai.* (claves exportadas como AI_MESSAGES, valores por defecto es y en en AI_LOCALES, ambos congelados): las respuestas de reserva del autoresponder cuando el proveedor falla o no contesta nada (ai.error) o se niega (ai.refusal), salvo que fijes errorMessage / refusalMessage, la pregunta de confirmación de herramientas (ai.tools.confirm, {tool} / {args}; ai.tools.confirmButtons y las etiquetas ai.tools.button.yes / ai.tools.button.no en canales con botones, y los avisos ai.tools.confirmAnswered / ai.tools.confirmExpired donde los botones no se pueden quitar) y sus piezas (ai.tools.confirmRepeat, ai.tools.ago.*, ai.tools.value.*, ai.tools.more), las notas del prompt de sistema (ai.tools.confirmNote, ai.tools.doneNote), la respuesta al agotar maxSteps (ai.tools.maxSteps), la respuesta cuando las tool calls del último paso se cortaron por el tope de tokens (ai.tools.truncated) y la línea de appendSources (ai.rag.sources, {sources}). Una clave que tus idiomas no definen cae al texto interno en ctx.lang; para un idioma sin textos internos (cualquiera salvo es y en), en el idioma por defecto del bot (ctx.defaultLang) si los tiene, y si no, en inglés:

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

Released under the MIT License.