DEV Community

Cover image for Building Telegram Mini App with Drayman Framework
Yan Ivan Evdokimov
Yan Ivan Evdokimov

Posted on

Building Telegram Mini App with Drayman Framework

In this tutorial, we'll create a Telegram mini app that will allow users to match with movies. We'll use Drayman framework to create this app and go trough some major features of Telegram mini apps.

You can find the final code of this app here: tg-movie-matcher-app.

If you want to check final result, share t.me/movie_matcher_bot/app link to your friend in Telegram, launch mini app and start movie matching!

Multiplayer

Initialization with Drayman Framework

Drayman Framework was chosen for the Telegram Mini App development because of its fast setup and unified full-stack development - combining server and client side inside components. It is also very easy to use and has a lot of features that are useful for Telegram Mini Apps.

Let's start by initializing and starting new Drayman project:

npx @drayman/framework-init@latest tg-movie-matcher-app
cd tg-movie-matcher-app
npm start
Enter fullscreen mode Exit fullscreen mode

"Drayman started at http://localhost:3033" text will be printed in the console. Open this URL in your browser and you'll see Drayman welcome page:

Drayman welcome page

Installing Necessary Packages

Let's install all packages we'll need for this app:

  • telegraf - Telegram bot API wrapper, we'll use it to work with Telegram API.
  • @lottiefiles/lottie-player - Lottie animation player, we'll use it to play animated stickers.
  • chroma-js - color manipulation library, we'll use it to generate correct color for movie rating badge.
  • drayman-swipi-cards - Drayman component for swiping cards.
  • js-confetti - confetti animation library, we'll use it to show confetti animation when user matches with a movie.
  • nanoid - library to generate unique IDs, we'll use it to generate unique IDs for user connections.
  • node-themoviedb - library to work with The Movie Database API, we'll use it to get movies data.
npm install telegraf@4.14.0 @lottiefiles/lottie-player@2.0.2 chroma-js@2.4.2 drayman-swipi-cards@2.0.9 js-confetti@0.11.0 nanoid@3.3.4 node-themoviedb@0.2.8 --save-exact
Enter fullscreen mode Exit fullscreen mode

Modifying index.html, adding styles.css and assets

Some packages we've installed require scripts to be added to index.html file. Also you'll need to add telegram-web-app.js script to enable Telegram mini app mode. Here is the final index.html file:

<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <title>Movie Matcher Telegram Mini App</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
  <script src="/drayman-framework-client.js"></script>
  <script src="/node_modules/drayman-swipi-cards/dist/swipi-cards/swipi-cards.js"></script>
  <script src="/node_modules/js-confetti/dist/js-confetti.browser.js"></script>
  <script src="/node_modules/@lottiefiles/lottie-player/dist/lottie-player.js"></script>
  <link rel="stylesheet" href="styles.css" />
</head>
Enter fullscreen mode Exit fullscreen mode

As you can see, we've also added styles.css file to index.html. Don't forget to create this file in public folder next to index.html file. Copy the code from this link: styles.css and paste it to your styles.css file.

Create stickers folder in public folder and copy all stickers from this link: stickers.

Retrieving API Tokens

Now let's retrieve required tokens for our app.

  • Telegram token - BOT_TOKEN - create a new bot using /newbot command in BotFather and copy the token.
  • The Movie Database API key - MOVIE_DB_API_KEY - create a new account on The Movie Database and copy the API key from API page.

Setting up ngrok for HTTPS Tunneling

One of the Telegram requirements is that web app must be hosted on HTTPS, so we'll use ngrok to create a tunnel to our local server. Install it and after that, I suggest you to create a ngrok account to create a static domain. This way you'll be able to use the same link to your app every time you start it. Here are official instructions on how to do it: free static domain for all ngrok users.

After retrieving your domain, start ngrok tunnel:

ngrok http --domain=[static-domain] 3033
Enter fullscreen mode Exit fullscreen mode

In result, you'll see something like this:

