Сегодня я хочу поведать о том как я на своей работе пытался сделать дополнительный канал для привлечения клиентов через 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 основных способа(на самом деле больше):

  1. Кнопка "запуск приложения"

  2. Нажатие на keyboard кнопку

  3. Нажатие на inline кнопку

  4. Прямая ссылка вида https://t.me/my_bot/my_app_name

Первые 3 способа - просто кнопки, которые содержат ссылку на сайт. В Telegram API выглядит примерно так:

{
  "text": "Создать заявку",
  "url": "https://example.com/miniapp"
}
Способы 1 и 2
Способы 1 и 2
Способ 3
Способ 3

Каждый способ имеет свои особенности и требует отдельного внимания.

Например по какой то причине 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 вещь довольно новая и как решать основные бизнес юзкейсы было не очень понятно. Буду надеятся, что статья поможет кому-то сэкономить время.

Полезные ссылки

Комментарии (1)


  1. user-book
    30.09.2025 11:31

    MiniApp до сих пор что-то с чем-то и это спустя столько лет.

    Удивительно как интересную задумку и простую реализацию (прокидывай браузер) убивают всеми силами (развивая как будто вопреки), вкладывая все силы в сторисы и прочую срань.

    Жаль, но на сейчас телега подходит к той же пропасти что и ВК в свое время.

    Будем верить что в других месенджерах подхватят пальму и мы таки увидим нормальные MiniApp в том же матриксе