Инлайн-кнопка одна на всех: параметры, аутентификация и обратная связь в Telegram miniapp
Примеры кода — на C#/.NET, потому что так написан наш прод. Но вся статья — про механику Telegram, а не про язык: то же самое собирается на Node, Python или Go без изменений. Именно ради переносимой механики статья и написана.
О чём статья? Это вторая часть серии о том, как мы вайб-кодингом построили GoosleeBot — мост из Telegram в Google Meet, Zoom и другие сервисы видеовстреч. Здесь — самая недокументированная часть разработки под Telegram: механизм сессий, который связывает чат, miniapp и внешний браузер в одно приложение. Вот что внутри:
- как передать параметры из инлайн-бота в miniapp, если кнопка в чате одна на всех участников;
- как аутентифицировать пользователя miniapp за один запрос — с готовым скелетом валидации initData;
- как сохранить состояние там, где переменные Telegram не работают вовсе — во внешнем браузере;
- как доставлять изменения из чата в miniapp мгновенно (спойлер: WebSocket в telegram-webview работает);
- как редактировать сообщения в чате асинхронной очередью и не словить бан от Bot API;
- и другие.
А это вся серия:
- Часть 1 — история продукта и честный вердикт Telegram как платформе: правила для памяти агента, функция «где я?», туннели.
- Часть 2 (вы здесь) — механизм сессий: параметры, аутентификация, обратная связь, редактирование без бана.
- Часть 3 — UX-хаки: бот, который «звонит» как телефон, онбординг-воронка одним JSON-файлом и почему ссылки надо готовить до клика.
Проблема: кнопка общая, ссылка тупая, а половина флоу — вообще не в Telegram
Наш главный сценарий выглядит невинно: в чате лежит сообщение бота с кнопкой «Присоединиться к звонку». Но у этой кнопки три неприятных свойства, о которых не пишут в туториалах:
Первое: кнопка одна на всех. Сообщение в чате общее — его видят все участники. Нажать может любой, и для каждого miniapp должен открыться со «своим» контекстом: кто ты, в каком звонке, что тебе можно. А кнопка умеет ровно одно — открыть ссылку. Одинаковую для всех.
Второе: нужна аутентификация и обратная связь. Мало открыть страницу — надо понять, кто её открыл (и не поверить на слово), а потом вернуть результат действий обратно: в сообщение в чате, другим участникам, в их miniapp.
Третье, самое коварное: часть флоу уходит во внешний браузер. OAuth-авторизация у Google или Zoom по правилам провайдеров происходит в системном браузере — и в момент, когда пользователь туда перешёл, все переменные miniapp перестают существовать. Нет `Telegram.WebApp`, нет initData, нет ничего. Из всего контекста Telegram остаётся ровно то, что вы сами успели положить в URL.
Три проблемы — одно решение: серверные сессии. И сразу важная рамка: это не трюк для проброса параметров. Это полноценный аналог классической веб-сессии — серверный объект с временем жизни и типизированным хранилищем значений, на котором держится вся серверная логика. Если в вашем продукте есть хотя бы сценарий «выйти из miniapp наружу и вернуться» — механизм сессий обязателен, без вариантов.
Сессия как серверный объект
Скелет предельно простой (напоминаю, здесь C#, но это словарь с TTL — он есть в любом языке):
Хранилище — обычный `ConcurrentDictionary` в памяти процесса, фоновый воркер выметает протухшие. Ключ — случайный Guid: он непредсказуем, поэтому сам по себе работает как секрет.
Главное здесь — `GetData()`. У каждого типа сессии свой класс данных, и это данные не «для передачи», а рабочее состояние, на которое опирается серверная логика. Например, у сессии инлайн-звонка внутри лежит: кто создал звонок, список пользователей, нажимавших кнопки, выбранный провайдер, статус звонка, id инлайн-сообщения в чате, последний отрисованный текст этого сообщения. Сервер читает и меняет это состояние на каждом шаге сценария — ровно как классическая сессия в вебе, просто ключ к ней приезжает не в cookie (помните правило из части 1: cookies в webview ненадёжны), а в URL и токенах.
Цепочка: от кнопки в чате до персональной страницы
Теперь само решение — цепочка из трёх сессий. Звучит громоздко, на деле — три словарных записи и два редиректа:
По шагам:
1. Создание. Когда пользователь вызывает бота в чате, сервер создаёт `RequestCall`-сессию — общую для этого звонка. Ключ уходит в кнопки. Для callback-кнопок мы кодируем его прямо в payload (`{key}@@@{команда}`), для кнопки miniapp — в параметр `startapp` диплинка. Это единственный канал: Telegram не даст кнопке передать ничего, кроме этой строки.
2. Роутер. Кнопка открывает не целевую страницу, а лёгкую страницу-роутер. Её JS собирает `initData` и шлёт на сервер:
3. Проверка и обмен. Сервер валидирует подпись initData (следующий раздел), находит общую сессию по ключу из `start_param` — и выпускает персональную `AccessToken`-сессию: «пользователь X в контексте звонка Y». Внутри неё — ссылка на общую:
Одна общая сессия звонка — много персональных токенов поверх неё. Любой запрос с целевой страницы несёт персональный токен, и сервер за один lookup знает и «кто», и «в каком звонке». Проблема «кнопка одна на всех» решена.
4. Внешний браузер. А теперь то, ради чего это всё. Когда пользователь уходит на OAuth к Google и возвращается на наш callback-URL — он приходит в обычном браузере, без единой переменной Telegram. Но токен-то мы положили в URL заранее. Сервер достаёт по нему AccessToken-сессию → из неё OriginalSession → и продолжает сценарий, как будто никто никуда не уходил. Состояние пережило переход чат → miniapp → внешний браузер → обратно, потому что оно никогда и не покидало сервер.
Аутентификация: не верьте `initDataUnsafe` на слово
Telegram кладёт в miniapp данные пользователя двумя способами: `initDataUnsafe` (удобный распарсенный объект) и `initData` (сырая строка с криптографической подписью). Слово «Unsafe» в названии — не кокетство: эти данные каждый умеет подделать, отправив вам любой user_id. Использовать их для UI — можно, для серверных решений — только после проверки подписи сырой строки.
Алгоритм проверки описан в доках Telegram, вот его скелет целиком — это один статический метод:
Три детали, которые агент при вайб-кодинге стабильно упускает — проверьте руками:
- Сортировка полей ordinal, не culture-зависимая — иначе подпись «иногда не сходится».
- Сравнение хэшей в константное время (`FixedTimeEquals`), а не `==` — защита от timing-атак на подбор подписи.
- Проверка `auth_date`. Подпись без срока годности — это вечный пропуск: перехваченная один раз строка initData работала бы годами.
После валидации `user_id` из initData можно считать доказанным — Telegram расписался. Заметьте, что получилось: регистрация, логин и восстановление пароля в вашем приложении не существуют как задачи. Это одна из главных причин строить бизнес-приложения на Telegram вообще.
Врезка честности: всё это лежит в памяти процесса
Да, наши сессии — in-memory, `ConcurrentDictionary`, без Redis. Это осознанный трейдофф, и вот его границы:
- Когда это нормально: один инстанс приложения; сессии короткоживущие (минуты-часы) и по природе восстановимые — пользователь в худшем случае нажмёт кнопку ещё раз; критичное для бизнеса состояние (звонки, пользователи, настройки) и так живёт в PostgreSQL, сессия лишь оркестрирует сценарий.
- Когда сломается: рестарт или деплой обнуляет живые сценарии (у нас это «кнопка перестала отвечать, вызовите меню заново»); второй инстанс за балансировщиком не увидит чужих сессий — привет, sticky sessions или внешний стор.
Для вайб-кодера мораль такая: начинайте со словаря в памяти — это ноль инфраструктуры и вся механика статьи работает как есть. Но заведите интерфейс хранилища сессий с первого дня, чтобы Redis, когда понадобится, был заменой одного класса, а не переписыванием.
Обратная связь: сигнал по WebSocket, данные по запросу
Осталась последняя стрелка: изменения должны лететь обратно. Собеседник выбрал провайдера в своём miniapp — у вас на странице должен обновиться статус; кто-то подключился к встрече — сообщение в чате должно это показать.
В части 1 я уже спойлерил: SignalR (WebSocket) внутри telegram-webview работает штатно на всех платформах. Схема такая:
- При открытии страницы клиент вступает в группу по ключу общей сессии звонка: все участники одного звонка — в одной комнате.
- Когда серверное состояние меняется, сервер шлёт в группу голое событие `StateChanged` — без данных.
- Клиент, получив сигнал, сам запрашивает актуальное состояние обычным HTTP-запросом со своим персональным токеном.
Почему сигнал пустой? Три причины: не надо думать о правах в push-канале (каждый забирает состояние своим токеном — сервер сам решит, что ему видно); потерянный сигнал ничего не ломает (следующий `refreshState` всё выровняет); и события можно дебаунсить — при шквале изменений группа получает один пинок в N миллисекунд, а не очередь устаревших снапшотов. Плюс дешёвая страховка: редкий фоновый poll на случай, если WebSocket всё-таки умер.
Бонус-лайфхак: редактирование сообщений через очередь — иначе бан
Обратная связь долетает не только до miniapp, но и до того самого инлайн-сообщения в чате: «Готово. Подключились: Аня, Дмитрий». И вот здесь вайб-кодеры массово наступают на грабли: наивный код дёргает `editMessageText` на каждое изменение состояния. Два участника нажали кнопки одновременно — два edit'а; шквал событий — шквал edit'ов. А у Bot API на это две реакции: ошибка `400: message is not modified` (текст совпал с текущим) и flood-лимиты вплоть до временной блокировки бота — для продукта, который живёт в чужих чатах, это смерть.
Решение — не редактировать сообщение из бизнес-логики вообще. Вместо этого:
Три свойства, ради которых всё затевалось: дедупликация — десять изменений за секунду дают одно редактирование, потому что пометки схлопываются по ключу сессии, а текст строится по финальному состоянию; идемпотентность — сравнение с `LastInlineMessageText` из сессии гарантирует, что мы никогда не пошлём Telegram то, что у него уже есть (заметьте: это ещё одна работа для серверного состояния сессии); асинхронность — бизнес-логика не ждёт Telegram API и не падает от его ошибок.
Правило в память агента: сообщение в чате — это проекция состояния сессии, обновляемая фоновым воркером. Бизнес-логика сообщений не трогает.
Итого
Механика из этой статьи — переиспользуемый каркас для любого бизнес-приложения на miniapp, где есть «общая кнопка» и «выход наружу»: серверная сессия с типизированным состоянием; персональные токены поверх общего контекста; подпись initData как замена всей системе логина; пустые сигналы по WebSocket вместо данных в push; и очередь-проекция для сообщений в чате. Ни один из этих элементов не завязан на C# — это протокол работы с платформой.
В части 3 — фокусы: как бот «звонит» пользователю так, что телефон ведёт себя как при настоящем входящем вызове (спойлер: удаляем и переотправляем сообщение — и почему это работает). Почему в Telegram невозможен привычный веб-паттерн «клик → сервер сформировал ссылку → редирект» и как жить с тем, что конечный URL должен лежать в кнопке до клика. И скрипт-чат: онбординг-воронка целиком в одном JSON-файле — с эффектом печати, слайдами и аналитикой по шагам.
Всё описанное написано для Telegram, но в большей части справедливо и для MAX — механика ботов, miniapp и сессий там строится по тем же принципам. Если есть желание сделать адаптацию под MAX и российские сервисы видеовстреч — велком в личку.
Потрогать сам мост: @GoosleeBot · gooslibot.com