Сегодня я хочу поведать о том как я на своей работе пытался сделать дополнительный канал для привлечения клиентов через Telegram, в какие боли это вылилось и как технически выглядело решение. Я буду рассказыть о кейсе в некоторой компании, в которой я работаю.
Кто такой этот Telegram Miniapp?
Первый релиз Telegram Miniapp (далее просто миниапп) состоялся ближе к середине 2023 года. Данная технология позволяет рендерить внутри клиента телеграмма специальный виджет с веб страницей. Получается эдакий мини-браузер, который умеет отображать контент для пользователя, используя данные пользователя напрямую из телеграмма. С тех пор было собрано множество интересных проектов через миниапп.
Важно еще отметить что для создания миниаппа необходимо иметь как минимум пустого бота, к которому миниапп будет подвязан.

Первые шаги
Когда я только начал интеграцию миниаппа, у проекта уже был действующий сайт и отдельно создавать страничку под миниапп не хотелось по причине экономии времени. Первая мысль была открывать в миниапп мобильную версию сайта, но на деле с этим были свои нюансы.
У полноценного сайта есть множество ссылок, которые не релевантны миниаппу. Например на сайте у нас был еще отдельный блог, рекламные ленды, разные служебные страницы которые не предназначены для открытия через миниапп.
Аутентификация в мобильной версии не работает для миниаппов. Телеграм открывает миниапп передавая готовые данные аутентификации. Теоретически если юзер выйдет в миниаппе из своего аккаунта - миниапп сломается.
В результате помимо мобильной версии сайт был адаптирован для миниаппа. На практике выглядело так:
// Vue.JS <headerComponent v-if="!isTelegramApp" /> <main> <content></content> </main> <footerBlock v-if="!isTelegramApp" />
Шапка и подвал сайта не рендерились для миниапп. В этом случае юзер не мог случайно выйти из аккаунта или попасть в страничку за пределами миниапп используя навигацию сайта.


Конечно, на практике пришлось сделать чуть больше изменений - создание навигации для миниапп, скрытие ненужных блоков, новые стили. Но в целом первая версия получилась довольно простой.
На бекенде под миниапп был сделан свой способ регистрации и логина, который использует данные юзера с Telegram. Об этом чуть ниже.
Валидация
Когда первая версия была готова, первый технический вопрос был "насколько это безопасно?". Что если любое устройство могло притвориться Telegram-ом и попытаться передать "левые" данные?
В миниапп этот вопрос решается через валидацию входных данных. Есть 2 основных способа
Проверить что данные подписаны токеном бота
Проверить, что данные подписаны приватным ключом Telegram
Сразу выглядит так что второй способ безопаснее, т.к. в этом случае не нужно хранить токен бота в приложении, а следовательно меньше шанс его скомпрометировать, но на практике такой способ не подошел. Были кейсы(о них ниже), ради которых приходилось самостоятельно формировать данные и подписывать их, а так как мы не знаем приватный ключ Telegram, то и подписать такие данные не сможем.
В итоге финальным вариантом осталась валидация через токен бота.
Выглядело это примерно так:
// https://docs.telegram-mini-apps.com/packages/telegram-apps-init-data-node const { validate, parse } = require("@telegram-apps/init-data-node"); // ... try { // Читает приходящие Telegram данные и валидирует, что они были подписаны токеном бота validate(telegramData, process.env.TELEGRAM_BOT_TOKEN); } catch (err) { // 401 Unauthorized если данные невалидные throw new UnauthorizedError("Telegram data is invalid"); } .. Парсинг отвалидированных данных const { user: { id, first_name, last_name, username, language_code = 'en'}, start_param = "", } = parse(initData);
Важно валидировать данные на бекенде, чтобы нельзя было "обмануть" сервер, прислав якобы отвалидированного юзера.
Полный пример реализованной валидации можно найти в доке https://docs.telegram-mini-apps.com/platform/authorizing-user
Интерфейс
Чтобы попасть в миниапп, есть 4 основных способа(на самом деле больше):
Кнопка "запуск приложения"
Нажатие на keyboard кнопку
Нажатие на inline кнопку
Прямая ссылка вида https://t.me/my_bot/my_app_name
Первые 3 способа - просто кнопки, которые содержат ссылку на сайт. В Telegram API выглядит примерно так:
{ "text": "Создать заявку", "url": "https://example.com/miniapp" }


Каждый способ имеет свои особенности и требует отдельного внимания.
Например по какой то причине Telegram не передат данные пользователя при нажатии на кнопку 2. При открытии миниапп через эту кнопку браузер получит user: null. Чтобы избежать этого поведения, придется вручную сформировать данные юзера:
// В очередной раз либа приходит на помощь и защищает от написания велосипеда. import { sign } from "@telegram-apps/init-data-node"; // ... function addUserDataHash(baseUrl: string | URL, lang: string, tgUser: TgUser): URL { const token = process.env.TELEGRAM_BOT_TOKEN; // tgUser - текущий пользователь бота. Мы получаем его из контекста Telegram API const userData = { id: tgUser.id, first_name: tgUser.first_name, last_name: tgUser.last_name, username: tgUser.username, language_code: lang, allows_write_to_pm: true, }; // Персонализированная ссылка для пользователя const webAppUrlForKeyboardButton = new URL(baseUrl); const currentDate = new Date(); const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; const alphabet = `${letters}0123456789`; const generateRandomString = (alp: string, len: number) => new Array(len).fill(null).map(() => alp[Math.floor(Math.random() * alp.length)]).join(''); // Примерно по такой логике генерируется query_id в Telegram Miniapp. // Нужно для этого метода https://core.telegram.org/bots/api#answerwebappquery const query_id = generateRandomString(letters, 16) + generateRandomString(alphabet, 8); // Подпись данных токеном бота и добавление информации о моменте формирования данных const initData = sign({ user: userData, query_id, }, token, currentDate); // Данные о пользователя передаются через urlencoded хеш параметр const hash = new URLSearchParams({ tgWebAppData: initData }); webAppUrlForKeyboardButton.hash = hash.toString(); // Возвращаем ссылку обогащенную данными о пользователе. // Затем эта ссылка будет подставлена при нажатии на кнопку 2 "создать заявку". return webAppUrlForKeyboardButton; }
К счастью, с методами 1, 3 и 4 такого делать не приходилось.
Передача данных
Следующим вызовом стал вопрос о том как передавать данные в миниапп чтобы была возможность оценивать эффективность рекламных кампаний.
Самое первое решение пришедшее в голову - сделать похожее поведение как на сайте. А именно передавать рекламные метки как query параметры.
Возьмем за основу нашу старую ссылку и добавим в неё больше параметров.
https://example.com/miniapp?utm_source=ads&utm_medium=what_is_medium
На практике стали всплывать проблемы. При открытии миниапп, Telegram передает свой собственный query параметр - tgWebAppStartParam. В общем случае это работало хорошо - query параметры уживались вместе. Но бывали случае, когда какой нибудь android клиент Telegram мог полностью затереть переданные UTM и оставить только tgWebAppStartParam.
Дебажить такое поведение было трудно за счет плохой воспроизводимости на разных устройствах. А еще такой способ совсем не работал с прямыми ссылками на Miniapp по тем же причинам - конфликт с параметрами TG.
Таким образом оказалось проще не бороться с Telegram, а обьединить усилия:
if (this.isTelegramApp) { // Внутри миниапп собираем переданный start параметр const startParam = new URLSearchParams(document.location.search).get("tgWebAppStartParam"); // Читаем UTM-ки из start параметра // Пример ссылки - https://example.com/miniapp?tgWebAppStartParam=utm_source-ads--utm_medium-what_is_medium const utmParams = Object.fromEntries( (startParam ?? "") .split('--') .map(pair => pair.split('-')) ); // Отправляем событие в аналитику window.gtag('event', 'Click_start_bot', utmParams); }
После изобретения такого кастомного решения у маркетологов, которым приходилось создавать ссылки и подстравить их под парсер, начал дергаться глаз.

Но из хорошего таким же методом можно передать и другие данные - версию бота, реферальные ссылки, промокоды и т.д. При этом есть ограничения на длину и допустимые символы для tgWebAppStartParam - ^[\w-]{0,512}$.
Фактически можно передать любые данные в миниапп, закодировав их в base64, который, кстати, хорошо поддерживается Telegram-ом и является рекомендованным способом. Для моего проекта такое решение не подошло, ведь в таком случае маркетологам пришлось еще бы и base64 encode/decode изучать...
Версионирование
Со временем стала появлятся проблема "устаревания" интерфейса бота и ссылок на миниапп. Юзер мог переходить по стары�� ссылкам в интерфейсе бота. В нашем случае ссылки обновлялись когда менялись промокоды, добавлялись новые UTM метки или хотелось передать о пользователе чуть больше информации перед запуском Miniapp.
Нажимая на ссылку в интерфейсе, пользователь мог "перескочить" бота и попасть напрямую в миниапп. Поэтому на стороне миниапп появилось такое окошко с предупреждение, если текущая версия не является последней.

import semver from "semver"; const botMinSupportedVersion = '0.0.2'; if ( // Если миниапп был открыт через прямую ссылку - нет смысла обновлять бота. !telegram.isOpenedViaDirectMiniappLink() && // Если у пользователя старая версия бота (version === undefined || semver.neq(botMinSupportedVersion, version)) ) { showModalOutdatedVersion(); }
Проблему это решило, хоть и требовало пару лишних кликов от пользователя. Этот сценарий можно сделать чуть эффективнее, автоматически закрывая миниапп, передавая информацию боту о устаревшей версии, после чего бот обновляет меню и юзеру остается еще раз запустить миниапп.
С этим помог бы этот метод https://core.telegram.org/bots/api#answerwebappquery
Что можно сделать лучше?
После проделанной работы становится ясно, что проще поддерживать отдельный фронтенд под миниапп, нежели собранный франкенштейн пестрящий if-ами isTelegramApp(). При этом если стоит задача быстро адаптировать текущий сайт, то вышеописанный способ поможет быстро получить первый результат, особенно если основные компоненты сайта не готовы к переиспользованию.
В общем и целом, если после прочтения статьи появились мысли что можно улучшить, а что сделать по-другому было бы эффективнее - welcome в комментарии, обсудим.
Почему история грустная?
Весь путь создания миниаппа являлся собой хождением по граблям, так как информации было немного, Telegram Miniapp вещь довольно новая и как решать основные бизнес юзкейсы было не очень понятно. Буду надеятся, что статья поможет кому-то сэкономить время.
Полезные ссылки
https://docs.telegram-mini-apps.com/ (Все про миниаппы)
