Пользователь открывает бота, переключает язык с русского на английский, и все кнопки послушно переписываются. Через час приходит напоминание: текст уже английский, а время в нём по-прежнему московское, хотя человек сидит в Лиссабоне. Бот не сломан. Он просто честно выполнил то, что ему велели: язык поменялся, а про часовой пояс его никто не спрашивал. Это две независимые настройки, и большая часть ошибок в многоязычных ботах растёт из попытки склеить их в одну.
Ниже разберём, как развести эти вещи: что хранить в профиле, откуда брать язык по умолчанию, как устроить переводы и меню команд, как провести одно событие через UTC и показать его двум людям в разных зонах, что делать с неоднозначными часами при переводе стрелок и как всё это связано с тем, на чём бот запущен.
Две настройки, которые не родственники
Язык отвечает на вопрос «на каком наречии говорить с человеком». Часовой пояс отвечает на вопрос «в какой точке мира он находится относительно календаря». Русскоязычный пользователь живёт в Алматы, Тбилиси, Калининграде или Берлине; англоязычный сидит в Сиднее и в Нью-Йорке. Вывести одно из другого нельзя: язык не говорит ничего о зоне, а зона ничего не говорит о предпочитаемом языке интерфейса. Поэтому в таблице пользователей это два отдельных поля, у каждого своя история заполнения.
Минимальная схема выглядит так:
ui_language— выбранный язык интерфейса, может быть пустым;timezone— название зоны из базы IANA, напримерEurope/Lisbon, тоже может быть пустым;language_source— откуда взято значение: «выбрал сам» или «предположили по данным Telegram»;- при желании
timezone_sourceпо тому же принципу.
Пометка об источнике нужна затем, что у значений разный вес. Явный выбор человека — это факт. Предположение по language_code из профиля Telegram — это догадка, причём разумная только для первого знакомства. Если пользователь однажды нажал «English», бот больше не должен пересматривать это решение, даже когда в очередном апдейте придёт другой language_code. Приоритет простой: явная настройка, затем предположение по Telegram, затем язык по умолчанию самого бота.
Как читать language_code
Поле language_code в данных пользователя удобно использовать ровно один раз — при первом обращении, чтобы выбрать стартовый язык. Значение приходит в виде языкового тега, иногда с регионом, и его нужно нормализовать: привести к нижнему регистру, отрезать регион, сопоставить со списком языков, которые бот реально умеет. Тег, которого в списке нет, не повод падать: пользователь получает запасной язык. А дальше всё решает сохранённое значение в вашей базе. Вторая роль language_code — подсказка в экране настроек: «Telegram предлагает русский, оставить?». Но это уже подсказка, а не команда.
Переводы: небольшие, но дисциплинированные
Каталог переводов бота — это словарь ключей и строк на каждый язык. По объёму это ничтожно мало: сотни строк, пара килобайт на язык. Ресурсов он не просит, зато просит дисциплины. Три правила спасают от большинства неприятностей.
- В коде только ключи. Вместо «Напоминание создано» в обработчике стоит
reminder.created. Текст подставляется на выходе по языку получателя. - Запасной язык определён заранее. Если ключа нет в переводе на выбранный язык, бот берёт строку из запасного, а не показывает пользователю сырое
reminder.created. Лучше английская фраза среди польских, чем технический идентификатор. - Переменные не склеиваются из кусков. Строка «Через {n} дней» в русском требует разных форм для 1, 2 и 5; в английском хватает двух форм. Поэтому целую фразу с числом переводят целиком, а правила множественного числа живут в самом переводе, а не в коде бота.
Отдельно стоит сказать про содержимое, которое набирает пользователь. Если человек написал напоминание «купить молоко», бот не обязан и не должен пытаться его переводить: это не строка интерфейса, а данные. Смена языка меню не превращает «купить молоко» в «buy milk», и не нужно обещать такого в описании бота. Переводится то, что сказал бот; то, что сказал человек, остаётся как есть.
Меню команд живёт по своим правилам
Список команд в меню Telegram — отдельный объект, не связанный с каталогом переводов вашего бота. Согласно документации Bot API, набор команд выбирается по области действия и language_code, а если подходящего набора нет, происходит переход к более общим наборам. Практический вывод: достаточно задать общий набор без указания языка (он станет запасным) и добавить локализованные наборы для тех языков, где есть что показать.
Это важно по двум причинам. Во-первых, описания команд, которые видит человек в меню, зависят от языка клиента Telegram, а не от вашего поля ui_language. Человек, выбравший в боте английский при русском интерфейсе Telegram, увидит русские описания команд, если вы задали для русского отдельный набор. Это нормальное расхождение, и его стоит принять как данность: меню принадлежит Telegram, а вы лишь предоставляете наборы. Во-вторых, из-за запасного перехода не нужно дублировать одинаковые описания: язык без своего набора получит общий.
Названия самих команд (/settings, /remind) разумно держать одинаковыми для всех языков — иначе поддержка превратится в зоопарк. Переводятся только описания. А все внутренние сообщения бота после нажатия команды уже говорят на языке из вашего профиля. Так меню и ответы бота могут на время разойтись, и это решается одной подсказкой в экране настроек, а не усложнением логики.
Одно событие, две зоны, один момент в UTC
Теперь о времени. Правило, на котором держится всё остальное: момент события хранится в UTC, а локальные часы появляются только при показе. Возьмём условный сценарий с двумя пользователями. Анна живёт в Москве и выбрала зону Europe/Moscow. Жуан живёт в Лиссабоне и выбрал Europe/Lisbon. Они договорились о созвоне, и бот создал общее напоминание.
Допустим, Анна ввела «15 января, 18:00» по своим часам. Бот переводит это в момент на оси времени и записывает в базу как due_at = 2027-01-15 15:00:00+00:00 (в январе Москва на три часа впереди UTC). Теперь тот же due_at показывается каждому в его зоне:
| Поле | Значение |
|---|---|
due_at в базе | 2027-01-15 15:00 UTC |
Показано Анне (Europe/Moscow) | 15 января, 18:00 |
Показано Жуану (Europe/Lisbon) | 15 января, 15:00 |
| Язык сообщения Анне | из её ui_language |
| Язык сообщения Жуану | из его ui_language |
Обратите внимание: строка в базе одна, она не знает, ни кто на неё смотрит, ни на каком языке. Отправка наступает, когда текущий момент достигает due_at, и в этот миг для каждого получателя собирается своё сообщение: зона превращает UTC в локальные часы, язык выбирает шаблон и формат даты. Анна может завтра переключить бота на английский, и due_at не шелохнётся.
В Python для этого есть модуль zoneinfo: он опирается на базу IANA и учитывает переходы часовых поясов, а данные берутся из системы либо из пакета tzdata. Из этого следуют две практические мелочи. Название зоны хранится строкой IANA (Europe/Lisbon), а не смещением вроде «+0»: смещение не знает, что весной оно изменится. И на сервере, где мало системных данных (минимальный образ, контейнер без полной базы), пакет tzdata нужно поставить явно, иначе поиск зоны упадёт именно тогда, когда придёт первый пользователь с экзотической зоной.
Когда часы повторяются или исчезают
Пока всё происходит в январе, хватает арифметики на смещениях. Сложности начинаются у границы перехода на летнее или зимнее время. Для зон, где такие переходы есть, локальное время бывает двух неприятных видов.
- Пропавшее время. Весной часы перескакивают вперёд, и некоторых локальных значений просто не существует. Пользователь пишет «в 01:30», а такого момента в этот день в его зоне нет.
- Повторяющееся время. Осенью часы откатываются назад, и одно и то же локальное значение случается дважды. «01:30» — это какое из двух?
zoneinfo умеет различать такие случаи: у объекта времени есть признак, к какому из двух вхождений относится значение (параметр fold). Но решать, что показать человеку, придётся вам, потому что правильного ответа в самих данных нет. Рабочие правила, которые не заставляют пользователя читать лекцию о календаре:
- Для повторяющегося времени берём первое вхождение и в подтверждении пишем полную форму: дата, часы и смещение от UTC. Тогда человек видит, что именно запомнил бот.
- Для пропавшего времени не угадываем молча. Бот отвечает: «Такого времени в этот день нет из-за перевода часов, выберите 03:30 или 04:00».
- Для регулярных напоминаний вида «каждый день в 09:00» хранится не только
due_atближайшего срабатывания, но и правило: локальные часы плюс зона. Следующее срабатывание вычисляется заново из локальных 09:00, и оно остаётся девяти часами утра по стене, даже когда смещение от UTC изменилось.
Последний пункт принципиален. Если для ежедневного напоминания просто прибавлять 24 часа к прошлому due_at, после перехода оно сдвинется на час относительно стенных часов. Разовое событие — это точка в UTC; повторяющееся — это правило в локальном времени.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Что происходит, когда человек меняет зону
Теперь самый коварный сценарий. Жуан улетел в Нью-Йорк и поменял зону на America/New_York. Что должно случиться с напоминаниями, которые уже стоят в очереди? Ответ зависит от вида задания, и ошибиться можно в обе стороны.
Разовое событие «созвон с Анной 15 января в 15:00 UTC» привязано к моменту. Жуан по-прежнему будет на нём, просто увидит 10:00 вместо 15:00. Поэтому due_at не меняется, меняется только отображение. Другое дело — личное напоминание «выпить таблетку в 09:00». Оно привязано не к моменту, а к стенным часам: после переезда человек хочет таблетку в 09:00 по нью-йоркскому времени. Значит, для таких заданий нужно пересчитать due_at из сохранённых локальных часов и новой зоны.
Чтобы не угадывать, в записи напоминания хранится вид привязки: anchor = absolute для событий, у которых зафиксирован момент, и anchor = wall_clock для заданий, привязанных к местным часам. При смене зоны бот пробегает по активным заданиям человека: у absolute ничего не трогает, у wall_clock пересчитывает due_at. А в экране настроек перед сохранением честно говорит, сколько напоминаний сдвинется. Пользователь не должен узнавать об этом по будильнику в три часа ночи.
Очередь напоминаний и сервер под ней
Теперь посмотрим, чем всё это питается. Переводы — это крошечный каталог; он не определяет выбор сервера. Измеримыми величинами здесь являются другие: сколько заданий ожидает отправки, сколько записей накопилось в истории отправок и как часто планировщик просыпается. Именно под них и стоит подбирать ресурсы.
Возьмём условное небольшое расписание. В таблице reminders лежат несколько тысяч ожидающих заданий, у каждого поля user_id, due_at (UTC), anchor, rule, status. Рядом таблица deliveries с историей: что, кому и когда отправлено, чем закончилось. Состояния в очереди простые:
| status | Что значит | Что дальше |
|---|---|---|
pending | ждёт своего due_at | планировщик берёт его, когда момент наступил |
sending | взято в работу | после ответа API — sent или retry |
retry | отправка не удалась | повтор с новым due_at |
sent | доставлено | для повторяющихся создаётся следующее задание |
Планировщик — это обычный процесс, который раз в короткий промежуток выбирает задания со status = pending и due_at не позже текущего момента, помечает их как sending и передаёт отправщику. Индекс по паре (status, due_at) делает эту выборку дешёвой, а не полным проходом по таблице. Так расписание на тысячи заданий не требует ничего, кроме терпеливого процесса и базы на диске.
Отсюда и ресурсный вывод. Диск нужен под расписание и историю отправок: deliveries растёт всё время, и её либо архивируют, либо чистят по сроку. Оперативная память нужна процессу бота и базе. Процессорные ядра пригождаются, когда параллельно работают приём апдейтов, планировщик и отправщики. Для старта хватает самой скромной конфигурации, а рост очереди видно по измеримым вещам: сколько заданий висит в pending, насколько запаздывает отправка относительно due_at, как быстро растёт история. На VPS MAATRIX с NVMe-хранилищем и root-доступом можно поставить базу, процесс бота и планировщик рядом и самому решать, как запускать их под менеджером процессов. Какая из конфигураций линейки понадобится — Ba с 1 vCPU и 2 ГБ RAM или что-то крупнее — подскажет замер вашей очереди, а не число пользователей в рекламе.
Чего ядра не отменяют
Один момент стоит держать в голове, чтобы не покупать лишнего. Если в 09:00 одновременно наступает срок у нескольких тысяч заданий, узким местом станет не процессор, а скорость, с которой Telegram принимает сообщения от бота. Эти лимиты внешние: они не зависят от количества vCPU на вашем сервере, и добавление ядер не заставит их исчезнуть. Поэтому планировщик должен уметь сглаживать пик: брать задания порциями, отправлять в разумном темпе, при отказе переводить задание в retry с новым due_at и не терять при этом запись о попытке. Здесь же полезно размазывать массовые напоминания по нескольким минутам, если точность до секунды не нужна. Более мощный сервер ускорит подготовку сообщений, но отправку не ускорит сверх допустимого внешней стороной.
Про рассылки на большую аудиторию и связанные с ними заботы подробнее — в материале про бота для рассылки по подписчикам в Telegram: напоминания по расписанию устроены похоже, только получатель у каждого задания свой.
Сборка сообщения в момент отправки
Теперь соберём всё вместе на примере Жуана. Планировщик достаёт задание: due_at = 2027-01-15 15:00 UTC, user_id Жуана. Для отправки нужно три шага.
- Прочитать из профиля
timezoneиui_languageна момент отправки, а не на момент создания задания. Если человек успел поменять настройки, он должен получить сообщение в новых. - Перевести
due_atв его зону:Europe/Lisbonдаёт 15:00, а после смены зоны наAmerica/New_Yorkтот же момент даст 10:00. - Взять шаблон по ключу и языку, при отсутствии строки — запасной язык, подставить локализованную дату и отправить.
Ничего из этого не записывается в due_at: итоговый текст не хранится, а собирается заново. Единственное, что нужно сохранить, — запись в deliveries о факте отправки: когда и что именно вышло. Это нужно, чтобы при разборе жалобы «мне пришло не то время» можно было увидеть, какая зона и какой язык были применены. Для этого в запись истории стоит класть значения timezone и ui_language, использованные при сборке.
Экран настроек, где всё встаёт на места
Теперь вернёмся к тому, с чего начали: человек меняет язык, а время остаётся прежним. Именно так и должен вести себя хороший экран настроек. Он состоит из двух независимых строк.
Первая строка — «Язык». Показывает текущее значение и, если оно взято по догадке, подпись вроде «выбран автоматически». Нажатие открывает список поддерживаемых языков. После выбора сохраняется ui_language с пометкой «выбрал сам», экран тут же перерисовывается на новом языке, а в очереди не меняется ничего: ни один due_at не сдвигается, потому что язык не имеет отношения к моменту события.
Вторая строка — «Часовой пояс». Показывает зону и текущие местные часы: «Europe/Lisbon, сейчас 14:20». Это самая надёжная проверка для человека: он сам видит, правильно ли бот понимает, который час. Смена зоны — отдельный шаг: бот предлагает выбрать город или регион, сохраняет название IANA и перед применением сообщает: «Разовые события останутся на тех же моментах, а напоминания «в 09:00» перейдут на новую зону — их сдвинется три». После подтверждения пересчитываются только задания с привязкой к местным часам.
Если свести правила в одну табличку, получится короткая и проверяемая логика:
| Действие | Текст интерфейса | Отображаемые часы | due_at разовых событий | due_at заданий «по местным часам» |
|---|---|---|---|---|
| Смена языка | меняется | формат даты по языку, зона та же | не меняется | не меняется |
| Смена зоны | не меняется | пересчитываются | не меняется | пересчитывается |
Проверить это легко без всякого тестового стенда: заведите двух тестовых пользователей, как Анну и Жуана, создайте общее событие, переключите одному язык, затем другому зону и убедитесь по таблице выше, что в базе движется ровно то, что должно. Если после смены языка хотя бы одно значение due_at изменилось, где-то язык и время всё ещё склеены.
Пользователь из первой сцены в итоге получает то, что ожидал: он выбирает английский, и бот начинает говорить по-английски, а напоминание о созвоне по-прежнему приходит в тот самый момент, о котором договорились. А когда он сам скажет боту, что переехал, время сдвинется — предсказуемо, по понятному правилу и с предупреждением заранее.