
Telegram Mini Apps — отличная возможность выйти за пределы обычных ботов и попробовать себя в создании более интересных интерфейсов приложений. На базе этого инструмента можно создать магазин или даже сервис для заказа шавермы.
В этой статье познакомимся с Telegram Mini Apps и попробуем создать простое приложение. Сделаем это с использованием обновленного Angular 17 и telegraf, а в конце — задеплоим проект на виртуальный сервер.
Инициализация бота
1. Для начала создаем новый проект Node.js, в котором мы объединим Angular и telegraf:
npm init -y
2. Следующим этапом нужно создать Telegram-бота. Для этого понадобится API-токен, который можно получить у @BotFather с помощью команды /newbot:

3. Устанавливаем telegraf и описываем базовую структуру программы в файле main.js:
import { Telegraf, Markup } from 'telegraf' import { message } from 'telegraf/filters' const token = '6908588510:AAGJ8Lhf_ItjNl9gQoCnK7IejRWQHWpPfiE' const webAppUrl = 'https://vk.com/' const bot = new Telegraf(token) bot.command('start', (ctx) => { ctx.reply( 'Добро пожаловать! Нажмите на кнопку ниже, чтобы запустить приложение', Markup.keyboard([ Markup.button.webApp('Отправить сообщение', `${webAppUrl}/feedback`), ]) ) }) bot.launch()
Markup позволяет отправлять пользователю клавиатуру в ответ на команду start. API-токен бота при желании можно вынести в конфигурацию — пример есть в прошлой инструкции.
4. Далее добавим структуру в package.json — это нужно инициализации main.js:
"type": "module", "scripts": { "start": "node main.js" },
5. В BotFather пропишем команду /setmenubutton, чтоб добавить красивую кнопку запуска приложения в нашем боте:

Создание веб-приложения на Angular
Теперь создадим новый проект для веб-приложения на Angular. На самом деле, вместо него можно использовать нативную связку из HTML, CSS и JavaScript — выбирайте инструменты из своих предпочтений.
npm install -g @angular/cli ng new tg-angular-app
Создадим необходимые страницы для сайта с помощью Angular CLI. Это довольно удобный способ добавлять новые сущности и сервисы в проект:
ng g c pages/feedback ng g c pages/product ng g c pages/shop
ng g s services/products ng g s services/telegram
Теперь подключим библиотеку Telegram к index.html в секции head:
... <script src="https://telegram.org/js/telegram-web-app.js"></script> ...
В ./src/app/services/telegram.service.ts пропишем базовый функционал:
import { DOCUMENT } from '@angular/common'; import { Inject, Injectable } from '@angular/core'; // интерфейс для функционала кнопок interface TgButton { show(): void; hide(): void; setText(text: string): void; onClick(fn: Function): void; offClick(fn: Function): void; enable(): void; disable(): void; } @Injectable({ providedIn: 'root', }) export class TelegramService { private window; tg; constructor(@Inject(DOCUMENT) private _document) { this.window = this._document.defaultView; this.tg = this.window.Telegram.WebApp; } get MainButton(): TgButton { return this.tg.MainButton; } get BackButton(): TgButton { return this.tg.BackButton; } sendData(data: object) { this.tg.sendData(JSON.stringify(data)); } ready() { this.tg.ready(); } }
Выше описан сервис, который получает доступ к глобальному объекту window и Telegram. Также в коде добавлены удобные типизированные методы для работы с библиотекой внутри Angular.
Далее app.component.ts добавим роутинг в поле template, чтобы Angular знал, куда рендерить динамические страницы. После подключаем ранее созданный Telegram-сервис и вызываем метод ready, чтобы он знал, когда приложение готово к работе:
import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { TelegramService } from './services/telegram.service'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, RouterOutlet], template: `<router-outlet />`, }) export class AppComponent { telegram = inject(TelegramService); constructor() { this.telegram.ready(); } }
В app.routes.ts добавим следующую конфигурацию для трех страниц:
import { Routes } from '@angular/router'; import { ShopComponent } from './pages/shop/shop.component'; import { FeedbackComponent } from './pages/feedback/feedback.component'; import { ProductComponent } from './pages/product/product.component'; export const routes: Routes = [ { path: '', component: ShopComponent, pathMatch: 'full' }, { path: 'feedback', component: FeedbackComponent }, { path: 'product/:id', component: ProductComponent }, ];
Далее создадим сервис для работы со списком продуктов в services/product.services.ts. Ниже привожу пример на базе списков обучающих программ по программированию:
import { Injectable } from '@angular/core'; const domain = 'https://result.school'; export enum ProductType { Skill = 'skill', Intensive = 'intensive', Course = 'course', } export interface IProduct { id: string; text: string; title: string; link: string; image: string; time: string; type: ProductType; } function addDomainToLinkAndImage(product: IProduct) { return { ...product, image: domain + product.image, link: domain + product.link, }; } const products: IProduct[] = [ { id: '29', title: 'TypeScript', link: '/products/typescript', image: '/img/icons/products/icon-ts.svg', text: 'Основы, типы, компилятор, классы, generic, утилиты, декораторы, advanced...', time: 'С опытом • 2 недели', type: ProductType.Skill, }, { id: '33', title: 'Продвинутый JavaScript. Создаем свой Excel', link: '/products/advanced-js', image: '/img/icons/products/icon-advanced-js.svg', text: 'Webpack, Jest, Node.js, State Managers, ООП, ESlint, SASS, Data Layer', time: 'С опытом • 2 месяца', type: ProductType.Intensive, }, { id: '26', title: 'Марафон JavaScript «5 дней — 5 проектов»', link: '/products/marathon-js', image: '/img/icons/products/icon-marathon-five-x-five.svg', text: 'плагин для картинок, мини-кол Trello, слайдер картинок, мини-игра, анимированная игра', time: 'С нуля • 1 неделя', type: ProductType.Course, }, ]; @Injectable({ providedIn: 'root', }) export class ProductsService { readonly products: IProduct[] = products.map(addDomainToLinkAndImage); // получаем конкретный продукт getById(id: string) { return this.products.find((p) => p.id === id); } // для удобного распределения по блокам в компоненте get byGroup() { return this.products.reduce((group, prod) => { if (!group[prod.type]) { group[prod.type] = []; } group[prod.type].push(prod); return group; }, {}); } }
Создадим также компонент для отображения списка элементов и добавим в него код:
ng g c components/product-list
import { Component, Input } from '@angular/core'; import { IProduct } from '../../services/products.service'; import { RouterLink } from '@angular/router'; @Component({ selector: 'app-product-list', standalone: true, imports: [RouterLink], // подключаем директиву, которая работает в шаблоне template: ` <h2 class="mb">{{ title }}</h2> <h4 class="mb">{{ subtitle }}</h4> <ul class="products"> @for (product of products; track product.id) { <li class="product-item" [routerLink]="'/product/' + product.id"> <div class="product-image"> <img [src]="product.image" [alt]="product.title" /> </div> <div class="product-info"> <h3>{{ product.title }}</h3> <p class="hint">{{ product.text }}</p> <p class="hint">{{ product.time }}</p> </div> </li> } </ul> `, }) export class ProductListComponent { // прописываем входящие параметры в компонент и их тип @Input() title: string; @Input() subtitle: string; @Input() products: IProduct[]; }
Обратите внимание на новый синтаксис итерации внутри шаблона с директивой for. По сути, этот компонент просто принимает три входящих параметра и выводит их красиво в шаблон.
Далее реализуем shop-page.component.ts:
import { ProductsService } from './../../services/products.service'; import { Component, inject } from '@angular/core'; import { TelegramService } from '../../services/telegram.service'; import { ProductListComponent } from '../../components/product-list/product-list.component'; @Component({ selector: 'app-shop', standalone: true, imports: [ProductListComponent], // регистрация компонента template: ` <app-product-list title="Отдельный навык" subtitle="Изучите востребованные технологии, чтобы расширить свой стек и добавить заветную галочку в резюме" [products]="products.byGroup['skill']" /> <app-product-list title="Интенсивы" subtitle="Экспресс-программы, где за короткий период вы получаете максимум пользы" [products]="products.byGroup['intensive']" /> <app-product-list title="Бесплатные курсы" subtitle="Необходимые навыки и проекты в портфолио за ваши старания" [products]="products.byGroup['course']" /> `, }) export class ShopComponent { // подключаем сервисы в компонент telegram = inject(TelegramService); products = inject(ProductsService); // прячем кнопку назад внутри телеграм constructor() { this.telegram.BackButton.hide(); } }
Остальные страницы тоже не оставим без внимания:
import { Component, OnDestroy, OnInit } from '@angular/core'; import { IProduct, ProductsService } from '../../services/products.service'; import { TelegramService } from '../../services/telegram.service'; import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'app-product', standalone: true, template: ` <div class="centered"> <h2 class="mb">{{ product.title }}</h2> <br /> <img [src]="product.image" [alt]="product.title" /> <p>{{ product.text }}</p> <p>{{ product.time }}</p> <a [href]="product.link" target="_blank">Посмотреть курс</a> </div> `, }) export class ProductComponent implements OnInit, OnDestroy { product: IProduct; constructor( private products: ProductsService, private telegram: TelegramService, private route: ActivatedRoute, private router: Router ) { // получаем динамический айди из адресной строки const id = this.route.snapshot.paramMap.get('id'); // получаем конкретный продукт из сервиса this.product = this.products.getById(id); this.goBack = this.goBack.bind(this); } goBack() { this.router.navigate(['/']); } ngOnInit(): void { this.telegram.BackButton.show(); // добавляем функционал для перехода назад в телеграм this.telegram.BackButton.onClick(this.goBack); } ngOnDestroy(): void { this.telegram.BackButton.offClick(this.goBack); } }
pages/product.component.ts — выводит детальные данные отдельного продукта, найденного по id.
import { Component, OnDestroy, OnInit, signal } from '@angular/core'; import { TelegramService } from '../../services/telegram.service'; @Component({ selector: 'app-feedback', standalone: true, styles: ` .form { heigth: 70vh; justify-content: center; } `, template: ` <form class="centered form"> <h2 class="mb">Обратная связь</h2> <textarea [value]="feedback()" (input)="handleChange($event)" class="form-control" ></textarea> </form> `, }) export class FeedbackComponent implements OnInit, OnDestroy { // создаем стейт через сигнал feedback = signal(''); constructor(private telegram: TelegramService) { this.sendData = this.sendData.bind(this); } ngOnInit(): void { this.telegram.MainButton.setText('Отправить сообщение'); this.telegram.MainButton.show(); this.telegram.MainButton.disable(); this.telegram.MainButton.onClick(this.sendData); } sendData() { // отправляем данные в телеграм this.telegram.sendData({ feedback: this.feedback() }); } handleChange(event) { // изменение стейта при изменении textarea this.feedback.set(event.target.value); if (this.feedback().trim()) { this.telegram.MainButton.enable(); } else { this.telegram.MainButton.disable(); } } ngOnDestroy(): void { this.telegram.MainButton.offClick(this.sendData); } }
pages/feedback-component.ts. В последнем компоненте обратите внимание на использование signal в качестве local state.
Деплой фронтенда с Firebase
Чтобы связать наш фронтенд с Telegram, его нужно захостить. Переходим в Firebase, делаем новый проект и открываем Hosting. Далее по инструкции устанавливаем пакеты, а после — локально:
firebase login firebase init
В файле firebase.json обновляем публичный путь до приложения:
"public": "dist/[PROJECT-NAME]/browser"
Деплоим и получаем публичный URL:
firebase deploy
Публичный URL заносим в константу webAppUrl в боте. Теперь при его запуске мы видим наше приложение.
Связка бота и веб-приложения
До этого в feedback.component.ts мы добавили отправку данных из формы:
sendData() { this.telegram.sendData({ feedback: this.feedback() }); }
Теперь эти данные мы можем обработать в боте:
bot.on(message('web_app_data'), async (ctx) => { const data = ctx.webAppData.data.json() ctx.reply(`Ваше сообщение: ${data?.feedback}` ?? 'empty message') })
Супер — бот и приложение могут коммуницировать друг с другом!

Деплой проекта на облачный сервер
Сейчас бот запущен на компьютере. Это неудобно, если вы хотите записывать истории круглосуточно. Ведь тогда нужно поддерживать бесперебойную работу компьютера и постоянное соединение с интернетом. Бота лучше перенести в облако.
Подготовка
Будем деплоить бота в Docker — добавим два файла:
FROM node:16-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . ENV PORT=3000 EXPOSE $PORT CMD ["npm", "start"]
Dockerfile.
build: docker build -t tgbot . run: docker run -d -p 3000:3000 --name tgbot --rm tgbot
Makefile.
Загрузка проекта
1. Переходим в раздел Облачная платформа внутри панели управления:

2. Создаем сервер. Для работы нашего приложения много мощностей не нужно, поэтому будет достаточно одного ядра vCPU с долей 20% и 512 МБ оперативной памяти:

3. Авторизуемся на сервере через консоль:

4. Обновляем систему и устанавливаем Git:
apt update apt install git
5. Устанавливаем Node.js — полная инструкция доступна в Академии Selectel:
curl -o- <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh> | bash source ~/.bashrc nvm install 20 nvm use 20 npm -v node -v
6. Устанавливаем на сервер Docker по инструкции.
7. Создаем репозиторий на GitHub, загружаем туда с компьютера наш проект и клонируем на сервер:
apt install git git clone REPO_URL
8. Запускаем проект:
cd PROJECT_NAME make build make run
Готово — бот c Telegram Mini Apps запущен.
Заключение
В этой статье мы не просто сделали интересное приложение, а изучили основы Telegram Mini Apps — от создания простого скрипта до деплоя на сервер. Полученные знания можно использовать при работе с более крупными проектами. Видеоверсия инструкции доступна по ссылке.
Автор: Владилен Минин, создатель YouTube-канала.
