Я разрабатываю PWA для голосовой практики английского. Несколько раз пытался опубликовать его в RuStore через Trusted Web Activity (TWA) — Google-обёртку, которая упаковывает PWA в подписанный Android AAB. После четырёх отказов модерации я понял, что для моего класса приложений TWA в RuStore не работает, и за день переключился на Telegram Mini App.
Эта статья — не история стартапа, а разбор технических решений:
Чем отличаются PWABuilder и Bubblewrap CLI при сборке TWA, и почему второй надёжнее
Какие конкретно изменения нужны в Android-манифесте для прохождения модерации RuStore (Save password, Site Settings, RECORD_AUDIO)
Как устроена авторизация Telegram Mini App и почему её нельзя писать «по интуиции»
Как реализовать серверные лимиты времени для голосового приложения, которые не обходятся со стороны клиента
Какие баги я нашёл в Bubblewrap CLI и как их обойти
Стек проекта: NestJS (backend), React + Vite (frontend), Socket.IO (голосовой WebSocket), PostgreSQL.
? UPD 1 мая 2026: пока эта статья жила, я параллельно
переписал Android-обёртку на Capacitor 6 — и 29 апреля
приложение всё-таки прошло модерацию RuStore.
Так что в итоге работают обе ветки: Mini App как
низкобарьерное демо в Telegram, и полноценный Android-app
в RuStore. Ниже — история, как и почему я пришёл к
такому раздвоению.
Если хочется сразу попробовать продукт, не дочитывая до технических деталей:
— ? Демо в Telegram (3 минуты с AI без регистрации): t.me/aiteacher_emma _bot/emma
— ? Веб-версия (если у вас iOS или нет Telegram): speakwithai.pro
— ? Android в RuStore: Speakwithai в RuStore
Контекст: почему TWA, а потом не TWA
PWA уже жил в продакшене. Хотел иконку в RuStore без переписывания на Flutter/Kotlin. TWA — это легальный путь Google: твой PWA открывается в полноэкранном режиме, без адресной строки браузера, привязан к домену через assetlinks.json. По факту — Chrome Custom Tab с подписью разработчика.
Что не получилось с TWA в RuStore:
Модерация отклоняла четыре раза подряд. Первые три отказа были по конкретным техническим претензиям (которые я закрыл фиксами), а вот на последний отказ модератор написал, что приложение «является обёрткой над сайтом и не воспринимается как самостоятельный продукт».
Это политика магазина — фиксу через код не подлежит. Чтобы пройти, нужно дописывать нативные экраны (онбординг, профиль, настройки), что для голосового AI-сервиса — несколько недель работы. Я решил, что эти недели лучше потратить на запуск в Telegram, и не пожалел.
Конкретные технические претензии RuStore (для тех, кто всё-таки идёт по этому пути):
Диалог Chrome «Сохранить пароль?» при первом логине
Пункт «Site Settings» в свойствах приложения
Отсутствие нативной декларации разрешений (например, RECORD_AUDIO для микрофона)
Скриншоты в современном соотношении 9:19.5 вместо требуемого 9:16
Разберём, как закрывается каждый.
Часть 1. PWABuilder vs Bubblewrap CLI
PWABuilder (web-сервис) — самый простой способ собрать AAB. Загружаешь URL манифеста, через 5 минут получаешь готовый файл. Для прототипа отлично, но для боевой публикации не хватает контроля.
Через UI PWABuilder нельзя:
Отключить пункт «Site Settings» в свойствах приложения (он зашит в шаблон по умолчанию)
Добавить дополнительные uses-permission в Android-манифест (например, разрешение на микрофон)
Настроить повторную подпись тем же ключом, что использовался ранее (для обновлений в магазинах это критично)
Решение — Bubblewrap CLI, официальный инструмент Google для генерации TWA. Он работает локально, генерирует полноценный Android-проект с Gradle, и позволяет редактировать всё руками.
Что Bubblewrap скачивает при первом запуске:
JDK 17, Android SDK build-tools и Android SDK platforms — суммарно около 500 МБ. Без VPN на российском интернете может качать долго. У меня было три бага на этом этапе.
Баг 1: битый JDK zip
Bubblewrap качает свой собственный JDK 17 в ~/.bubblewrap/jdk. У меня архив пришёл битым, init упал на этапе распаковки с сообщением о неверной сигнатуре zip-файла.
Решение: не пользоваться bundled JDK. Установить системный OpenJDK 17 через apt и прописать путь в ~/.bubblewrap/config.json вручную. После этого Bubblewrap использует системный JDK и не пытается скачивать свой.
Баг 2: «session has been destroyed»
При повторном init падает с этой ошибкой. Под капотом Bubblewrap использует Puppeteer для парсинга манифеста и скачивания иконок — если в системе нет Chrome или Chromium, Puppeteer падает.
Решение: установить Chrome stable. После этого init проходит до конца.
Баг 3: некорректный launch URL
После bubblewrap init загляни в сгенерированный app/build.gradle. Если в переменной launchUrl лежит package ID (например, com.yourdomain.twa), а не путь типа / — значит парсер манифеста ошибся.
В моём случае это случилось на ровном месте. Стартовый URL запекается в строковые ресурсы Android-проекта при сборке. С неправильным значением приложение пыталось открыть https://yourdomain.com/com.yourdomain.twa и показывало 404. Замени значение на / (или на актуальный start_url из твоего web-манифеста) и пересобирай.
Часть 2. Кастомизация Android-манифеста
После того как проект сгенерирован, нужны три ручные правки.
Удалить «Site Settings»
В сгенерированном AndroidManifest.xml есть отдельная активность ManageDataLauncherActivity, привязанная к интенту android.intent.action.APPLICATION_PREFERENCES. Она и создаёт пункт «Настройки сайта» в свойствах приложения, который не нравится модерации RuStore - приложение должно выглядеть нативным.
Что делать: удалить блок этой активности из AndroidManifest.xml и атрибут android:manageSpaceActivity у .
В twa-manifest.json есть флаг enableSiteSettingsShortcut: false, но bubblewrap update не всегда применяет его к существующему проекту — особенно если ты уже редактировал манифест руками. Проще удалить активность напрямую и забыть про флаг.
Декларация микрофона
Для голосового приложения недостаточно getUserMedia() в JavaScript — Android требует явной декларации разрешения в манифесте. Bubblewrap по умолчанию не добавляет RECORD_AUDIO.
Что добавить: uses-permission для android.permission.RECORD_AUDIO и uses-feature для android.hardware.microphone с атрибутом required=“false”.
required=“false” важно — иначе устройства без микрофона физически не смогут установить приложение. Для русскоязычной аудитории это редкий случай, но в Play Store/AppGallery такие пользователи есть.
Подавление диалога Chrome «Сохранить пароль?»
Это уже фронтенд-фикс, не Android. Chrome видит рядом с и через TWA предлагает сохранить пароль. Модерация RuStore это считает багом UX («диалог браузера в нативном приложении»).
Решение — известный трюк: добавить в форму скрытые decoy-поля перед настоящими. Парсер Chrome находит первую пару username + password (это decoy, скрытые display: none и помеченные aria-hidden), и не предлагает сохранять следующую — настоящую.
Атрибуты на decoy-полях: autoComplete=“username” для текстового, autoComplete=“new-password” для пароля, tabIndex={-1} чтобы не попадались Tab-навигацией, readOnly чтобы не были редактируемыми.
На настоящих полях: autoComplete=“off” плюс на пароле data-form-type=“other”. На самой форме — тоже autoComplete=“off”.
После этих изменений диалог сохранения пароля не появляется ни в обычном Chrome, ни в TWA.
Часть 3. Telegram Mini App: как устроена авторизация
После четвёртого отказа RuStore я переключился на Telegram Mini App. Это веб-приложение, открывающееся внутри Telegram через t.me//<short_name>. Те же HTML/CSS/JS, что и сайт, но с дополнительным контекстом — initData от Telegram, в котором подписан текущий пользователь.
Подключение: добавить в скрипт https://telegram.org/js/telegram-web-app.js. Он заполняет
window.Telegram.WebApp со всем необходимым: initData, initDataUnsafe.user, методами для управления UI (ready(), expand(), openLink()).
Что такое initData и почему его нельзя проверять «на глаз»
initData — это URL-encoded строка, которую Telegram прокидывает в Mini App при открытии. В ней лежат поля: auth_date, user (JSON с id, именем, аватаром), hash (HMAC-подпись от Telegram), и ещё несколько служебных.
Главное правило безопасности: никогда не доверяй initDataUnsafe.user напрямую. Это поле клиент может подделать в DevTools. Доверять можно только тому, что
прошло проверку HMAC на бэкенде.
Алгоритм проверки
Описан в официальной документации Telegram (раздел «Validating data received via the Mini App»). Кратко:
Распарсить query string из initData
Извлечь поле hash отдельно
Остальные поля собрать в строку формата key=value\n…, отсортированную по ключу алфавитно
Вычислить промежуточный ключ: secret_key = HMAC_SHA256(bot_token, “WebAppData”)
Вычислить подпись: signature = HMAC_SHA256(data_check_string, secret_key)
Сравнить полученную подпись с hash через timing-safe сравнение
Дополнительно проверить auth_date на свежесть (рекомендуется не старше 24 часов)
Тонкости, которые легко пропустить:
На шаге 4 порядок аргументов имеет значение: secret_key — это HMAC от bot_token с ключом “WebAppData”, а не наоборот. Половина реализаций в npm-пакетах путает порядок. Если auth «вроде работает, но иногда невалидный» — проверяй именно это.
Сравнение хэшей обязательно через timing-safe функцию (в Node.js это crypto.timingSafeEqual). Обычное === уязвимо к timing-атакам, особенно через медленный сервер на бесплатном тарифе хостинга.
Проверка auth_date критична. Без неё initData можно один раз вытащить из логов или DevTools и переиспользовать неограниченно во времени. С таймаутом 24 часа — окно атаки конечное.
Не пиши свою проверку «вручную через split + map» — используй URLSearchParams, потому что значения внутри initData URL-encoded, и наивный парсинг сломается на пользователях с эмодзи в имени.
Авто-логин на фронтенде
В корневом AuthContext при загрузке приложения:
Загружаем Telegram SDK через тег в (синхронно, поэтому к моменту монтирования React-компонентов window.Telegram.WebApp уже доступен).
Если initData непустой — отправляем POST на /api/auth/telegram с этой строкой, получаем JWT, кладём в localStorage, грузим профиль через /api/auth/me.
Если initData пустой — обычный flow с refresh-куки, либо редирект на лендинг.
Один важный нюанс: на страницах /login и /register авто-логин по initData
нужно отключить. Иначе пользователь, который хочет переключиться с
demo-аккаунта на реальный, никогда не сможет — каждая загрузка /register будет возвращать его в demo через initData. Я наступил на эти грабли в первый день после запуска.
? Кстати, если уже стало интересно потрогать что получилось — демо доступно прямо в Telegram, 3 минуты без регистрации. А ниже — про то, как я упёрся в серверные лимиты.
Часть 4. Серверные лимиты для демо-сессии
Demo-юзер может попробовать голосовой диалог 3 минуты в сутки. Лимит обязан быть на сервере — клиент не доверенный, любой localStorage или таймер в браузере обходится за минуту в DevTools.
Схема данных
В таблицу пользователей я добавил пять колонок: telegram_id (с уникальным
индексом среди не-NULL значений), флаг is_demo_user, счётчик demo_seconds_used, время начала текущего 24-часового окна demo_session_started_at и timestamp первого использования demo_used_at (нужен для будущей конверсии в платный тариф — даю 7 дней бонуса при регистрации после демо).
Важно про индекс: telegram_id — частичный уникальный индекс с условием WHERE
telegram_id IS NOT NULL. Это позволяет иметь сколько угодно пользователей без telegram_id (обычные регистрации через email), но гарантирует уникальность
среди тех, у кого он есть.
Логика лимита
При попытке начать голосовую сессию сервер проверяет:
Если пользователь не demo — пропускаем эту проверку, идём в обычный биллинг.
Если у demo-юзера demoSessionStartedAt старше 24 часов или null — сбрасываем счётчик секунд в 0, ставим текущее время как начало нового окна, разрешаем сессию с полным бюджетом 180 секунд.
Если окно ещё свежее, но demoSecondsUsed уже ≥ 180 — отказ с указанием времени, когда снова можно (demoSessionStartedAt + 24h).
Иначе — разрешаем сессию с остатком 180 - demoSecondsUsed секунд.
При завершении сессии (или дисконнекте) — добавляем фактически потраченное время к счётчику.
Хард-cutoff в WebSocket gateway
Голосовая сессия идёт через Socket.IO. На старте сессии для demo-юзера сервер запускает setTimeout на оставшийся бюджет (например, 180 секунд для свежего окна или 47 секунд если юзер уже потратил 133). По истечении таймера сервер сам отправляет событие voice:demo_limit_reached и принудительно завершает сессию через тот же путь, что и обычный voice:stop.
Главное: если клиент ничего не отправит, не нажмёт кнопку «стоп», или попытается обмануть таймер на фронте — серверный таймер всё равно сработает. Обойти со стороны клиента нельзя.
Параллельно сервер шлёт фронту событие voice:demo_budget с текущим остатком — чтобы UI мог отрендерить прогресс-бар. Фронтенд при этом просто визуализирует значение, не управляет логикой.
Часть 5. Грабли, не вошедшие в основные секции
Telegram webview сохраняет initData при переходах
Когда demo-юзер кликает «Зарегистрироваться» из Mini App, ссылка по умолчанию открывается внутри того же Telegram webview. Там доступен тот же initData от Telegram, и фронтовый авто-логин на странице регистрации снова отправит его как demo. Юзер не может зарегистрироваться как реальный пользователь — попадает в бесконечный цикл «открыл регистрацию → залогинило как demo →редиректнуло обратно в demo».
Фикс: открывать ссылку через Telegram.WebApp.openLink() — это документированный API, который гарантированно открывает URL в внешнем браузере, а не в webview. Снаружи Telegram fallback на обычный window.open(url, ‘_blank’).
bubblewrap update не регенерирует AndroidManifest.xml
После правки twa-manifest.json команда bubblewrap update обновляет build.gradle и ресурсы, но не трогает AndroidManifest.xml. Если ты, например, изменил enableSiteSettingsShortcut с true на false в манифесте, активность ManageDataLauncherActivity всё равно останется в XML. Удаляй вручную или пересобирай проект с нуля через bubblewrap init.
Скриншоты RuStore: 9:16, не 9:19.5
Современные телефоны (Xiaomi, Samsung, Pixel) делают скриншоты в соотношении 9:19.5 (1080×2400 или 1080×2078). RuStore требует минимум 9:16 — иначе отказ модерации.
Фикс: кропать через ImageMagick или любой другой инструмент пакетной обработки. Сдвиг ~80 пикселей сверху обычно обрезает статусбар, оставляя контент.
Railway Postgres UI и RETURNING
Не относится напрямую к Telegram, но грабля общая для всех, кто работает с Railway: если делаешь UPDATE через UI Railway-Postgres консоль, добавляй RETURNING в конец запроса — иначе транзакция в их UI не коммитится даже при отображении сообщения «success». Стоило мне получаса дебага.
С RETURNING коммит происходит, обновлённое значение видно в результатах, всё
работает. Без RETURNING запрос показывает «success», но в БД ничего не
меняется.
Итоги
TWA в RuStore работает, но с оговорками:
Если в приложении только web-функциональность — даже с микрофоном/камерой через WebRTC — модерация может отказать с формулировкой «приложение является обёрткой над сайтом»
Бюджет на нативные экраны (онбординг, профиль, настройки) обязательно закладывай при выборе TWA для RuStore
PWABuilder для прототипа норм, но Bubblewrap CLI даёт необходимый контроль для боевой публикации
Telegram Mini App — рабочая альтернатива для PWA:
Никакой модерации — публикация через @BotFather за 15 минут
Авто-логин по initData — пользователь не вводит email/пароль, конверсия растёт
Тот же фронтенд работает и в браузере, и в Mini App — двух кодовых баз не нужно
Серверные лимиты обязательны: клиент не доверяй
Что бы я делал по-другому:
Не публиковал TWA в RuStore «как есть» — сначала прочитал бы требования к «оригинальному наполнению» на сайте RuStore
Закладывал ~30-60 минут на настройку JDK/Android SDK для Bubblewrap (это нормальная цена контроля)
Изначально открывал бы любые внешние ссылки из Mini App через Telegram.WebApp.openLink(), не через
Не верил npm-пакетам с HMAC-проверкой initData без аудита — там встречаются уязвимые реализации (порядок аргументов, отсутствие timing-safe сравнения, отсутствие проверки auth_date)
После всей этой одиссеи продукт живёт сразу в трёх местах, и для разных
пользователей удобны разные форматы:
— ? Telegram Mini App — самый быстрый старт. Открыл бота, дал доступ к
микрофону, 3 минуты живого диалога с AI без регистрации: ? t.me/aiteacher_emma_bot/emma
— ? Веб-версия — если вы на iPhone (где TG Mini App ограничен по
микрофону) или просто не пользуетесь TG. Полный функционал, бесплатный пробный тариф:
? speakwithai.pro
— ? RuStore — Android-приложение. Капаситоровскую сборку приняли 29
апреля 2026 после трёх попыток, и теперь оно живёт там как полноценный нативный app:
? Speakwithai в RuStore
❓ К читателям: а как у вас обстоят дела с RuStore? Тоже видели «признаки браузерного окна» в отказах, или у кого-то TWA проходит модерацию с первого раза? В комментах будет полезно собрать опыт.