ngrok

Integrating with Telegram

Copy this HTTPS link and using BotFather create a new app with /newapp command. When asked for a link to the app, paste the link you've copied from ngrok. As the short app name, you can simply type app. In result, you'll get a link to your app, something like this: t.me/movie_matcher_test_bot/app.

We can also add menu button to our bot to launch our app. To do this, go to BotFather and select your bot. Then select Bot Settings and then Menu Button, Configure menu button. Send him a link you got from ngrok. After that, you'll be able to launch your app by clicking on the menu button in your bot. However, we haven't added Telegram bot API support to our app yet, so it won't work. Let's do it now.

Adding Telegram Bot API Support

Let's integrate Telegram bot API support to our app now. Create a new Server index.ts file in src folder and add the following code to it:

import { Telegraf } from "telegraf";
import { message } from "telegraf/filters";
import express from "express";
import MovieDB from "node-themoviedb";

export const Server: DraymanServer = async ({ EventHub, app }) => {
  app.use("/node_modules", (req, res, next) =>
    express.static("node_modules")(req, res, next)
  );
  const mdb = new MovieDB(process.env.MOVIE_DB_API_KEY);
  const tgToken = process.env.BOT_TOKEN;
  const bot = new Telegraf(tgToken);
  bot.launch();
  bot.on(message(), (ctx) => {
    return ctx.replyWithHTML(`🎬 <b>Welcome to Movie Matcher!</b> 🎥

Choose your genres and years, and <b>swipe through</b> our top movie picks. 

To match with <b>friends</b> share the <b>app link</b> - t.me/movie_matcher_test_bot/app.

If you're in the mood for <b>solo</b> discovery, use the <b>menu button</b> 

When everyone <b>swipes right</b> on a movie, it's popcorn time! 

Dive in and elevate your movie nights!
`);
  });
};
Enter fullscreen mode Exit fullscreen mode

In this code we've initialized Telegraf bot API wrapper and The Movie Database client. We've also added a handler for any message that will be sent to our bot and replied with a welcome message. Please make sure that you use your own link to the app in the message.

Start our app, providing required tokens as environment variables:

MOVIE_DB_API_KEY=<your-movie-db-token> BOT_TOKEN=<your-telegram-token> npm start
Enter fullscreen mode Exit fullscreen mode

Now open your bot in Telegram and you'll see the welcome message:

welcome

Click "Start Matching" button you've added earlier and mini app will be launched:

mini app welcome

We have finished main setup of our app. You can use these instructions and code above as boilerplate for your own Telegram app. But we'll continue and add more features to our Movie Matcher app.

Defining the Movie Matcher app flow

Before we start implementing our app, let's define the flow of it.

  • User opens the app.
    • When user can't be validated, failed validation screen will be shown.
    • When matching is in progress, user will see the matching screen.
    • When matching is not in progress, user will see the setup screen where he can select genres and years and can see connected users. User can also click "Start" button.
    • When user selectes genres or years (genres screen, years screen), "Back" button will appear and user will be able to go back to the setup screen.
    • When user clicks "Start" button, and there movies with selected genres and years can be found, matching will start and user will see the matching screen.
    • When user clicks "Start" button, and there are no movies with selected genres and years, user will see the no movies screen.
    • When user disconnects from setup screen, connected users list will be updated.
  • User is in the matching screen.
    • "Start" button will be hidden and "Back" button will appear.
    • User can swipe left movie card to dislike the movie and swipe right to like the movie.
    • When all users swipe right on the same movie, confetti animation will be shown, notifcation message will be sent from the bot and users will see the movie selected screen.
    • When user made selection and there are no more movies to swipe, user will see the waiting for others screen.
    • When all users made selection and haven't matched with any movie, users will see the no movie selected screen.
    • When user disconnects from matching screen, likes and dislikes will be recalculated according to the new number of users.
    • When user clicks "Back" button, all users will be redirected to the setup screen and matching will be stopped.
  • Connected users list is created based on chat from which a link to the app was sent. When a user opens the app link within the same chat, he will be added to the list.

Setting up browser commands and events

Inside index.html, change <script> tag to this:

<script>
  const jsConfetti = new JSConfetti();
  initializeDraymanFramework({
    browserCommands: (emit) => ({
      getTelegramData: () => {
        window.Telegram.WebApp.ready();
        return {
          initData: window.Telegram.WebApp.initData,
          initDataUnsafe: window.Telegram.WebApp.initDataUnsafe,
          themeParams: window.Telegram.WebApp.themeParams,
          colorScheme: window.Telegram.WebApp.colorScheme,
          viewportHeight: window.Telegram.WebApp.viewportHeight,
        };
      },
      setMainButtonParams: (params) => {
        window.Telegram.WebApp.MainButton.setParams(params);
      },
      setBackButtonVisibility: ({ visible }) => {
        visible
          ? window.Telegram.WebApp.BackButton.show()
          : window.Telegram.WebApp.BackButton.hide();
      },
      explode: async () => {
        jsConfetti.addConfetti();
      },
      events: async ({
        onMainButtonClick,
        onViewportChanged,
        onBackButtonClick,
      }) => {
        Telegram.WebApp.onEvent("mainButtonClicked", () =>
          emit(onMainButtonClick)
        );
        Telegram.WebApp.onEvent("backButtonClicked", () =>
          emit(onBackButtonClick)
        );
        Telegram.WebApp.onEvent("viewportChanged", () =>
          emit(
            onViewportChanged,
            { viewportHeight: window.Telegram.WebApp.viewportHeight },
            { debounce: 300 }
          )
        );
      },
    }),
  });
</script>
Enter fullscreen mode Exit fullscreen mode

This code will initialize Drayman framework and set up browser commands and events. You can read about browser commands and events in Drayman framework docs here. Let's go through each command and event we've added:

  • getTelegramData - this command will return neccessary Telegram data that we'll use later to set up main component when it initializes.
  • setMainButtonParams - this command will set up main button parameters. We'll use it to change main button text and visibility.
  • setBackButtonVisibility - this command will set up back button visibility. We'll use it to show back button when user performs some actions.
  • explode - this command will trigger confetti animation. We'll use it to show confetti animation when users match with a movie.
  • events - this allows to listen for Telegram events and emit Drayman events. We'll use it to listen to main button click, back button click and viewport change events.

Validating user

First thing user will load when connecting to the app is home.tsx component. It is already created in src/components folder when you initialized Drayman framework project. It is a good place where main logic and UI of the app should be placed. Let's begin modifying this component by adding user validation because Telegram requires that user is validated before app can be used.

Create utils.ts file and place it to src folder next to index.ts file. Add the following code to it:

import crypto from "crypto";

export function verifyInitData(telegramInitData: string): boolean {
  const urlParams = new URLSearchParams(telegramInitData);
  const hash = urlParams.get("hash");
  urlParams.delete("hash");
  urlParams.sort();
  let dataCheckString = "";
  for (const [key, value] of urlParams.entries()) {
    dataCheckString += `${key}=${value}\n`;
  }
  dataCheckString = dataCheckString.slice(0, -1);
  const secret = crypto
    .createHmac("sha256", "WebAppData")
    .update(process.env.BOT_TOKEN);
  const calculatedHash = crypto
    .createHmac("sha256", secret.digest())
    .update(dataCheckString)
    .digest("hex");

  return calculatedHash === hash;
}
Enter fullscreen mode Exit fullscreen mode

verifyInitData function will be used to verify user data according to Telegram requirements. Let's use it in home.tsx component. Add the following code to home.tsx file:

import { verifyInitData } from "../utils";

export const component: DraymanComponent = async ({ Browser }) => {
  const data = await Browser.getTelegramData();

  if (!verifyInitData(data.initData)) {
    await Browser.setMainButtonParams({ is_visible: false });

    return () => {
      return <h1>Invalid data</h1>;
    };
  }

  return () => {
    return <h1>Valid data</h1>;
  };
};
Enter fullscreen mode Exit fullscreen mode

In this code we've added verifyInitData function to home.tsx file and used it to validate user data. If user data is invalid, we'll hide main button and show "Invalid data" text. Otherwise, we'll show "Valid data" text. We get initial data by calling Browser.getTelegramData command we defined earlier in "Setting up browser commands and events" section.

We can safely make validations here because in Drayman all components are server-side rendered and communication between browser (Browser object) and server (Server object) is done in a secure way.

Returning textual info seems boring, so let's add some stickers!

Sticker animation component

Create lottieAnimation.tsx file in src/components folder and add the following code to it:

export const component: DraymanComponent<{
  src: string;
  title?: string;
  overview?: string;
}> = async ({ props }) => {
  return async () => {
    return (
      <div class="lottie-animation">
        <lottie-player autoplay loop src={props.src}></lottie-player>
        {!!props.title && <div class="title">{props.title}</div>}
        {!!props.overview && <div class="overview">{props.overview}</div>}
      </div>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

This component will be used to show Lottie animation. We'll use it to show stickers. Let's modify home.tsx component to use this component:

import { verifyInitData } from "../utils";

export const component: DraymanComponent = async ({ Browser }) => {
  const data = await Browser.getTelegramData();

  if (!verifyInitData(data.initData)) {
    await Browser.setMainButtonParams({ is_visible: false });

    return () => {
      return (
        <lottieAnimation
          src="stickers/not_found.json"
          title="Oops, Something Went Wrong!"
          overview="We couldn't verify your identity."
        />
      );
    };
  }

  return () => {
    return <h1>Valid data</h1>;
  };
};
Enter fullscreen mode Exit fullscreen mode

Let's test it. Open your bot in Telegram and click "Start Matching" button. You'll see the following screen:

valid data

To test what happens when user data is invalid, change verifyInitData(data.initData) to verifyInitData(data.initData + 1) and you'll see the following screen (don't forget to return it back to verifyInitData(data.initData) after testing):

invalid data

Setting up server logic

We will now program logic defined before in "Defining the Movie Matcher app flow" section. Copy this index.ts and replace your index.ts file with it. Modify your utils.ts code with this code.

Let's go through the code we've added to index.ts file. Main logic of this script is around methods a server exposes to all components:

  • enterStage - will be called when user enters a stage (opens app). If there are no stages with specific chat_instance, new stage will be created. If there are stages with specific chat_instance, user will be added to the stage with the same chat_instance. After this method is called, stageUpdated event will be emitted to all components within the same chat_instance group.
  • exitStage - will be called when user exits a stage (disconnects from app). If there are no users left in the stage, stage will be deleted. If there are some users left in the stage and they are in movie selection state, movie selection gets recalculated using updateMovieSelectionState function. We will go trough this function later. After this method is called, stageUpdated event will be emitted to all components within the same chat_instance group.
  • restartStage - will be called when user restarts a stage (clicks "Back" button). It simply resets stage preserving users and movie options. After this method is called, stageUpdated event will be emitted to all components within the same chat_instance group.
  • changeMovieOption - will be called when user changes movie option (selects genres or years). After this method is called, stageUpdated event will be emitted to all components within the same chat_instance group.
  • startMovieSelection - will be called when user starts movie selection (clicks "Start" button). It will search for movies using movie API and selected years and genres and switch to movie selection scene. After this method is called,stageUpdated event to all components within the same chat_instance group.
  • rateMovie - will be called when user rates a movie (swipes left or right). It will call updateMovieSelectionState function which determines whether movieSelected or movieNotSelected. If so, stageUpdated event to all components within the same chat_instance group.

There is also updateMovieSelectionState function which counts likes and dislikes. If it determines that movie is selected (all users matched on one movie), it sends users a notification message and emits movieSelected event. If it determines that movie is not selected (all users made their selection and no movie was matched), it emits movieNotSelected event.

Matching main component to server logic

Now as we have server logic, let's match main component to it. Copy this home.tsx and replace your home.tsx file with it. Let's go through the code we've added to home.tsx file.

  • We receive stage state from server by using EventHub.on('stageUpdated') event handler. Inside this handler:
    • If user is on setup screen, component calls browser to show main button and sets main button text to "Start". It aslo hides back button if user was selecting genres or years.
    • If user is on matching screen, component calls browser to hide main button and show back button.
    • If user is on movie selected screen, component calls browser to show back button and calls explode browser command to show confetti animation.
    • If user is on no movie selected screen, component calls browser to show back button.
  • When user initializes component (ComponentInstance.onInit), a server call enterStage method is called.
  • When user destroys component (ComponentInstance.onDestroy), a server call exitStage method is called.
  • All events from browser are also handled:
    • When user clicks main button, startMovieSelection method is called.
    • When user clicks back button and user is not in setup screen, restartStage method is called.
    • When user changes viewport height, viewportHeight internal variable is updated (we will see how it is used later).

Based on state the component is in, it renders different screens. But now we can't launch our app because we haven't added some necessary components yet. Let's do it now.

Options button component

Add optionsButton.tsx file to src/components folder and add the following code to it:

export const component: DraymanComponent<{
  buttonLabel: string;
  selectedLabel: string;
  onSelect: () => Promise<void>;
}> = async ({ props }) => {
  return async () => {
    return (
      <div
        class="select clickable"
        onclick={async () => await props.onSelect()}
      >
        <div>{props.buttonLabel}</div>
        <div class="separator"></div>
        <div>{props.selectedLabel} ›</div>
      </div>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

This component will be used to show options button. We'll use it to show genres and years buttons. It is crafted to match Telegram UI style and matches user's theme color:

opt white

opt black

Options menu component

Add optionsMenu.tsx file to src/components folder and add the following code to it:

export const component: DraymanComponent<{
  header: string;
  options: any[];
  selectedOption: any;
  viewportHeight: number;
  onSelect: (data: { value: any }) => Promise<void>;
}> = async ({ props }) => {
  return async () => {
    return (
      <div
        class="options-menu right-to-left-appear"
        style={{ height: `${props.viewportHeight - 20}px` }}
      >
        <div class="option-header">{props.header}</div>
        <div class="select-wrapper">
          {props.options.map((option) => {
            return (
              <div
                class="select clickable"
                onClick={async () => await props.onSelect({ value: option })}
              >
                <div>{option.name}</div>
                <div class="separator"></div>
                <div>{props.selectedOption.id === option.id && `âś“`}</div>
              </div>
            );
          })}
        </div>
      </div>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

This component will be used to show options menu. It is crafted to match Telegram UI style and matches user's theme color:

menu dark

menu light

Movie card component

Add movieCard.tsx file to src/components folder and add the following code to it:

import chroma from "chroma-js";
import { genres } from "../utils";

export const component: DraymanComponent<{
  movie: any;
  viewportHeight: number;
}> = async ({ props }) => {
  const ratingScale = chroma.scale(["#e64d44", "#32b545"]).domain([0, 10]);

  return async () => {
    return (
      <div class="wrapper">
        <div
          class="fade-image"
          style={{
            "--dynamic-bg-image": `url('https://image.tmdb.org/t/p/w1280/${props.movie.backdrop_path}')`,
          }}
        >
          {!!props.movie.vote_count && (
            <div
              class="rating-circle"
              style={{
                background: ratingScale(props.movie.vote_average).hex(),
              }}
            >
              {props.movie.vote_average}
            </div>
          )}
        </div>
        <div class="title">{props.movie.title}</div>
        <div class="genre">{`${props.movie.genre_ids
          .map((x) => genres.find((xx) => xx.id == x).name)
          .join(", ")} | ${props.movie.release_date.slice(0, 4)}`}</div>
        <div
          class="overview"
          style={{ height: `${props.viewportHeight - 340}px` }}
        >
          {props.viewportHeight - 340 > 100 && (
            <div class="content">{props.movie.overview}</div>
          )}
        </div>
      </div>
    );
  };
};
Enter fullscreen mode Exit fullscreen mode

This component will be used to show movie card. It is crafted to match Telegram UI style and matches user's theme color:

card dark

card light

Movie card component has rating badge which is colored using chroma-js library based on movie rating.

Final look at the main component

Now we have everything set up. Let's check how our main component looks like.

Setup screen and year/genre selection screens

Main screen where user can select genres and years and see connected users. It matches Telegram UI style and matches user's theme color. It also has transition animation when user navigates between screens:

setup

Matching screen and movie selected screen

Matching screen is a screen where user can swipe movie cards. If all users swipe right on the same movie, confetti animation will be shown, notifcation message will be sent from the bot and users will see the movie selected screen:

matching screen

Waiting for others screen

When user made selection and there are no more movies to swipe and other users haven't made their selection yet, user will see the waiting for others screen:

waiting screen

No movie selected screen

When all users made selection and haven't matched with any movie, users will see the no movie selected screen:

no movie selected

No movies screen

When user clicks "Start" button, and there are no movies with selected genres and years, user will see the no movies screen:

no movie found

Invalid data screen

When user can't be validated, failed validation screen will be shown:

invalid data screen

Reacting to viewport height change

Some of components are using viewport height which is received from Telegram API. You can see method which returns viewport height in index.html file:

Telegram.WebApp.onEvent("viewportChanged", () =>
  emit(
    onViewportChanged,
    { viewportHeight: window.Telegram.WebApp.viewportHeight },
    { debounce: 300 }
  )
);
Enter fullscreen mode Exit fullscreen mode

It is then received in home.tsx file:

onViewportChanged: async (data) => {
  viewportHeight = data.viewportHeight;
  await forceUpdate();
};
Enter fullscreen mode Exit fullscreen mode

Then, it is passed to some components as a prop and height of elements is calculated based on it. This way we can make our app responsive to viewport height change and also hide or show some elements based on viewport height:

viewport change

Reacting to theme change

Because all component styles are built aroun Telegram CSS variables, our app will automatically change its style when user changes theme:

theme change

Multiplayer usage

If a link to the app (in our case it is t.me/movie_matcher_test_bot/app) is sent to a chat, all users who open this link within the same chat will be added to the same group. This way, users can match with each other. If a user opens the app link in a different chat, he will be added to a different group. This way, users can match with different people in different chats:

multiplayer

Publishing the app

The easiest way to publish you app is to use render. While they offer a free plan, it's not ideal for Telegram apps since it suspends them after periods of inactivity. However, their first paid plan is just $7/month, which is suitable for hosting small Telegram apps without significant traffic.

Some tips of publishing your app on render:

  • Don't forget to add BOT_TOKEN and MOVIE_DB_API_KEY environment variables. Also you'll need to add PORT environment variable and set it to 3033:

render

  • You'll also need to use npm install as build command and npm run start as start command:

render commands

Top comments (3)

Collapse
 
mloetsch profile image
Michael Lötsch

Nice, looks real good! Thanks for the tipps. The framework looks promising. Also the bot is quite nice! :) I have a question though, how do you manage to open the bot from within chats? If I display a t.me/mybotnameBot/menu for example it opens only the botchat and I manually have to click on the menu button to open the miniApp, do you have any idea where I could look to debug this? Thanks in advance

Collapse
 
mloetsch profile image
Michael Lötsch

ah found the solution! You have to actually create an app with /newapp and not just the bot! Thank you :)

Collapse
 
jansivans profile image
Yan Ivan Evdokimov

Thank you for your kind words, Michael! I'm glad to hear that you found the solution to your issue.