- PVSM.RU - https://www.pvsm.ru -

Восемь раз я думал, что сломал код. Это была платформа

В начале августа я сел делать игру для мессенджера MAX – обычный вордли, слово из пяти букв, шесть попыток. Мини-приложение на ванильном JS, сервер на Go, самый дешёвый VPS [1]. По моим прикидкам всё должно было заработать, за три вечера.

Первый вечер целиком ушёл на то, чтобы сервер вообще смог поговорить с 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

вечер

dialog.not.found

вечер плюс переделка напоминаний

Права на чтение в чатах

2 часа и одно удаление бота из чата

chatType в двух формах

2 часа и две регрессии в проде

Превью ссылки

полдня вместе с прокладкой

Команды в чатах

2 часа

Собственный парсер

неделя, пока не заметили

Планировал три вечера на всю игру. Только на выяснение недокументированного ушло около двадцати часов.

Я не думаю, что это претензия к MAX. Платформа молодая, документация догоняет продукт, а не наоборот, и так бывает у всех, кто открывает API раньше, чем дописывает к нему текст. Но пока она догоняет, единственный источник знания – чужие грабли, поэтому я и выложил свои.

Чего я до сих пор не понимаю: почему chatType в двух формах одного метода значит разное. Это выглядит не как ограничение, а как два разных параметра, которым досталось одно имя. Если кто-то из читавших знает, как оно устроено внутри, – расскажите, мне правда интересно, я потратил на это два часа и остановился на догадке.

Автор: Kitron4ik

Источник [2]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/tls/456516

Ссылки в тексте:

[1] VPS: https://www.reg.ru/?rlink=reflink-717

[2] Источник: https://habr.com/ru/articles/1070044/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1070044