@moebius/grammy-fluent

Fluent localization system integration for grammY Telegram bot framework

Usage no npm install needed!

<script type="module">
  import moebiusGrammyFluent from 'https://cdn.skypack.dev/@moebius/grammy-fluent';
</script>

README

@moebius/grammy-fluent

npm version License: MIT

Fluent localization system integration for grammY Telegram bot framework.

Why Fluent?

I've studied several i18n standards and message formats and have found that Fluent has all the required features and at the same time provides a very user-friendly message format, which is very important for non-dev people (i.e. translators).

It is also supported by Mozilla Foundation, a well-respected leader in world of OpenSource and the Web standards.

Message format example

Consider the following message format example and see for yourself:

-project-name = Super Project

welcome = Welcome, {$name}, to the {-project-name}!
  .balance =
    Your balance is: {
      NUMBER($value, maximumFractionDigits: 2)
    }
  .apples-count =
    You have { NUMBER($applesCount) ->
      [0] no apples
      [one] {$applesCount} apple
      *[other] {$applesCount} apples
    }

Fluent features

  • Variable substitution (aka placeables),

  • Built-in and custom formatters that could be applied to the values of the rendered variables,

  • Conditional substitution (selection) based on variable value,

  • Powerful pluralization with built-in rules for every locale.

Library features

  • Built on top of @moebius/fluent library, that on itself simplifies Fluent integration,

  • Adds helper functions to grammY bots context to simplify message translation,

  • Automatically uses translation locale based on the language selected by the user in their Telegram settings,

  • Uses an automatic language negotiation, so the best possible language will be automatically picked for each user,

  • Gives you full access to Fluent instance, so you can configure it yourself as you see fit,

  • Uses peer dependencies so that you can use a wide combination of library versions in your project,

  • All LTS Node.js versions are supported (starting from Node 12),

  • Written completely in TypeScript from scratch in a very strict manner with 100% type coverage (and no any's), ensuring that the library code is correct (type safe) by itself and also provides high quality typing declarations to make sure that your code is also correct and type safe,

  • Enables amazing type completion for your IDE (even if you are not using TypeScript) thanks to the provided typing declarations,

  • Minimal possible dependencies (all are high quality ones) updated to the latest versions,

  • Source maps for the library is generated and provided to you for easier debugging.

Install

Install the libraries:

npm install --save @moebius/grammy-fluent @moebius/fluent

Prior knowledge

It is highly advisable to read @moebius/fluent as well as fluent.js libraries documentation before using this library.

Usage

import { Bot, Context } from 'grammy';
import { Fluent } from '@moebius/fluent';
import { useFluent, FluentContextFlavor } from '@moebius/grammy-fluent';

// Extend your application context type with the provided
// flavor interface
export type MyAppContext = (
  & Context
  & FluentContextFlavor
);

// Create grammY bot as usual,
// specify the extended context
const bot = new Bot<MyAppContext>();

// Create an instance of @moebius/fluent and configure it
const fluent = new Fluent();

// Add translations that you need
await fluent.addTranslation({
  locales: 'en',
  source: `
-brand-name = Super Project

welcome =
  Welcome, {$name}, to the {-brand-name}!
  Your balance is: {
    NUMBER($value, maximumFractionDigits: 2)
  }
  You have { NUMBER($applesCount) ->
    [0] no apples
    [one] {$applesCount} apple
    *[other] {$applesCount} apples
  }
  `,

  // All the aspects of Fluent are highly configurable
  bundleOptions: {
    // Use this option to avoid invisible characters
    // around placeables
    useIsolating: false,
  },
});

// You can also load translations from files
await fluent.addTranslation({
  locales: 'ru',
  filePath: [
    `${__dirname}/feature-1/translation.ru.ftl`,
    `${__dirname}/feature-2/translation.ru.ftl`
  ],
});

// Add fluent middleware to the bot
bot.use(useFluent({
  fluent,
}));

bot.command('i18n_test', async context => {

  // Call the "translate" or "t" helper to render the
  // message by specifying it's ID and
  // additional parameters:
  await context.reply(context.t('welcome', {
    name: context.from.first_name,
    value: 123.456,
    applesCount: 1,
  }));

  // The locale to use will be detected automatically

});

API

useFluent

function useFluent(options: GrammyFluentOptions): Middleware;

Call this function to add Fluent middleware to your bot, e.g:

bot.use(useFluent({
  fluent,
}));

The following options are supported:

Name Type Description
fluent * Fluent A pre-configured instance of Fluent to use.
defaultLocale LocaleId A locale ID to use by default. This is used when locale negotiator returns an empty result. The default value is: "en".
localeNegotiator LocaleNegotiator An optional function that determines a locale to use. Check the locale negotiation section below for more details.

Please, see @moebius/fluent documentation for all the Fluent configuration instructions.

Context helpers

The following helpers are added to the bots' context by the middleware:

Name Type Description
fluent Object Fluent context namespace object, see the individual properties below.
fluent.instance Fluent An instance of Fluent.
fluent.renegotiateLocale() () => Promise You can manually trigger additional locale negotiation by calling this method. This could be useful if locale negotiation conditions has changed and new locale must be applied (e.g. user has changed the language and you need to display an answer in new locale).
fluent.useLocale() (localeId: string) => void Sets the specified locale to be used for future translations. Effect lasts only for the duration of current update and is not preserved. Could be used to change the translation locale in the middle of update processing (e.g. when user changes the language).
translate | t (messageId: string, context?: TranslationContext) => string Translation function bound to the current locale. Shorthand alias "t" is also available.

Make sure to use FluentContextFlavor to extend your application context in order for typings to work correctly:

import { Context } from 'grammy';
import { FluentContextFlavor } from '@moebius/fluent';

export type MyAppContext = (
  & Context
  & FluentContextFlavor
);

const bot = new Bot<MyAppContext>();

Locale negotiation

You can use a localeNegotiator property to define a custom locale negotiation function that will be called for each Telegram update and must return a locale ID to use for message translation.

The default negotiator will detect locale based on users Telegram language setting.

Locale negotiation normally happens only once during Telegram update processing. However, you can call await context.fluent.renegotiateLocale() to call the negotiator again and determine the new locale. This is useful if the locale changes during single update processing.

API

type LocaleNegotiator<ContextType> = (
  (context: ContextType) => (LocaleId | PromiseLike<LocaleId>)
);

Example

The example below will try to use locale ID stored in users session:

async function myLocaleNegotiator(context: Context) {
  return (
    (await context.session).languageId ||
    context.from.language_code ||
    'en'
  );
}

bot.use(useFluent({
  fluent,

  // Telling middleware to use our custom negotiator
  localeNegotiator: myLocaleNegotiator,

  defaultLocale: 'en', // this is the default
}));

Cookbook

i18n plugin replacement

If you were using the official i18n plugin with session storage, you can easily replace it using the following code:

bot.use(useFluent({
  fluent,
  localeNegotiator: async context => (
    (await context.session).__language_code
  ),
}));

async function handleLocaleChange(context: MyAppContext) {

  // Getting locale from button's callback data
  const newLocale = context.callbackQuery;

  // Saving new locale to the session
  (await context.session).__language_code = newLocale;

  // Making sure that callback query answer will be
  // in new locale
  await context.fluent.useLocale(locale);

  // Sending an answer to the callback query
  await context.answerCallbackQuery(
    context.t('settings_language-changed', {
      language: context.t(`locale_${locale}`)
    })
  );

}

It will use the locale that is already stored in the user session by the i18n plugin.

Contributors

License (MIT)

Copyright © 2021 Slava Fomin II

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.