Инлайн-кнопка одна на всех: параметры, аутентификация и обратная связь в 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 — он есть в любом языке):

```csharp public sealed class Session { public string Key { get; init; } // Guid без дефисов — едет в URL public SessionType SessionType { get; set; } // RequestCall | MiniAppRoute | AccessToken | ... public TimeSpan SessionLifeTime { get; set; } public bool IsExpired => UtcNow - CreatedDate > SessionLifeTime; public T? GetData<T>() where T : class { ... } // типизированные данные сценария public void SetData<T>(T? value) where T : class { ... } } ```

Хранилище — обычный `ConcurrentDictionary` в памяти процесса, фоновый воркер выметает протухшие. Ключ — случайный Guid: он непредсказуем, поэтому сам по себе работает как секрет.

Главное здесь — `GetData()`. У каждого типа сессии свой класс данных, и это данные не «для передачи», а рабочее состояние, на которое опирается серверная логика. Например, у сессии инлайн-звонка внутри лежит: кто создал звонок, список пользователей, нажимавших кнопки, выбранный провайдер, статус звонка, id инлайн-сообщения в чате, последний отрисованный текст этого сообщения. Сервер читает и меняет это состояние на каждом шаге сценария — ровно как классическая сессия в вебе, просто ключ к ней приезжает не в cookie (помните правило из части 1: cookies в webview ненадёжны), а в URL и токенах.

Цепочка: от кнопки в чате до персональной страницы

Теперь само решение — цепочка из трёх сессий. Звучит громоздко, на деле — три словарных записи и два редиректа:

```text [чат] RequestCall-сессия ─── общая, одна на звонок │ ключ уезжает в кнопку: t.me/MyBot/app?startapp={key} ▼ [miniapp] роутер-страница: собирает Telegram.WebApp.initData → POST /init │ сервер валидирует подпись, находит RequestCall по ключу ▼ [сервер] AccessToken-сессия ── персональная, у каждого пользователя своя, │ внутри ссылка на общую OriginalSession ▼ [страница] редирект на целевую страницу с персональным токеном в URL ```

По шагам:

1. Создание. Когда пользователь вызывает бота в чате, сервер создаёт `RequestCall`-сессию — общую для этого звонка. Ключ уходит в кнопки. Для callback-кнопок мы кодируем его прямо в payload (`{key}@@@{команда}`), для кнопки miniapp — в параметр `startapp` диплинка. Это единственный канал: Telegram не даст кнопке передать ничего, кроме этой строки.

2. Роутер. Кнопка открывает не целевую страницу, а лёгкую страницу-роутер. Её JS собирает `initData` и шлёт на сервер:

```js const tg = window.Telegram.WebApp; const resp = await fetch('/miniapp/init', { method: 'POST', body: JSON.stringify({ initData: tg.initData, // подписанная строка — для проверки startParam: tg.initDataUnsafe.start_param, // ключ RequestCall-сессии platform: tg.platform, // помните «где я?» из части 1 }), }); const { redirectUrl } = await resp.json(); location.replace(redirectUrl); ```

3. Проверка и обмен. Сервер валидирует подпись initData (следующий раздел), находит общую сессию по ключу из `start_param` — и выпускает персональную `AccessToken`-сессию: «пользователь X в контексте звонка Y». Внутри неё — ссылка на общую:

```csharp var accessSession = SessionService.Create(SessionType.AccessToken, lifeTime); accessSession.SetData(new AccessTokenSessionData { UserId = user.Id, OriginalSession = requestCallSession, // общий контекст звонка — один на всех }); return RedirectUrlFor(page, accessSession.Key); // токен — в URL целевой страницы ```

Одна общая сессия звонка — много персональных токенов поверх неё. Любой запрос с целевой страницы несёт персональный токен, и сервер за один lookup знает и «кто», и «в каком звонке». Проблема «кнопка одна на всех» решена.

4. Внешний браузер. А теперь то, ради чего это всё. Когда пользователь уходит на OAuth к Google и возвращается на наш callback-URL — он приходит в обычном браузере, без единой переменной Telegram. Но токен-то мы положили в URL заранее. Сервер достаёт по нему AccessToken-сессию → из неё OriginalSession → и продолжает сценарий, как будто никто никуда не уходил. Состояние пережило переход чат → miniapp → внешний браузер → обратно, потому что оно никогда и не покидало сервер.

Аутентификация: не верьте `initDataUnsafe` на слово

Telegram кладёт в miniapp данные пользователя двумя способами: `initDataUnsafe` (удобный распарсенный объект) и `initData` (сырая строка с криптографической подписью). Слово «Unsafe» в названии — не кокетство: эти данные каждый умеет подделать, отправив вам любой user_id. Использовать их для UI — можно, для серверных решений — только после проверки подписи сырой строки.

