Если бот рассчитан только на РФ — обычно хватает русского. Но для туристических, международных и B2B-сервисов многоязычность становится требованием. Реализовать i18n в боте можно за вечер, а можно построить устойчивую систему, которая не сломается при добавлении пятого языка. Разница — в архитектурных решениях на старте.
Эта статья — практический разбор того, как сделать многоязычный бот в мессенджере MAX от VK Tech: какие языки выбирать, где хранить переводы, какие библиотеки использовать в Python и Node, как обрабатывать множественное число, даты и валюты, как тестировать качество локализации и какие антипаттерны убивают проект на старте.
Зачем боту в MAX мультиязык
Решение о многоязычности зависит от рынка:
- Внутренний рынок РФ — обычно достаточно русского. 95% пользователей MAX — носители русского, мультиязык создаёт overhead без отдачи.
- Туристический сегмент в РФ —
ru+enдля иностранных гостей (отели, экскурсии, аренда авто, медицинский туризм). - СНГ-проекты —
ruкак основной +uz,kk,azдля локальной аудитории. Узбекистан и Казахстан — крупнейшие рынки, где знание русского у молодёжи снижается. - B2B-платформы —
ru+enобязательны, если есть зарубежные клиенты или партнёры. - Mini App с глобальной аудиторией —
ru+en+ 2-3 региональных (испанский для LATAM, арабский для MENA).
Главное правило: не добавляйте язык «на всякий случай». Каждый язык — это поддержка переводов, тестирование, документы (политика, оферта), служба поддержки. Лучше два языка отлично, чем десять плохо.
Стратегии выбора языка
Есть три рабочие стратегии определения языка пользователя:
- Автоопределение по locale в MAX. Bot API передаёт код языка интерфейса MAX в payload (
user.language_code). Это первая подсказка: если у пользователя интерфейс на русском, начинаем с русского. Минус — пользователь может жить в РФ, но предпочитать английский. - Ручной выбор при первом контакте. На
/startпоказываем кнопки с флагами —Русский 🇷🇺,English 🇬🇧,Oʻzbek 🇺🇿. Сохраняем выбор в профиле. Плюс — точно, минус — лишний шаг для пользователя. - Дефолт + переключатель. Стартуем на основном языке (обычно
ru), а в меню добавляем команду/language. Универсальный подход для проектов с одним основным языком и поддержкой 1-2 дополнительных.
Лучшая практика — комбинация: автоопределение по locale, но с возможностью переключиться через /language или кнопку в меню профиля. Сохраняем выбор в Postgres в поле users.locale, чтобы при следующем заходе не переспрашивать.
Определение языка пользователя
Bot API передаёт код языка пользователя в поле локали. Это первая подсказка: если у пользователя в MAX интерфейс на русском, скорее всего, он хочет общаться на русском.
Дополнительные сигналы:
- Явный выбор через команду
/languageили кнопку. - IP-геолокация (если она доступна на бэкенде).
- История прошлых сообщений (детектор языка).
Лучшая практика — сохранять выбор языка в профиле пользователя в Postgres. При первом контакте — детектируем, при сомнениях — спрашиваем кнопками с флагами.
# Получаем локаль из update и резолвим к поддерживаемому языку
SUPPORTED = {"ru", "en", "uz", "kk"}
DEFAULT = "ru"
async def resolve_locale(user_id: int, max_locale: str | None) -> str:
saved = await db.fetch_user_locale(user_id)
if saved in SUPPORTED:
return saved
if max_locale and max_locale[:2] in SUPPORTED:
await db.set_user_locale(user_id, max_locale[:2])
return max_locale[:2]
return DEFAULT
Хранение переводов
Самые частые форматы:
- JSON-файлы по языку:
locales/ru.json,locales/en.json. - YAML — удобнее редактировать, поддерживает многострочные тексты.
- gettext (.po) — стандарт для серверной разработки, подходит, если переводы делает агентство.
- БД с админкой — если перевод правят нетехнические люди.
- SaaS (Crowdin, Lokalise, Transifex) — для команд, где переводчики работают параллельно с разработчиками; есть автоматический pull через CLI.
Для бота на 2-3 языка хватает JSON. Для 5+ — лучше БД или хотя бы Crowdin/Lokalise с экспортом в JSON.
| Формат | Когда использовать | Плюсы | Минусы |
|---|---|---|---|
| JSON | 2-3 языка, разработка in-house | Просто, диффы читаемые | Нет multiline, нет комментариев |
| YAML | Контент-heavy, есть длинные тексты | Multiline, комментарии | Чувствителен к отступам |
| gettext | Перевод агентством | Стандарт, есть тулинг | Бинарный .mo, тяжелее в Git |
| БД + админка | Контент правят нетехнические люди | Релиз без деплоя | Нужна админка и миграции |
| Crowdin/Lokalise | 5+ языков, команда переводчиков | Параллельная работа, TM | Стоимость от $50/мес |
i18n-библиотеки
Под Python:
- Babel — де-факто стандарт, поддерживает CLDR (даты, числа, валюты, plural).
- GetText — встроен в стандартную библиотеку (
gettext+locale), хорош для серверов на Linux. - FluentPython (fluent.runtime) — современный формат от Mozilla с условиями и селекторами.
Под Node:
- i18next — самый популярный, плагины для backend/frontend, namespaces, lazy loading.
- FormatJS (intl-messageformat) — реализация ICU MessageFormat от Yahoo, отлично с React.
- lingui — компилируемые сообщения, типобезопасность.
Под Go: nicksnyder/go-i18n (поддерживает CLDR plural). Под Java: ResourceBundle + ICU4J.
Структура файлов и ключей
Плоские ключи welcome_message плохо масштабируются. Лучше иерархия:
# locales/ru.yaml
booking:
start: "Запись на услугу"
ask_service: "Какую услугу выбираем?"
ask_date: "Выберите дату"
confirmed: "Запись подтверждена на {date} в {time}"
errors:
invalid_phone: "Телефон в формате +7..."
generic: "Что-то пошло не так"
nav:
back: "Назад"
cancel: "Отмена"
next: "Далее"
Это снимает конфликты между похожими формулировками и упрощает ревью переводов. Структура папки:
src/
i18n/
locales/
ru.json
en.json
uz.json
kk.json
index.ts
plurals.ts
Параметры и подстановки
Выберите один формат плейсхолдеров и придерживайтесь везде:
{name}— стандарт ICU и большинства Node-библиотек.%{name}— Ruby i18n, Babel.{{name}}— Mustache/Handlebars-стиль (i18next по умолчанию).{0},{1}— позиционные, формат Python.format().
Рекомендую {name} — его понимают почти все парсеры, и он хорошо читается переводчиками.
// i18next init
import i18next from 'i18next';
import Backend from 'i18next-fs-backend';
await i18next.use(Backend).init({
lng: 'ru',
fallbackLng: 'ru',
supportedLngs: ['ru', 'en', 'uz', 'kk'],
interpolation: { prefix: '{', suffix: '}' },
backend: { loadPath: './locales/{lng}.json' }
});
// Использование в handler
bot.command('start', async (ctx) => {
const locale = await resolveLocale(ctx.from.id, ctx.from.language_code);
const t = i18next.getFixedT(locale);
await ctx.reply(t('greeting', { name: ctx.from.first_name }));
});
Pluralization по CLDR
Простая подстановка Привет, {name}! работает везде. Сложнее с plural forms: «1 запись», «2 записи», «5 записей». В русском три формы (one, few, many), в английском — две (one, other), в арабском — шесть.
Без библиотеки с CLDR-правилами вы получите «1 записей» или «5 запись» — и доверие к боту улетает в ноль, особенно в коммерческих сценариях с числами.
# locales/ru.po (gettext-стиль)
msgid "appointments_count"
msgid_plural "appointments_count"
msgstr[0] "{count} запись"
msgstr[1] "{count} записи"
msgstr[2] "{count} записей"
Или ICU MessageFormat (i18next, FormatJS):
{count, plural,
=0 {нет записей}
one {# запись}
few {# записи}
many {# записей}
other {# записи}
}
В Python — babel, Format.JS. В Go — nicksnyder/go-i18n. В Node — i18next или formatjs.
Даты, числа, валюты
«15.05.2026» в РФ и «5/15/2026» в США — разные форматы одной и той же даты. Полагаться на ручное форматирование нельзя — нужна локаль:
- Даты —
Intl.DateTimeFormat(Node),babel.dates(Python),time.Formatс локалью (Go). - Числа — разделители тысяч и десятичных.
1,000.50vs1 000,50. - Валюты — символ, позиция, число знаков после запятой.
Для бота на многоязычной аудитории это важно: «1,500» в немецком интерфейсе — это полтора, а не полторы тысячи.
// Локализованное форматирование
const date = new Date('2026-05-15T14:30:00');
new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long', timeStyle: 'short' }).format(date);
// "15 мая 2026 г. в 14:30"
new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeStyle: 'short' }).format(date);
// "May 15, 2026 at 2:30 PM"
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(1500.50);
// "1 500,50 ₽"
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1500.50);
// "$1,500.50"
RTL-языки и Mini App
Если в плане арабский (ar), иврит (he) или фарси (fa) — нужно поддержать right-to-left. Для текстовых сообщений бота особой работы нет: MAX-клиент сам отрисует RTL-текст справа налево. Но для Mini App придётся:
- Установить
dir="rtl"на<html>при выборе RTL-локали. - Зеркалить иконки направления (стрелки, breadcrumbs).
- Использовать logical properties:
margin-inline-startвместоmargin-left. - Тестировать в реальном RTL-клиенте, не на Chrome с переключателем.
Динамическая загрузка vs предзагрузка
Два подхода:
- Предзагрузка всего — на старте бота читаем все языки в память. Быстро в runtime, но при 10 языках и больших файлах — заметный RAM (50-200 МБ).
- Lazy load по запросу — загружаем JSON только при первом обращении к языку. Экономит память, но первое сообщение пользователю чуть медленнее.
Для бота с 2-5 языками — предзагрузка проще и предсказуемее. Для платформы с десятками языков (e-commerce, путешествия) — lazy load с LRU-кешем.
Версионирование переводов
Когда меняется текст в коде — нужно обновить переводы во всех языках. Иначе пользователь увидит старую формулировку в uz и новую в ru.
Подходы:
- Хеш ключа —
welcome_v2вместоwelcomeпосле изменения. Грубо, но работает. - Версии в YAML — поле
version: 2рядом с ключом, скрипт ловит несовпадения. - Флаги «требует перевода» в Crowdin/Lokalise — при изменении исходника помечается во всех языках.
CI-скрипт прогоняет diff locales/ru.json против остальных языков и падает, если есть пропуски.
Статичный vs динамический контент
Текст бота бывает двух видов:
- Статичный — приветствия, меню, ошибки, описания услуг. Локализуется через i18n-файлы.
- Динамический — имена товаров, описания категорий, отзывы. Локализуется через таблицы в БД с переводами.
-- Таблица переводов товаров
CREATE TABLE product_translations (
product_id BIGINT REFERENCES products(id),
locale VARCHAR(8),
name TEXT NOT NULL,
description TEXT,
PRIMARY KEY (product_id, locale)
);
Запрос вида SELECT name FROM product_translations WHERE product_id = $1 AND locale = $2 с fallback на ru при отсутствии перевода. Не смешивайте статичный и динамический контент в одной системе — это путаница.
AI-перевод как черновик
Для bootstrap-перевода нового языка можно использовать:
- DeepL — лучшее качество для европейских языков, есть API.
- Yandex Translate — лучший по русскому и языкам СНГ (узбекский, казахский, азербайджанский).
- Google Translate — широкий охват, посредственное качество для русского.
- GPT-4 / Claude — хорошо понимают контекст, дороже, но для маркетинговых текстов лучше machine translation.
| Сервис | Языки СНГ | Качество RU | Цена за 1М символов |
|---|---|---|---|
| Yandex Translate | Отлично | — | $5 |
| DeepL | Нет UZ/KK | Хорошо | $20 |
| Google Translate | Хорошо | Средне | $20 |
| Claude / GPT-4 | Отлично | Отлично | $30-100 |
Важно: AI-перевод — это черновик. Для production обязательна корректура native-speaker, особенно для деловых текстов, юридических документов и UI с короткими строками (там часто нужен другой порядок слов).
Контроль качества переводов
Чек-лист перед релизом нового языка:
- Native-speaker review — носитель проходит сценарий бота от начала до конца.
- Тесты на длину — немецкий длиннее русского на 30%, кнопки могут не влезть.
- Capitalization — в немецком существительные с заглавной, в английском — заголовки в Title Case.
- Bad-words filter — отдельный словарь «слов-минусов» на каждый язык.
- Тесты подстановок —
{name},{count},{date}должны быть на месте во всех переводах. - Линтер на пропущенные ключи — i18next-parser, react-intl-extract.
# Тест на покрытие ключей
import json, sys
def collect_keys(d, prefix=""):
keys = set()
for k, v in d.items():
full = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
keys |= collect_keys(v, full)
else:
keys.add(full)
return keys
base = json.load(open("locales/ru.json", encoding="utf-8"))
base_keys = collect_keys(base)
for lang in ["en", "uz", "kk"]:
other = json.load(open(f"locales/{lang}.json", encoding="utf-8"))
diff = base_keys - collect_keys(other)
if diff:
print(f"{lang}: missing {len(diff)} keys")
sys.exit(1)
Метрики использования языков
После запуска нужно понять, какие языки реально используются. Что мерить:
- Распределение пользователей по locale — сколько % на каждом языке.
- Конверсия по языкам — может оказаться, что en-аудитория конвертит хуже из-за плохого перевода.
- Жалобы и тикеты — количество жалоб «непонятный перевод» на язык.
- Retention — удержание по локалям. Если en-пользователи уходят после первой сессии, проблема в локализации.
-- Распределение по языкам
SELECT locale, COUNT(*) AS users, COUNT(*) * 100.0 / SUM(COUNT(*)) OVER () AS pct
FROM users
GROUP BY locale
ORDER BY users DESC;
Если язык даёт меньше 1% и не растёт — стоит подумать о его удалении. Поддержка стоит дорого, а аудитории нет.
Переключатель языка в боте MAX
Команда /language должна работать в любой момент. Хорошая практика — показывать inline-кнопки с флагами и нативным названием языка (не «Английский», а «English»):
async def cmd_language(message: Message):
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🇷🇺 Русский", callback_data="lang:ru")],
[InlineKeyboardButton(text="🇬🇧 English", callback_data="lang:en")],
[InlineKeyboardButton(text="🇺🇿 Oʻzbek", callback_data="lang:uz")],
[InlineKeyboardButton(text="🇰🇿 Қазақша", callback_data="lang:kk")],
])
t = i18n.t(user.locale)
await message.answer(t("language.choose"), reply_markup=keyboard)
async def on_language_change(call: CallbackQuery):
new_locale = call.data.split(":")[1]
await db.set_user_locale(call.from_user.id, new_locale)
t = i18n.t(new_locale)
await call.message.edit_text(t("language.changed"))
После смены локали обязательно обновляем меню команд через setMyCommands с language_code, чтобы пользователь увидел перевод и в системном UI MAX.
Антипаттерны
- Хардкод строк в коде (
bot.send("Привет!")) — даже на старте проекта. - Один JSON на все языки в виде дерева
{key: {ru: ..., en: ...}}— масштабируется плохо. - Сборка переводов через машинный перевод без ревью — для делового контекста подведёт.
- Игнорирование RTL (арабский, иврит) — если планируется такая аудитория.
- Конкатенация строк типа
"Hello, " + name + "!"— невозможно перевести правильно (порядок слов). - Пропуск тестов на длину — кнопки на немецком могут не влезть.
Итого
Многоязычность в боте MAX — это не «добавить три файла», а архитектурное решение. Правильный набор: язык в профиле пользователя, иерархические ключи, библиотека с поддержкой ICU MessageFormat, локали для дат и чисел, отдельные документы для каждого языка, метрики использования. Сделанный с этими основами бот спокойно растёт от двух до десяти языков без переписывания.
Частые вопросы
Как определить язык пользователя в боте MAX?
Bot API передаёт код языка в поле локали — это первая подсказка. Если у пользователя в MAX интерфейс на русском, скорее всего, он хочет общаться на русском. Дополнительные сигналы: явный выбор через команду /language или кнопку, IP-геолокация (если доступна на бэкенде), история прошлых сообщений (детектор языка). Лучшая практика — сохранять выбор языка в профиле пользователя в PostgreSQL. При первом контакте детектируем, при сомнениях спрашиваем кнопками с флагами. Не пытайтесь угадать язык по каждому сообщению — это создаёт скачки интерфейса.
В каком формате хранить переводы для бота MAX?
Самые частые форматы. JSON-файлы по языку (locales/ru.json, locales/en.json) — для бота на 2-3 языка хватает. YAML — удобнее редактировать, поддерживает многострочные тексты. Gettext (.po) — стандарт для серверной разработки, подходит, если переводы делает агентство. БД с админкой — если перевод правят нетехнические люди. Для 5+ языков лучше БД или хотя бы Crowdin/Lokalise с экспортом в JSON. Структура ключей — иерархия (booking.start, errors.invalid_phone), а не плоские имена, иначе масштабируется плохо.
Как обработать множественное число в переводах бота?
Через ICU MessageFormat. Простая подстановка Привет, {name}! работает везде. Сложнее с plural forms: «1 запись», «2 записи», «5 записей». В русском три формы (one, few, many), в английском — две (one, other). Нативные библиотеки i18n умеют это: в Python — babel, Format.JS; в Go — nicksnyder/go-i18n; в Node — i18next или formatjs. Без ICU MessageFormat пользователю показывают «1 записей» или «5 запись» — это убивает доверие к боту, особенно в коммерческих сценариях с числами.
Как форматировать даты, числа и валюты в многоязычном боте?
Через локализацию. «15.05.2026» в РФ и «5/15/2026» в США — разные форматы одной даты. Полагаться на ручное форматирование нельзя — нужна локаль. Даты — Intl.DateTimeFormat (Node), babel.dates (Python), time.Format с локалью (Go). Числа — разделители тысяч и десятичных: 1,000.50 в США vs 1 000,50 в Европе. Валюты — символ, позиция, число знаков после запятой. «1,500» в немецком интерфейсе — это полтора, а не полторы тысячи. Без правильной локализации цены отображаются неверно, что приводит к спорам.
Что делать, если пользователь пишет на другом языке, чем его профиль?
Безопасный подход — не угадывать. Варианты: проигнорировать и продолжить на текущем языке, запустить детектор языка и спросить «вы хотите переключить интерфейс?», использовать встроенный в Bot API язык клиента (если изменился). Самый рабочий — не угадывать автоматически, а дать явную команду переключения через /language или кнопку. Иначе интерфейс будет скакать между языками, что путает пользователя. Документы (политика конфиденциальности, оферта) — отдельные на каждом языке; бот показывает соответствующую версию, иначе акцепт недействителен.
Можно ли использовать AI-перевод (DeepL, Yandex) для бота?
Можно, но как черновик. Yandex Translate — лучший для языков СНГ (узбекский, казахский, азербайджанский). DeepL — лучшее качество для европейских языков. Google Translate — широкий охват, среднее качество. GPT-4/Claude — хорошо понимают контекст и подходят для маркетинговых текстов. Для production обязательна корректура native-speaker, особенно для деловых текстов, юридических документов (политика, оферта) и UI с короткими строками — там часто нужен другой порядок слов, чем выдаёт MT. Релиз без ревью носителем рискован.
Как тестировать многоязычность бота?
Автоматизированно через линтеры. Тесты i18n проверяют: все ключи покрыты во всех локалях (нет «торчащих» английских строк в немецком интерфейсе), нет лишних ключей от старых сценариев, подстановки работают для всех форм plural, длина переводов не ломает интерфейс (немецкий обычно длиннее русского на 30%). Линтеры типа i18n-tasks (Ruby), i18next-parser (Node) или собственные скрипты на CI закрывают это автоматически. Антипаттерны: хардкод строк, конкатенация "Hello, " + name, игнорирование RTL для арабского/иврита, машинный перевод без ревью.