Бот, который звонит: дешёвые UX-хаки для Telegram-бота
Примеры кода — на C#/.NET, потому что так написан наш прод. Но вся статья — про механику Telegram, а не про язык: то же самое собирается на Node, Python или Go без изменений. Именно ради переносимой механики статья и написана.
О чём статья? Это третья часть серии о том, как мы вайб-кодингом построили GoosleeBot — мост из Telegram в Google Meet, Zoom и другие сервисы видеовстреч. Первые две части были про фундамент; эта — про фокусы: UX-приёмы, которые выглядят как магия, а стоят как пара вечеров. Вот что внутри:
как заставить бота «звонить» — чтобы телефон пользователя вёл себя как при настоящем входящем вызове, хотя Bot API звонков не умеет в принципе;
почему в Telegram невозможен привычный веб-паттерн «клик → сервер сформировал ссылку → редирект» — и как проектировать кнопки, когда переадресации нет;
как сделать онбординг-воронку целиком в одном JSON-файле — с эффектом печати, слайдами и аналитикой по шагам;
как один интерфейс провайдера держит шесть сервисов видеовстреч — от OAuth-API до голых диплинков;
и другие.
А это вся серия:
Часть 1 — история продукта и честный вердикт Telegram как платформе: правила для памяти агента, функция «где я?», туннели.
Часть 2 — механизм сессий: параметры, аутентификация, обратная связь, редактирование без бана.
Часть 3 (вы здесь) — UX-хаки: бот, который «звонит», ссылки до клика, скрипт-чат-воронка.
Хак 1. Бот, который звонит
Проблема. Наш продукт — про звонки, а Bot API звонить не умеет вообще: бот может только отправить сообщение. Но «я создал встречу, а собеседник не заметил» — главный убийца сценария. Сообщение со ссылкой на встречу — это письмо. А нужен звонок: настойчивый, повторяющийся, который вибрирует в кармане, пока не возьмёшь трубку.
Решение. Телефон реагирует не на «звонок» — телефон реагирует на уведомление. Значит, надо, чтобы уведомления приходили снова и снова, как гудки. Трюк: бот отправляет сообщение «📞 Вам звонит Дмитрий» с кнопками «Принять» / «Отклонить», ждёт несколько секунд — удаляет его и отправляет заново. Каждая переотправка — новый пуш: телефон снова вибрирует и светится. С точки зрения пользователя это неотличимо от дозвона; с точки зрения чата — там всегда одно аккуратное сообщение, а не лесенка из десяти одинаковых.
Вся механика — один фоновый сервис-планировщик. По шагам:
создать задание «прозвон» по ключу сессии звонка (идемпотентно: второй запрос — no-op)
получатели = участники чата, кроме инициатора, у кого разрешён прозвон
цикл до N попыток с интервалом T:
удалить прошлое сообщение-звонок (если было)
отправить новое: «Вам звонит X» [Принять — URL] [Отклонить — callback]
стоп-условия (проверяются каждый тик):
участник подключился к встрече / нажал «Отклонить»
звонок завершён или отменён / сессия истекла
после остановки: удалить все висячие сообщения-звонки
Краевые случаи, которые превращают трюк в фичу (и которые агент сам не сделает — диктуйте):
Идемпотентность. Нетерпеливый инициатор жмёт «Позвонить» три раза — задание должно создаться одно. Ключ задания = ключ сессии звонка (та самая сессия из части 2).
Стоп по подключению. Сервис следит за состоянием звонка: собеседник вошёл во встречу — гудки мгновенно прекращаются, даже если он так и не нажал «Принять» в чате, а зашёл по ссылке из сообщения.
Уборка. После остановки — удалить все оставшиеся сообщения-звонки. «Пропущенный вызов» не должен висеть в чате мусором.
Cooldown. Повторный прозвон того же звонка — не раньше, чем через паузу. Иначе кнопка «Позвонить» превращается в оружие спама, а ваш бот — в кандидата на блокировки.
Согласие. У получателя должен быть флаг «можно ли мне звонить». Незапрошенные повторные пуши — это то, за что боты банят люди, а не Telegram.
Правило в память агента: «звонок» бота = цикл «удалить и переотправить» с жёсткими стоп-условиями и уборкой за собой.
Хак 2. Ссылки готовят до клика, потому что редиректа не будет
Это самый контринтуитивный урок серии, поэтому разберу медленно.
Как вы привыкли в вебе: пользователь кликает → запрос приходит на сервер → сервер обрабатывает (создаёт встречу, выписывает токен, выбирает провайдера) → формирует конечный URL → отвечает переадресацией → браузер уезжает. Клик запускает вычисление, ссылка — его результат.
Как в Telegram: этой схемы не существует. URL-кнопка в сообщении — это готовая ссылка, зашитая в момент отправки сообщения. Между «пользователь нажал» и «клиент открыл URL» нет вашего сервера — Telegram сам мгновенно открывает то, что лежит в кнопке. Некуда встроить «обработать и переадресовать».
Следствие: всю «нагрузку редиректа» приходится выполнять заранее — до того, как пользователь нажмёт, и даже до того, как он увидит кнопку. Кнопка «Принять» в сообщении-звонке уже содержит персональную ссылку на страницу подключения; она была вычислена и положена в сессию в момент, когда бот формировал сообщение. У нас это выглядит так:
```csharp
// при создании сообщения-звонка, ДО отправки:
var prepareUrl = BuildPrepareCallUrl(callSession, recipient); // вся логика — здесь
callData.PrepareCallUrl = prepareUrl; // и в состояние сессии
var keyboard = new InlineKeyboard(
UrlButton(t("Принять"), prepareUrl), // готовый конечный URL
CallbackButton(t("Отклонить"), $"{callSession.Key}@@@decline"));
```
Что это меняет в проектировании — три привычки на замену:
Думайте «какие переходы возможны с этого экрана» вместо «что сделать по клику». Каждая кнопка = заранее просчитанный маршрут. Если маршрут зависит от того, кто нажмёт (а кнопка в чате одна на всех — привет, часть 2), — в кнопке лежит URL страницы-роутера, которая разберётся по сессии и токену уже на своей стороне.
Отделяйте «дорогое» от «мгновенного». Создание встречи у провайдера — секунды; их нельзя прятать за клик по URL-кнопке. Либо создавайте заранее, либо ведите на промежуточную страницу «Соединяем…», которая честно показывает процесс (у нас — второй вариант, со статусами по WebSocket из части 2).
То же касается возвратов. Ссылка «вернуться в Telegram» из внешнего браузера (`tg://`-диплинк после OAuth) — тоже готовится заранее и зависит от платформы (функция «где я?» из части 1).
Правило в память агента: в Telegram ссылка — не результат клика, а его предусловие. Всё кликабельное должно содержать конечный URL уже в момент отрисовки.
Хак 3. Онбординг-воронка в одном JSON-файле
Проблема. Пользователь зарегистрировался — и его надо провести за руку: что это, зачем, как сделать первый звонок. Классические варианты дороги: цепочка сообщений от бота — это state machine в коде и вечные правки «поменяйте текст третьего шага»; полноценный тур в miniapp — фронтенд-работа на недели.
Решение. Онбординг — это чат. Так пусть он и будет чатом — только скриптованным. Страница в miniapp рендерит «переписку с ботом», а весь сценарий лежит в одном JSON-файле: узлы-сообщения, кнопки-переходы, слайды. Вот реальный кусок нашей воронки (сокращённо):
```json
{
"chat-intro": {
"chatvelocity": 66, // скорость «печати», символов в секунду
"messages": [
{
"name": "intro",
"text": "<b>Привет!</b> Я GoosleeBot — звонки через Meet, Zoom… прямо из чата.",
"buttons": [
{ "key": "see_examples", "text": "💡 Примеры ситуаций", "goto": "examples" },
{ "key": "how_it_works", "text": "❔ Как это работает", "goto": "how" },
{ "key": "finish", "text": "Вернуться в Telegram", "href": "@return_to_bot" }
]
},
{
"name": "how_to_call",
"text": "Тапаете кнопку в чате — бот создаёт звонок…",
"slides": [ "step1.png", "step2.png", "step3.png" ] // карусель со скриншотами
}
]
}
}
```
Фронт один на все сценарии: печатает текст с заданной скоростью, показывает карусель слайдов, рисует кнопки. `goto` — переход к узлу графа, `href` — внешняя ссылка, служебный `@return_to_bot` закрывает miniapp и возвращает в чат. Каждое нажатие улетает на сервер как событие аналитики — и вот вам воронка по шагам бесплатно: докуда доходят, где отваливаются, какая кнопка мертва.
Почему это дешёво и почему это стоит красть:
Новый сценарий = новый JSON. Помощь, страница настроек, промо новой фичи — без единой строчки кода. Правки текстов — правки файла, их может делать не-программист (или агент — идеальная задача для вайб-кодинга: «добавь в воронку шаг про групповые звонки»).
Локализация — файлы по локалям (`chats_ru.json`, `chats_en.json`) с fallback на дефолтную. Требование каталогов из части 1 закрывается само.
Это и есть ваша презентация продукта. Тот же движок, который онбордит новичка, показывает «что нового» старым пользователям и продаёт функции — воронка и витрина в одном механизме.
Вместо эпилога: как один интерфейс держит шесть провайдеров
Обещанная последняя тема — коротко, потому что после трёх статей она почти очевидна. «Мост» держится на одном интерфейсе:
```csharp
public interface ICallProvider
{
CallProviderKind Kind { get; }
bool DirectLinkCall { get; } // ссылку можно собрать без API?
Task<ProviderResult> CreateAsync(CallContext ctx); // → ссылка на встречу
}
```
А адаптеры под ним — трёх сортов, по убыванию сложности:
OAuth-API (Google Meet, Zoom): полноценная интеграция — авторизация пользователя, хранение и рефреш токенов, создание встречи через API провайдера. Самые дорогие и самые функциональные.
Генерация комнаты (Jitsi): ни аккаунта, ни API — ссылка со случайным именем комнаты, комната возникает при первом входе. Адаптер на двадцать строк.
Диплинк (FaceTime, Teams): просто правильно собранная ссылка на чужое приложение.
Поверх — автовыбор: если у автора настроен любимый провайдер — он; нет — первый доступный по приоритету. Пользователь нажал одну кнопку, а какой из шести мостов под ней сработал — деталь реализации. Для вайб-кодера главный вывод: начинайте с адаптера третьего сорта. Наш MVP за два дня (из части 1) был возможен ровно потому, что Jitsi-адаптер — это генерация строки; OAuth-сложность Meet и Zoom доехала позже, когда идея уже была доказана.
Итог серии
Три статьи — один тезис: Telegram даёт бизнес-приложению неприличную фору (дистрибуция через чат, аутентификация без регистрации, realtime из коробки), но взамен требует выучить его правила: webview — не браузер, кнопка — не запрос, ссылка — не результат клика, а сообщение в чате — проекция серверного состояния. Выучите их сами — или просто продиктуйте своему агенту: все правила серии сформулированы так, чтобы копироваться в CLAUDE.md как есть.
Всё описанное написано для Telegram, но в большей части справедливо и для MAX — механика ботов, miniapp и сессий там строится по тем же принципам. Если есть желание сделать адаптацию под MAX и российские сервисы видеовстреч — велком в личку.
Потрогать сам мост: @GoosleeBot · gooslibot.com







