В начале августа я сел делать игру для мессенджера MAX – обычный вордли, слово из пяти букв, шесть попыток. Мини-приложение на ванильном JS, сервер на Go, самый дешёвый . По моим прикидкам всё должно было заработать, за три вечера.
Первый вечер целиком ушёл на то, чтобы сервер вообще смог поговорить с API мессенджера. Не на игру, не на словарь, а на TCP-соединение.
Дальше было ещё семь таких мест. Каждое выглядело как мой баг, но каждое оказалось особенностью платформы, и ни одного из них нет в документации. Ниже представлены все восемь, с кодом и с тем, как именно я до них дошёл. Игра тут нужна только как повод: она набрала 107 игроков за пять дней, для меня это неплохой результат, но если обобщить, то это скромно, и рассказывать я собираюсь не про неё.
Три часа искал ошибку в Go, а она была в системе
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 в двух формах одного метода значит разное. Это выглядит не как ограничение, а как два разных параметра, которым досталось одно имя. Если кто-то из читавших знает, как оно устроено внутри, – расскажите, мне правда интересно, я потратил на это два часа и остановился на догадке.
Автор: Kitron4ik
