Глава 7. Динамическое определение и переключение языка в Aiogram
Практический пример
Теперь мы знаем, как проводится интернационализация и локализация продукта. Но нам бы хотелось это все внедрить в интерфейс нашего приложения на фреймворке Aiogram, чтобы повысить качество UX (user experience).
Пример будет реализован с помощью Fluent, поскольку gettext уже изучен и по факту является промышленным стандартом. А нам хочется попробовать что-то новое.
Спроектируем взаимодействие с пользователем следующим образом.
Если пользователь впервые начал взаимодействовать с ботом, то мы не знаем, на каком языке он говорит. Попытаемся получить язык пользователя из апдейта. Но эта опция зависит от клиента Telegram, которым пользуется пользователь, и часто это поле не заполнено. В таком случае отдадим какой-то язык по умолчанию, например, английский.
Если пользователь выбрал в меню бота или отправил команду выбора языка, то сохраним язык в базе данных и переключим язык. В дальнейшем, при взаимодействии, будем использовать язык из базы данных.
Нам нужно сделать переводы для всех элементов интерфейса, включая сообщения, клавиатуры, меню, алерты.
В каких-то случаях, при отправке картинок или документов, необходимо локализовать картинки или документы.
В учебных целях мы реализуем следующие вещи:
Наш бот будет стартовать по команде
/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
. Это базовый абстрактный
класс для наследования и создания собственного обработчика.
Создадим в нашем приложении объект этого класса, и передадим туда наш кастомный менеджер, который реализуем ниже.
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
.
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.
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
и сохраняет пользователя
в БД. Язык нам не известен, поэтому его мы не сохраняем.
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
.
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"))
Создадим хэндлер для команды обработки смены языка.
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
.
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».
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
25# Это тестовая клавиатура
26rkb = ReplyKeyboardMarkup(
27 keyboard=[
28 [KeyboardButton(text=LazyProxy("help", case="capital"))] # or L.help()
29 ], resize_keyboard=True
30)
Текст клавиатуры будет также лениво переведен в момент отправки сообщения, когда уже язык будет известен.
Осталось сделать саму локализацию. Складываем картинки в папки локалей. А также создаем файлы переводов в формате .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 }
Русский перевод:
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 }
Основной код будет такой:
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))