Алгоритм проверки описан в доках Telegram, вот его скелет целиком — это один статический метод:

```csharp public static bool Validate(string initData, string botToken) { var parsed = ParseQuery(initData); // initData — это query string var receivedHash = parsed["hash"]; var dataCheckString = string.Join('\n', // все поля кроме hash, parsed.Where(kv => kv.Key != "hash") // отсортированы по ключу .OrderBy(kv => kv.Key, StringComparer.Ordinal) .Select(kv => quot;{kv.Key}={kv.Value}")); var secretKey = HMACSHA256(key: "WebAppData", message: botToken); var expected = HMACSHA256(key: secretKey, message: dataCheckString); if (!FixedTimeEquals(expected, receivedHash)) return false; // константное время! return UtcNow - FromUnix(parsed["auth_date"]) < MaxAge; // и свежесть: у нас 1 час } ```

Три детали, которые агент при вайб-кодинге стабильно упускает — проверьте руками:

  • Сортировка полей 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-запросом со своим персональным токеном.
```csharp // сервер: состояние изменилось → пнуть группу (с debounce, см. ниже) await hub.Clients.Group(callSessionKey).SendAsync("StateChanged"); // клиент: сигнал — не данные, а приглашение спросить connection.on('StateChanged', () => refreshState()); // GET со своим токеном ```

Почему сигнал пустой? Три причины: не надо думать о правах в push-канале (каждый забирает состояние своим токеном — сервер сам решит, что ему видно); потерянный сигнал ничего не ломает (следующий `refreshState` всё выровняет); и события можно дебаунсить — при шквале изменений группа получает один пинок в N миллисекунд, а не очередь устаревших снапшотов. Плюс дешёвая страховка: редкий фоновый poll на случай, если WebSocket всё-таки умер.

Бонус-лайфхак: редактирование сообщений через очередь — иначе бан

Обратная связь долетает не только до miniapp, но и до того самого инлайн-сообщения в чате: «Готово. Подключились: Аня, Дмитрий». И вот здесь вайб-кодеры массово наступают на грабли: наивный код дёргает `editMessageText` на каждое изменение состояния. Два участника нажали кнопки одновременно — два edit'а; шквал событий — шквал edit'ов. А у Bot API на это две реакции: ошибка `400: message is not modified` (текст совпал с текущим) и flood-лимиты вплоть до временной блокировки бота — для продукта, который живёт в чужих чатах, это смерть.

Решение — не редактировать сообщение из бизнес-логики вообще. Вместо этого:

```text логика: «состояние сессии X изменилось» → пометка X в очереди (дедупликация по ключу) воркер: берёт ключ → строит текст и клавиатуру ИЗ ТЕКУЩЕГО состояния сессии → сравнивает с последним отправленным текстом (он хранится в сессии!) → совпало: молча пропустить | изменилось: editMessageText, запомнить текст ```

Три свойства, ради которых всё затевалось: дедупликация — десять изменений за секунду дают одно редактирование, потому что пометки схлопываются по ключу сессии, а текст строится по финальному состоянию; идемпотентность — сравнение с `LastInlineMessageText` из сессии гарантирует, что мы никогда не пошлём Telegram то, что у него уже есть (заметьте: это ещё одна работа для серверного состояния сессии); асинхронность — бизнес-логика не ждёт Telegram API и не падает от его ошибок.

Правило в память агента: сообщение в чате — это проекция состояния сессии, обновляемая фоновым воркером. Бизнес-логика сообщений не трогает.

Итого

Механика из этой статьи — переиспользуемый каркас для любого бизнес-приложения на miniapp, где есть «общая кнопка» и «выход наружу»: серверная сессия с типизированным состоянием; персональные токены поверх общего контекста; подпись initData как замена всей системе логина; пустые сигналы по WebSocket вместо данных в push; и очередь-проекция для сообщений в чате. Ни один из этих элементов не завязан на C# — это протокол работы с платформой.

В части 3 — фокусы: как бот «звонит» пользователю так, что телефон ведёт себя как при настоящем входящем вызове (спойлер: удаляем и переотправляем сообщение — и почему это работает). Почему в Telegram невозможен привычный веб-паттерн «клик → сервер сформировал ссылку → редирект» и как жить с тем, что конечный URL должен лежать в кнопке до клика. И скрипт-чат: онбординг-воронка целиком в одном JSON-файле — с эффектом печати, слайдами и аналитикой по шагам.

Всё описанное написано для Telegram, но в большей части справедливо и для MAX — механика ботов, miniapp и сессий там строится по тем же принципам. Если есть желание сделать адаптацию под MAX и российские сервисы видеовстреч — велком в личку.

Потрогать сам мост: @GoosleeBot · gooslibot.com