Как создать веб-приложение на базе Telegram Mini Apps
Показываем, как завести и настроить Telegram-приложение на базе Angular и telegraf, а после — задеплоить на сервер.
Введение
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 и JavScript — выбирайте инструменты из своих предпочтений.
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 — от создания простого скрипта до деплоя на сервер. Полученные знания можно использовать при работе с более крупными проектами. Вне зависимости от того, какой они сложности, — в Selectel есть подходящая конфигурация.
Автор: Владилен Минин, создатель YouTube-канала.