Глава 7. Динамическое определение и переключение языка в Aiogram

Практический пример

Теперь мы знаем, как проводится интернационализация и локализация продукта. Но нам бы хотелось это все внедрить в интерфейс нашего приложения на фреймворке Aiogram, чтобы повысить качество UX (user experience).

Пример будет реализован с помощью Fluent, поскольку gettext уже изучен и по факту является промышленным стандартом. А нам хочется попробовать что-то новое.

Спроектируем взаимодействие с пользователем следующим образом.

  1. Если пользователь впервые начал взаимодействовать с ботом, то мы не знаем, на каком языке он говорит. Попытаемся получить язык пользователя из апдейта. Но эта опция зависит от клиента Telegram, которым пользуется пользователь, и часто это поле не заполнено. В таком случае отдадим какой-то язык по умолчанию, например, английский.

  2. Если пользователь выбрал в меню бота или отправил команду выбора языка, то сохраним язык в базе данных и переключим язык. В дальнейшем, при взаимодействии, будем использовать язык из базы данных.

  3. Нам нужно сделать переводы для всех элементов интерфейса, включая сообщения, клавиатуры, меню, алерты.

  4. В каких-то случаях, при отправке картинок или документов, необходимо локализовать картинки или документы.

В учебных целях мы реализуем следующие вещи:

  • Наш бот будет стартовать по команде /start

  • выводить текст помощи по команде /help или после получения слова помощь или help, в зависимости от текущего языка.

  • Отдавать клавиатуру на нужном языке.

  • По команде /languge_en и /languge_ru переключаться на соответствующий язык.

  • По команде /photo отправлять картинку, переведенную на текущий язык.

Нам потребуется база данных для хранения языка пользователя. В коде будет минимальный пример, просто «чтоб работало», а также много сообщений и много контекста. Ну и, как ранее говорилось, будет много дублирования кода - здравствуй WET, прощай DRY.

Структура части проекта, отвечающая за интернационализацию и локализацию, будет примерно такой:

...
├───database
│       bot.db
│       database.py
├───locales
│   ├───en
│   │   ├───LC_MESSAGES
│   │   │       messages.ftl
│   │   └───static
│   │           bayan_en.jpg
│   └───ru
│       ├───LC_MESSAGES
│       │       messages.ftl
│       └───static
│               bayan_ru.jpg
└───middlewares
        db_middleware.py
        i18n_middleware.py
...

База данных, интерфейс к ней, папка с переводами и локализованными картинками и два внешних middleware для работы с базой данных и переводами.

Базу данных и интерфейс к ней вы уже научились делать. Интерфейс БД должен уметь добавлять пользователя и получать его язык. Этого достаточно для нашего примера.

Самое главное, это то, что классы движка перевода, реализованные в Aiogram, не позволяют из коробки реализовать задуманный нами функционал. В главе Конфигурация движка перевода мы уже приводили описание этих классов. Напомню:

SimpleI18nMiddleware - выбирает код языка из объекта User, полученного в событии. Однако не все клиенты Telegram отдают это значение. Очень часто объект language_code не заполнен и является пустой строкой.

ConstI18nMiddleware - выбирает статически определенную локаль. Это не динамично и скучно.

FSMI18nMiddleware - хранит локаль в хранилище FSM. Но у нас ее там пока нет.

Но у нас есть то, что нужно: I18nMiddleware. Это базовый абстрактный класс для наследования и создания собственного обработчика.

Создадим в нашем приложении объект этого класса, и передадим туда наш кастомный менеджер, который реализуем ниже.

lesson3.py
80async def main() -> None:
81    basicConfig(level=INFO)
82    bot = Bot("TOKEN", parse_mode=ParseMode.HTML)
83
84    dp = Dispatcher()
85    dp.include_router(router)
86
87    # создаем объект middleware пакета локализации aiogram_i18n
88    i18n = I18nMiddleware(
89        core=FluentRuntimeCore(path="locales/{locale}/LC_MESSAGES"),
90        #  передаем наш кастомный менеджер языка из middlewares/i18n_middleware.py:
91        manager=i18n_middleware.UserManager(),
92        default_locale="en"
93    )

В конце этого раздела есть полный текст кода lesson3.py.

Начнем с реализации своего менеджера. Создадим файл middlewares/i18n_middleware.py.

middlewares/i18n_middleware.py
 1from aiogram_i18n.managers import BaseManager
 2from aiogram.types.user import User
 3from database.database import Database
 4
 5
 6class UserManager(BaseManager):
 7    """
 8    Собственная реализация middleware - менеджера для интернационализации
 9    на базе класса BaseManager из библиотеки aiogram_i18n. Базовый класс
10    BaseManager имеет абстрактные методы set_locale и get_locale, которые
11    нам нужно реализовать. Кроме того, при инициализации объекта класса,
12    выполняются LocaleSetter и LocaleGetter (см. реализацию BaseManager).
13    """
14
15    async def get_locale(self, event_from_user: User, db: Database = None) -> str:
16        default = event_from_user.language_code or self.default_locale
17        if db:
18            user_lang = db.get_lang(event_from_user.id)
19            if user_lang:
20                return user_lang
21        return default
22
23    async def set_locale(self, locale: str, event_from_user: User, db: Database = None) -> None:
24        if db:
25            db.set_lang(event_from_user.id, locale)

По сути мы просто реализовали два метода:

get_locale – геттер, который сначала проверяет есть ли в базе данных у пользователя какой-то язык. Если в базе ничего нет, то пытается получить язык из клиента. Если и его нет - просто отдает локаль по-умолчанию.

set_locale – сеттер, который просто записывает язык в базу данных, а если базы нет, то ничего не делает (потому, что я не придумал что делать).

Естественно, эту логику работы с языком каждый придумывает себе сам под свои задачи и особенности работы и используемые инструменты (кэш, хранилище и т.п.).

Регистрируем middleware сначала для базы данных, а затем i18n. Не забываем, что у i18n есть метод .setup(), который правильно регистрирует этот middleware.

lesson3.py
80async def main() -> None:
95    # Регистрация middleware.
96    # Сначала регистрируется middleware для базы данных, так как там хранится язык.
97    dp.update.outer_middleware.register(db_middleware.DBMiddleware())
98    # Затем регистрируем i18n middleware
99    i18n.setup(dispatcher=dp)

Импорты будут такие же, как во втором уроке:

from aiogram_i18n import I18nContext, LazyProxy, I18nMiddleware
from aiogram_i18n.cores.fluent_runtime_core import FluentRuntimeCore

from aiogram_i18n.types import (
    ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
    # you should import mutable objects from here if you want to use LazyProxy in them
)

Сначала пропишем наши хэндлеры. А уже в конце займемся переводами. Первый хэндлер обрабатывает команду /start и сохраняет пользователя в БД. Язык нам не известен, поэтому его мы не сохраняем.

lesson3.py
32@router.message(CommandStart())
33async def process_start_command(message: Message, i18n: I18nContext, db: Database):
34    if not db.get_user(message.from_user.id):
35        db.add_user(message.from_user.id, message.from_user.username)
36    name = message.from_user.full_name
37    await message.answer(text=i18n.get("hello", user=name, language=i18n.locale),
38                         reply_markup=rkb
39                         )

Следующий хэндлер обрабатывает команду /help и слова help, Help, помощь, Помощь, введенные на родном языке пользователя. Поскольку на момент попадания в фильтрацию объект i18n middleware не вызывается, язык мы не можем получить. Поэтому используем ленивую подстановку текстов LazyProxy. Мутабельные объекты, например клавиатуры, для LazyProxy экспортируем не из основной библиотеки aiogram, а из aiogram_i18n.

lesson3.py
43@router.message(Command("help"))
44@router.message(F.text == LazyProxy("help", case="capital"))
45@router.message(F.text == LazyProxy("help", case="lower"))
46async def cmd_help(message: Message, i18n: I18nContext) -> Any:
47    return message.reply(text=i18n.get("help-message"))

Создадим хэндлер для команды обработки смены языка.

lesson3.py
50async def switch_language(message: Message, i18n: I18nContext, locale_code: str):
51    await i18n.set_locale(locale_code)
52    await message.answer(i18n.get("lang-is-switched"), reply_markup=rkb)
53
54
55@router.message(Command("language_en"))
56async def switch_to_en(message: Message, i18n: I18nContext) -> None:
57    await switch_language(message, i18n,"en")
58
59
60@router.message(Command("language_ru"))
61async def switch_to_en(message: Message, i18n: I18nContext) -> None:
62    await switch_language(message, i18n,"ru")

Мы видим дублирование кода, но это неизбежно. Повторяющаяся часть была вынесена в функцию switch_language().

Далее отправка изображения. Изображения будут лежать в locale/имя_локали/static/имя_картинки_локаль.jpg.

lesson3.py
65@router.message(Command("photo"))
66@router.message(F.text == LazyProxy("photo"))
67async def sent_photo(message: Message, i18n: I18nContext) -> None:
68    locale_code = i18n.locale
69    path_to_photo = f"locales/{locale_code}/static/my_image_{locale_code}.jpg"
70    await message.answer_photo(photo=FSInputFile(path_to_photo))

Следующий хэндлер отвечает за обработку остальных сообщений. При этом он после ответ выдает еще и дату сообщения в формате, специфичном для локали пользователя. То есть «День Месяц Год» или «Month Day, Year».

lesson3.py
74@router.message()
75async def handler_common(message: Message, i18n: I18nContext) -> None:
76    await message.answer(text=i18n.get("i-dont-know"))
77    await message.answer(text=i18n.get("show-date", date_=message.date))

Ну и клавиатура, которую мы импортировали из aiogram_i18n.types

lesson3.py
25# Это тестовая клавиатура
26rkb = ReplyKeyboardMarkup(
27    keyboard=[
28        [KeyboardButton(text=LazyProxy("help", case="capital"))]  # or L.help()
29    ], resize_keyboard=True
30)

Текст клавиатуры будет также лениво переведен в момент отправки сообщения, когда уже язык будет известен.

Осталось сделать саму локализацию. Складываем картинки в папки локалей. А также создаем файлы переводов в формате .ftl в соответствующих папках. Логика работы описана в комментариях в каждом файле.

Английский перевод:

locales/en/LC_MESSAGES/messages.ftl
 1# This Source Code Form is subject to the terms of the Mozilla Public
 2# License, v. 2.0. If a copy of the MPL was not distributed with this
 3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
 4# Это был пример лицензии
 5
 6### Файл примера перевода на английский язык
 7### Логика перевода изменится, не затрагивая код и другие переводы
 8### С тройного шарпа начинается комментарий уровня файла
 9
10## Это комментарий уровня группировки блоков в тексте. См. документацию Fluent.
11## Hello section
12
13# Это пример термина. Термин начинается с дефиса.
14# Посмотрите как это работало в русском переводе. Здесь же мы изменим логику.
15# Падежи нам не нужны, но может потребоваться притяжательная форма
16-telegram = { $case ->
17     *[common] Telegram
18      [possessive] Telegram's
19    }
20
21# { $user } - user name, { $language } - language code.
22# Это было описание переменных, которые попадают сюда из основного кода приложения.
23# Термин мы берем из этого же файла перевода,
24# и вставляем с параметром нужного контекста использования (в нашем случае падежа).
25hello = Hi, <b>{ $user }</b>!
26    { $language ->
27     [None] In your { -telegram(case: "common") } client a language isn't set.
28            Therefore, everything will be displayed in default language.
29    *[any] Your Telegram client is set to { $language }.
30            Therefore, everything will be displayed in this language.
31    }
32
33help = { $case ->
34    *[capital] Help
35     [lower] help
36    }
37help-message =
38    <b>Welcome to the bot.</b>
39    Our bot can't do anything useful, but it can switch languages with dexterity.
40
41    The following commands are available in the bot:
42    /start to start working with the bot.
43    /help or just send the word <b><i>help</i></b> to show this message.
44    /language_en { switch-to-en }
45    /language_ru { switch-to-ru }
46    /photo or just send the word <b><i>photo</i></b> to send photo to you.
47
48
49# { $language } - language code.
50# The current language is { $language }.
51cur-lang = The current language is: <i>{ $language }</i>
52
53## Switch language section
54
55# Название языка мы отображаем на родном языке, чтоб человек
56# увидел знакомые буквы и понял, что не все потеряно.
57en-lang = English
58ru-lang = Русский
59switch-to-en = Switch the interface to { en-lang }.
60switch-to-ru = Switch the interface to { ru-lang }.
61lang-is-switched = Display language is { en-lang }.
62
63photo = photo
64
65## Common messages section
66
67i-dont-know = I'm so stupid bot. Make me clever.
68show-date = But look! Pretty date on English: { DATETIME
69   ($date_, month: "long", year: "numeric", day: "numeric", weekday: "long")
70   }

Русский перевод:

locales/ru/LC_MESSAGES/messages.ftl
 1# This Source Code Form is subject to the terms of the Mozilla Public
 2# License, v. 2.0. If a copy of the MPL was not distributed with this
 3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
 4# Это был пример лицензии
 5
 6### Файл примера перевода на русский язык
 7### Важно. Не забудь полить помидоры...
 8### С тройного шарпа начинается комментарий уровня файла
 9
10## Это комментарий уровня группировки блоков в тексте. См. документацию.
11## Hello section
12
13# Это пример термина. Термин начинается с дефиса.
14# Термины можно передавать внутри сообщений, указывая переменные для параметризации в скобках.
15# То есть это как атрибуты, но мы их задаем в тексте переводов, а не получаем извне.
16# Мы будем издеваться над языком, чтобы увидеть как и что работает
17-telegram = {$case ->
18    *[nominative] Телеграм {"{"}Telegram{"}"}
19     [genitive] Телеграма ({"{"}Telegram'а{"}"})
20     [dative] Телеграму ({"{"}Telegram'у{"}"})
21     [accusative] Телеграм ({"{"}Telegram{"}"})
22     [instrumental] Телеграмом ({"{"}Telegram'ом{"}"})
23     [prepositional] Телеграме ({"{"}Telegram'е{"}"})
24    }
25# {"}"}  это пример экранированного символа.
26# Падежи
27# nominative - именительный
28# genitive - родительный
29# dative - дательный
30# accusative - винительный
31# instrumental творительный.
32# prepositional - предложный
33
34# { $user } - user name, { $language } - language code.
35# Это было описание переменных, которые попадают сюда из основного кода приложения.
36# Термин мы берем из этого же файла перевода,
37# и вставляем с параметром нужного контекста использования (в нашем случае падежа).
38hello = Привет, <b>{ $user }</b>!
39        У тебя в клиенте { -telegram(case: "nominative") } { $language ->
40     [None] не указан язык, поэтому все будет отображается на языке по-умолчанию.
41    *[any] указан язык { $language }, поэтому все будет отображается на этом языке.
42    }
43
44# а так мы вставляем символы unicode по номеру \uHHHH. Например,
45# tears-of-joy1 = {"\U01F602"}
46# tears-of-joy2 = 😂
47
48help = { $case ->
49    *[capital] Помощь
50     [lower] помощь
51    }
52
53help-message =
54    <b>Добро пожаловать в бота.</b>
55    Наш бот не умеет ничего полезного, однако с ловкостью может переключать язык.
56
57    В боте доступны следующие команды:
58    /start чтобы начать работать с ботом
59    /help или просто отправьте слово <b><i>помощь</i></b>, чтобы показать это сообщение
60    /language_en { switch-to-en }
61    /language_ru { switch-to-ru }
62    /photo или просто отправьте слово <b><i>фото</i></b>, чтобы прислать вам картинку
63
64
65# Это комментарий подсказка для переводчиков (чтобы не искать что значат эти переменные в коде,
66# который не факт ,что они получат, а если и получат, то не поймут:
67
68# { $language } - language code.
69# The current language is { $language }.
70cur-lang = Текущий язык: <i>{ $language }</i>
71
72## Switch language section
73
74en-lang = English
75ru-lang = Русский
76switch-to-en = Переключить интерфейс на { en-lang } язык.
77
78# В фигурных скобках пример интерполяции одного сообщения в другом.
79switch-to-ru = Переключить интерфейс на { ru-lang } язык.
80lang-is-switched = Язык переключен на { ru-lang }.
81
82photo = фото
83
84## Common messages section
85
86i-dont-know = Я тупой бот. Сделай меня умным.
87show-date = Но посмотри! Красивая дата по правилам Русского языка: {
88   DATETIME($date_, month: "long", year: "numeric", day: "numeric", weekday: "long")
89   }

Основной код будет такой:

lesson3.py
  1import asyncio
  2import logging
  3from logging import basicConfig, INFO
  4from typing import Any
  5
  6from aiogram import Router, Dispatcher, F, Bot
  7from aiogram.enums import ParseMode
  8from aiogram.filters import CommandStart, Command
  9from aiogram.types import Message, FSInputFile
 10
 11from aiogram_i18n import I18nContext, LazyProxy, I18nMiddleware
 12from aiogram_i18n.cores.fluent_runtime_core import FluentRuntimeCore
 13from aiogram_i18n.types import (
 14    ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
 15    # you should import mutable objects from here if you want to use LazyProxy in them
 16    )
 17
 18from database import database
 19from database.database import Database
 20from middlewares import db_middleware
 21from middlewares import i18n_middleware
 22
 23router = Router(name=__name__)
 24
 25rkb = ReplyKeyboardMarkup(
 26    keyboard=[
 27        [KeyboardButton(text=LazyProxy("help", case="capital"))]  # or L.help()
 28    ], resize_keyboard=True
 29    )
 30
 31
 32@router.message(CommandStart())
 33async def process_start_command(message: Message, i18n: I18nContext, db: Database):
 34    if not db.get_user(message.from_user.id):
 35        db.add_user(message.from_user.id, message.from_user.username)
 36    name = message.from_user.full_name
 37
 38    await message.answer(text=i18n.hello(user=name, language=i18n.locale),# text=i18n.get("hello", user=name))
 39                       reply_markup=rkb
 40                       )
 41
 42
 43@router.message(Command("help"))
 44@router.message(F.text == LazyProxy("help", case="capital"))
 45@router.message(F.text == LazyProxy("help", case="lower"))
 46async def cmd_help(message: Message, i18n: I18nContext) -> Any:
 47    return message.reply(text=i18n.get("help-message"))
 48
 49
 50async def switch_language(message: Message, i18n: I18nContext, locale_code: str):
 51    await i18n.set_locale(locale_code)
 52    await message.answer(i18n.get("lang-is-switched"), reply_markup=rkb)
 53
 54
 55@router.message(Command("language_en"))
 56async def switch_to_en(message: Message, i18n: I18nContext) -> None:
 57    await switch_language(message, i18n,"en")
 58
 59
 60@router.message(Command("language_ru"))
 61async def switch_to_en(message: Message, i18n: I18nContext) -> None:
 62    await switch_language(message, i18n,"ru")
 63
 64
 65@router.message(Command("photo"))
 66@router.message(F.text == LazyProxy("photo"))
 67async def sent_photo(message: Message, i18n: I18nContext) -> None:
 68    locale_code = i18n.locale
 69    path_to_photo = f"locales/{locale_code}/static/bayan_{locale_code}.jpg"
 70
 71    await message.answer_photo(photo=FSInputFile(path_to_photo))
 72
 73
 74@router.message()
 75async def handler_common(message: Message, i18n: I18nContext) -> None:
 76    await message.answer(text=i18n.get("i-dont-know"))
 77    await message.answer(text=i18n.get("show-date", date_=message.date))
 78
 79
 80async def main() -> None:
 81    basicConfig(level=INFO)
 82    bot = Bot("TOKEN", parse_mode=ParseMode.HTML)
 83
 84    dp = Dispatcher()
 85    dp.include_router(router)
 86
 87    # создаем объект middleware пакета локализации aiogram_i18n
 88    i18n = I18nMiddleware(
 89        core=FluentRuntimeCore(path="locales/{locale}/LC_MESSAGES"),
 90        #  передаем наш кастомный менеджер языка из middlewares/i18n_middleware.py:
 91        manager=i18n_middleware.UserManager(),
 92        default_locale="en"
 93        )
 94
 95  # Регистрация мидлварей. Сначала регистрируется база данных, так как там хранится язык.
 96  dp.update.outer_middleware.register(db_middleware.DBMiddleware())
 97  i18n.setup(dispatcher=dp)
 98
 99  await dp.start_polling(bot)
100
101
102if __name__ == "__main__":
103    asyncio.run(main())

Запускаем, тестируем.

Исправляем ошибки.

Наш проект серьезно усложнился и нужно произвести тестирование и отладку. Вот некоторые ошибки, которые часто возникают.

aiogram_i18n.exceptions.NoTranslateFileExistsError: files with extension (.ftl) in folder (locales/ru/LC_MESSAGES) not found — ошибка возникает когда файл перевода не найден по указанному нами пути.

KeyNotFoundError: Key ‘help’ not found — ошибка возникает, когда в коде есть ключ, а в переводе его нет. Например, вызываем i18n.get("help"), а такой строчки help нет в файле перевода соответствующего языка.

fluent.runtime.errors.FluentReferenceError: Unknown external: user — такая ошибка возникает, когда вы забываете передать в вызове функции основного кода нужный аргумент для ключа или просто имеет место опечатка в имени. В нашем случае разберем на примере опечатки в переменной user. Например, в переводе есть такое сообщение:

hello = Привет, <b>{ $user }</b>!
    У тебя в клиенте { -telegram(case: "nominative") } { $language ->
    [None] не указан язык, поэтому все будет отображается на языке по-умолчанию.
   *[any] указан язык { $language }, поэтому все будет отображается на этом языке.
    }

Здесь { -telegram } – это термин. И он управляется конструкцией(case: "nominative") только внутри языкового файла. А вот дальше используются аргументы { $user } и { $language }, которые нужно передать из основного кода. Мы их передаем как именованные аргументы:

await message.answer(text=i18n.get("hello", user=name, language=i18n.locale))

или еще возможен такой способ:

await message.answer(text=i18n.hello(user=name, language=i18n.locale))

Так вот в случае, если мы неправильно указали аргумент в основном коде или вообще не указали, то во время компиляции перевода отсутствие аргумента и вызывает ошибку. Например nmae вместо name в строке:

await message.answer(text=i18n.get(«hello», user= nmae , language=i18n.locale))