Как создать веб-приложение на базе Telegram Mini Apps

от автора

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 — от создания простого скрипта до деплоя на сервер. Полученные знания можно использовать при работе с более крупными проектами. Видеоверсия инструкции доступна по ссылке.

Автор: Владилен Минин, создатель YouTube-канала.


ссылка на оригинал статьи https://habr.com/ru/articles/783462/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *