Legan Studio
Все статьи
~ 13 мин чтения

Многоязычность бота в MAX: как организовать i18n

Как сделать многоязычный бот в MAX: выбор языка, хранение переводов, форматы дат и чисел, ловушки при работе с пользовательским контентом.

  • MAX
  • архитектура
  • разработка

Если бот рассчитан только на РФ — обычно хватает русского. Но для туристических, международных и 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).

Главное правило: не добавляйте язык «на всякий случай». Каждый язык — это поддержка переводов, тестирование, документы (политика, оферта), служба поддержки. Лучше два языка отлично, чем десять плохо.

Стратегии выбора языка

Есть три рабочие стратегии определения языка пользователя:

  1. Автоопределение по locale в MAX. Bot API передаёт код языка интерфейса MAX в payload (user.language_code). Это первая подсказка: если у пользователя интерфейс на русском, начинаем с русского. Минус — пользователь может жить в РФ, но предпочитать английский.
  2. Ручной выбор при первом контакте. На /start показываем кнопки с флагами — Русский 🇷🇺, English 🇬🇧, Oʻzbek 🇺🇿. Сохраняем выбор в профиле. Плюс — точно, минус — лишний шаг для пользователя.
  3. Дефолт + переключатель. Стартуем на основном языке (обычно 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.

ФорматКогда использоватьПлюсыМинусы
JSON2-3 языка, разработка in-houseПросто, диффы читаемыеНет multiline, нет комментариев
YAMLКонтент-heavy, есть длинные текстыMultiline, комментарииЧувствителен к отступам
gettextПеревод агентствомСтандарт, есть тулингБинарный .mo, тяжелее в Git
БД + админкаКонтент правят нетехнические людиРелиз без деплояНужна админка и миграции
Crowdin/Lokalise5+ языков, команда переводчиковПараллельная работа, 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.50 vs 1 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 для арабского/иврита, машинный перевод без ревью.