Восемь раз я думал, что сломал код. Это была платформа
- пятница, 14 августа 2026 г. в 00:00:29
В начале августа я сел делать игру для мессенджера MAX – обычный вордли, слово из пяти букв, шесть попыток. Мини-приложение на ванильном JS, сервер на Go, самый дешёвый VPS. По моим прикидкам всё должно было заработать, за три вечера.
Первый вечер целиком ушёл на то, чтобы сервер вообще смог поговорить с API мессенджера. Не на игру, не на словарь, а на TCP-соединение.
Дальше было ещё семь таких мест. Каждое выглядело как мой баг, но каждое оказалось особенностью платформы, и ни одного из них нет в документации. Ниже представлены все восемь, с кодом и с тем, как именно я до них дошёл. Игра тут нужна только как повод: она набрала 107 игроков за пять дней, для меня это неплохой результат, но если обобщить, то это скромно, и рассказывать я собираюсь не про неё.
Post "https://platform-api2.max.ru/subscriptions": tls: failed to verify certificate: x509: certificate signed by unknown authority
Я проверил версию Go, полез в http.Transport, потом заподозрил VPS-провайдера. Всё это время ответ был в другом месте: platform-api2.max.ru отдаёт сертификат, выпущенный национальным удостоверяющим центром Минцифры. В хранилище доверенных корневых сертификатов Ubuntu его нет.
Лечится двумя файлами:
cd /usr/local/share/ca-certificates curl -O https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt curl -O https://gu-st.ru/content/lending/russian_trusted_sub_ca_pem.crt update-ca-certificates
Обратите внимание, что нужны оба сертификата и корневой, и промежуточный. С одним корневым цепочка не собирается, а ошибка при этом ровно та же самая, так что можно решить, что способ не сработал вообще.
Отдельно неприятно, что на локальной машине с macOS всё работало сразу: там сертификаты Минцифры уже стоят, если хоть раз ходил на «Госуслуги». То есть ошибка появляется только в проде, что для первого дня работы было отдельным удовольствием.
С сервером связь появилась. Следующим сломался фронт.
Мини-приложение получает от клиента window.WebApp.initData – подписанную строку с данными пользователя. Сервер обязан проверить подпись, иначе ?user= подделает кто угодно.
В документации к разделу с верификацией телефона описана простая схема: HMAC-SHA256 от строки на ключе-токене бота. Я так и сделал, правда хеш не сошёлся.
Я перебрал всё, что перебирают в таких случаях: порядок полей, разделители, кодировку, регистр хекса, обрезку пробелов. Написал отладочный дамп, который печатал checkString целиком. Потратил вечер.
Оказалось, что для общей init-data MAX использует не описанную в документации схему, а ту же двухшаговую деривацию, что и Telegram Mini Apps:
// secretKey = HMAC-SHA256(key="WebAppData", data=botToken) // hash = hex(HMAC-SHA256(key=secretKey, data=checkString)) secretMac := hmac.New(sha256.New, []byte("WebAppData")) secretMac.Write([]byte(botToken)) secretKey := secretMac.Sum(nil) mac := hmac.New(sha256.New, secretKey) mac.Write([]byte(checkString)) computedHash := hex.EncodeToString(mac.Sum(nil))
checkString собирается обычным способом: все поля кроме hash, ключи отсортированы по алфавиту, пары key=value склеены через \n.
Литерал "WebAppData" – именно тот, что в телеграме, буква в букву. Если вы приходите в MAX из телеграма, ваш старый код проверки подписи заработает без изменений. Если вы, как я, читаете документацию MAX и делаете по ней, то не заработает.
Подпись сошлась, пользователи опознались, игра поехала. А потом я решил напоминать людям, что слово дня ещё не разгадано.
Логика простая: вечером пройтись по тем, кто согласился на напоминания и сегодня не играл, и отправить каждому одно сообщение. Написал, задеплоил, посмотрел в лог:
reminder send failed for user 18053692: dialog.not.found
И так для большинства.
Бот в MAX не может написать первым человеку, у которого нет с ним диалога. Не «не рекомендуется», а API возвращает ошибку. Диалог появляется, только когда человек сам открыл чат с ботом хотя бы раз.
Тонкость в том, кто в эту категорию попадает игрок, пришедший по ссылке вида max.ru/имя_бота?startapp=метка, открывает мини-приложение поверх мессенджера – и чат с ботом при этом не открывается. То есть вся аудитория, пришедшая из рекламы, для бота недостижима, и узнать об этом можно только попробовав отправить.
Я сделал так: одна попытка отправки на игрока, результат запоминается, и если сообщение отскочило – в самом приложении появляется плашка «откройте один раз чат с ботом, иначе напоминания не дойдут». Плюс кулдаун, чтобы переключатель напоминаний нельзя было превратить в генератор запросов к API:
func noDialogError(err error) bool { return err != nil && strings.Contains(err.Error(), "dialog.not.found") }
Из ста семи игроков проверку прошли восемь, и у пяти из них диалога не было. Выборка крошечная, но направление понятное: по умолчанию бот не может дотянуться до собственных пользователей.
Раз в личку не пишется – логично было пойти в групповые чаты.
Бота добавили в чат. Он поздоровался. Я написал в чат «рейтинг» – тишина. Написал ещё раз с упоминанием – тишина. Проверил вебхук, подписку на события, секрет в урле. Всё на месте, событий нет.
Событий действительно нет: MAX не доставляет боту сообщения из группового чата, пока у бота нет права читать сообщения. Само по себе это нормально и даже правильно. Ненормально другое – асимметрия:
получать сообщения из чата без прав нельзя;
отправлять сообщения в тот же чат без прав можно.
То есть бот, которого добавили и не выдали прав, выглядит полностью рабочим. Он здоровается, он постит по расписанию, он делает всё, что делает сам. И молча игнорирует любое обращение к нему. Со стороны это неотличимо от сломанного бота, и человек его удаляет.
Единственное лечение – объяснить это первым же сообщением, до того как кто-то попробует. У меня приветствие при добавлении просит ровно одно право и прямо говорит, зачем:
Чтобы я понимал команды, дайте мне в настройках чата право читать сообщения – остальные права можно оставить выключенными, они мне не нужны.
Формулировка «дайте одно право» вместо «сделайте меня админом» – не вежливость. В чужом рабочем чате это два очень разных по размеру запроса, и от разницы зависит, останется бот или нет.
К этому моменту у меня работали и личка, и чаты. И я полез в то, ради чего всё затевалось, – в пересылку.
В игре есть механика «загадай слово другу»: игрок задаёт слово, получает ссылку, отправляет кому хочет. Отправка идёт через мост: window.WebApp.shareMaxContent(...).
У метода две формы. Можно передать текст со ссылкой, а можно mid – идентификатор сообщения, которое бот заранее отправил самому игроку. Вторая форма нужна затем, что клиентский шаринг не умеет ни форматирования, ни кнопок, а переслать готовое сообщение бота – умеет. Так получается карточка с настоящей кнопкой вместо голой ссылки.
У обеих форм есть параметр chatType со значениями DIALOG и CHAT. В документации он описан одной строкой – тип чата.
Я поставил CHAT в обеих формах, ожидая «показывать групповые чаты». Проверил на живом клиенте:
// групповые чаты появляются в списке выбора shareMaxContent({ text, link, chatType: "CHAT" }); // отправить в групповой чат невозможно shareMaxContent({ mid, chatType: "CHAT" });
Параметр называется одинаково и значит разное. В форме с текстом он, судя по поведению, фильтрует список получателей. В форме с mid он описывает, где лежит исходное сообщение – а лежит оно в диалоге игрока с ботом. Передаёшь CHAT – и мессенджер ищет сообщение там, где его нет.
Проверить это иначе, чем руками в настоящем клиенте, я не смог. Своего аккаунта в MAX у меня для тестов было ровно два, и оба ушли на этот вопрос.
Правильно так: форма с mid – только DIALOG, форма с текстом – CHAT для групп. Что означает: карточку с кнопкой можно отправить одному человеку, а в общий чат уйдёт обычный текст со ссылкой. Некрасиво, зато долетает.
И вот тут выяснилась вещь, ради которой, на мой взгляд, вся эта возня и стоила времени.
Чтобы бот сам написал в групповой чат, его нужно в этот чат добавить, а для команд ещё и выдать права. Это высокий порог: в чужом рабочем чате никто не станет добавлять неизвестного бота.
Пересылка сообщения этого порога не требует вообще. Человек пересылает сообщение в любой свой чат сам, своими руками, от своего имени. Бота там нет и не нужно.
Для мессенджера без публичной ленты это довольно принципиально. Внутри MAX нет места, где незнакомцы видят чужие сообщения, – значит, единственный способ выйти за пределы одного диалога – это чтобы человек сам занёс сообщение в комнату, где сидят тридцать других. Пересылка – ровно этот механизм, и она не спрашивает разрешения.
Насколько это сработает как канал роста, я пока не знаю: механику выкатил вчера, цифр нет. Но потолок у неё принципиально другой, и это видно из арифметики: за всё время у меня 132 созданных загадки, и ни одна не была сыграна больше чем тремя людьми. Медиана – один человек.
Пересылка заработала. И сразу же оказалось, что пересылается не то, что я думал.
Игрок отправляет в чат текст со ссылкой на max.ru/имя_бота?startapp=метка. Мессенджер, как и любой другой, строит превью по домену из ссылки. Домен – max.ru. Значит, og-теги берутся у MAX.
В результате под сообщением «загадал слово из пяти букв, кто первый разгадает» разворачивается карточка «MAX – быстрое и лёгкое приложение для общения», с иконкой мессенджера. Самый заметный блок в сообщении работает на платформу, а не на то, что человек пересылает.
Это касается вообще всех, кто раздаёт ссылки на свои мини-приложения: ваша ссылка выглядит как реклама мессенджера.
Обходится страницей-прокладкой на своём домене. Важно отдать её кодом 200 с телом, а не редиректом: краулер, идущий за 30x, снова окажется на max.ru и снова заберёт чужие теги.
func (h *Handler) getInvite(w http.ResponseWriter, r *http.Request) { tag := r.PathValue("tag") target := gameURL if inviteTagPattern.MatchString(tag) { // ^[A-Za-z0-9_-]{1,64}$ target = gameURL + "?startapp=" + tag } // og:title / og:description / og:image – свои, // затем переход на мини-приложение уже на клиенте ... }
Цена – лишний прыжок через браузер: тап по ссылке открывает страницу, она отправляет в мессенджер. Выглядит как вспышка. Я решил, что превью важнее: решение тапнуть принимается по превью, а вспышку человек видит уже после того, как согласился.
Оставалось научить бота командам. Казалось бы, здесь-то сюрпризов быть не может.
Команды регистрируются одним вызовом, и кириллица работает:
info, err := api.Bots.PatchBot(ctx, &schemes.BotPatch{ Commands: []schemes.BotCommand{ {Name: "слово", Description: "Слово дня и ссылка на игру"}, {Name: "рейтинг", Description: "Кто из чата уже разгадал"}, }, })
PatchBot трогает только переданные поля, так что имя, описание и аватар бота не пострадают.
Проверил через GetBot – команды на месте. Открыл диалог с ботом, набрал / – подсказка выпадает. Открыл групповой чат, куда бот добавлен, набрал / – ничего.
В BotPatch нет ничего похожего на области видимости команд. То есть повлиять на это нельзя: список регистрируется глобально, а подсказка живёт только в личке. В групповом чате про существование команд знает только тот, кто прочитал приветственное сообщение при добавлении бота, – то есть один человек из тридцати.
Лечится не командами, а кнопками под сообщениями самого бота:
func chatButtons() *maxbot.Keyboard { kb := &maxbot.Keyboard{} row := kb.AddRow() row.AddLink("Играть", schemes.POSITIVE, gameURL) row.AddCallback("Рейтинг чата", schemes.DEFAULT, callbackBoard) return kb }
Кнопку не нужно знать – она уже на экране. Только не забудьте подписаться на message_callback в вебхуке, иначе кнопка выглядит живой и молчит: нажатия приходят лишь тому, кто их запросил. И отвечайте на коллбэк всегда, даже если обработка упала, – иначе у всех в чате на кнопке остаётся крутилка.
Это уже не особенность платформы, а моя собственная глупость, но она из тех, что повторит каждый.
Пока подсказок команд не было, я разбирал сообщения по ключевым словам без слеша: «рейтинг», «счёт», «слово», «игра». Совпадение искал по началу слова – казалось, что так добрее к пользователю, который напишет «рейтинга» или «играть».
Русский язык склоняет всё. Вот что мой бот считал обращением к себе:
"словом говоря" → команда "играли вчера" → команда "счета пришли" → команда "таблица умножения" → команда
И вишенка: текст приглашения в игре содержит «загадал слово из пяти букв». То есть каждый раз, когда игрок пересылал загадку в чат, бот отвечал следом своим сообщением. Я потратил неделю на механику распространения и одновременно с этим встроил в неё спам.
Заметил не я – заметил пользователь.
Правильный ответ – слеш, и он же снимает весь ворох эвристик, которые я успел вокруг этого нагородить: лимит длины сообщения, точные словоформы, проверку «а не назвали ли бота по имени», отдельный фильтр «сообщение со ссылкой на игру – не команда». Всё это существовало ради одной задачи: угадать, обращались к боту или нет. Слеш отвечает на этот вопрос прямо.
for _, w := range strings.Fields(text) { if !strings.HasPrefix(w, "/") { continue } w = strings.Trim(w[1:], ".,!?:;()\"'«»…-") if at := strings.IndexByte(w, '@'); at >= 0 { w = w[:at] // "/рейтинг@имя_бота" } if cmd, ok := commands[w]; ok { return cmd } }
Ирония в том, что подсказок команд в групповых чатах нет – то есть платформа подталкивает как раз к разбору свободного текста, который в русском языке не работает.
Восемь мест, и ни одно из них не про мой код. Оценка сверху, по вечерам:
Что | Времени |
|---|---|
Сертификаты Минцифры | 3 часа |
Подпись init-data | вечер |
| вечер плюс переделка напоминаний |
Права на чтение в чатах | 2 часа и одно удаление бота из чата |
| 2 часа и две регрессии в проде |
Превью ссылки | полдня вместе с прокладкой |
Команды в чатах | 2 часа |
Собственный парсер | неделя, пока не заметили |
Планировал три вечера на всю игру. Только на выяснение недокументированного ушло около двадцати часов.
Я не думаю, что это претензия к MAX. Платформа молодая, документация догоняет продукт, а не наоборот, и так бывает у всех, кто открывает API раньше, чем дописывает к нему текст. Но пока она догоняет, единственный источник знания – чужие грабли, поэтому я и выложил свои.
Чего я до сих пор не понимаю: почему chatType в двух формах одного метода значит разное. Это выглядит не как ограничение, а как два разных параметра, которым досталось одно имя. Если кто-то из читавших знает, как оно устроено внутри, – расскажите, мне правда интересно, я потратил на это два часа и остановился на догадке.