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

tww28oi1ufaavnkbir6prezoodm.png


Telegram Mini Apps — отличная возможность выйти за пределы обычных ботов и попробовать себя в создании более интересных интерфейсов приложений. На базе этого инструмента можно создать магазин или даже сервис для заказа шавермы.

В этой статье познакомимся с Telegram Mini Apps и попробуем создать простое приложение. Сделаем это с использованием обновленного Angular 17 и telegraf, а в конце — задеплоим проект на виртуальный сервер.

Инициализация бота


1. Для начала создаем новый проект Node.js, в котором мы объединим Angular и telegraf:

npm init -y


2. Следующим этапом нужно создать Telegram-бота. Для этого понадобится API-токен, который можно получить у @BotFather с помощью команды /newbot:

igswwhibgnxzmlqylr38sot0okq.png


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, чтоб добавить красивую кнопку запуска приложения в нашем боте:

hoykjo_p8_idx5cqxz6hwqq58ui.png


Создание веб-приложения на 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:

...

...


В ./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: ``,
})
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: `
    

{{ title }}

{{ subtitle }}

    @for (product of products; track product.id) {
  • {{ product.title }}

    {{ product.text }}

    {{ product.time }}

  • }
`, }) 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: `
    
    
    
  `,
})
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: `
    

{{ product.title }}


{{ product.text }}

{{ product.time }}

Посмотреть курс
`, }) 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: `
    

Обратная связь

`, }) 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')
})


Супер — бот и приложение могут коммуницировать друг с другом!

1hdqmj1bvguax5hnugdz0ci_jbw.jpeg


Деплой проекта на облачный сервер


Сейчас бот запущен на компьютере. Это неудобно, если вы хотите записывать истории круглосуточно. Ведь тогда нужно поддерживать бесперебойную работу компьютера и постоянное соединение с интернетом. Бота лучше перенести в облако.

Подготовка


Будем деплоить бота в 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. Переходим в раздел Облачная платформа внутри панели управления:

il-wthfm2zbhx8fcegcdizwcajg.png


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

2s2ucq5llrdtklse_pyogrmwpyc.png


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

338znhq_tljcgsj0ue9ewss-rhy.png


4. Обновляем систему и устанавливаем Git:

apt update
apt install git


5. Устанавливаем Node.js — полная инструкция доступна в Академии Selectel:

curl -o-  | 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-канала.

© Habrahabr.ru