Сегодня я хочу поведать о том как я на своей работе пытался сделать дополнительный канал для привлечения клиентов через 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/ (Все про миниаппы)
user-book
MiniApp до сих пор что-то с чем-то и это спустя столько лет.
Удивительно как интересную задумку и простую реализацию (прокидывай браузер) убивают всеми силами (развивая как будто вопреки), вкладывая все силы в сторисы и прочую срань.
Жаль, но на сейчас телега подходит к той же пропасти что и ВК в свое время.
Будем верить что в других месенджерах подхватят пальму и мы таки увидим нормальные MiniApp в том же матриксе