April 28

How I built a project for myself and won a prize from Telegram

It's the translation of my own article initially published on habr.com that gained over 18000 views.

Hello everyone! Telegram held a contest for the development of mini-apps, where my work won a prize. The competition was intense, as the choice of technologies and ideas for the application was free. Due to this, Telegram even increased the total value of the prizes. In this article, we'll take a look at how the development process went, what difficulties I faced, and what I learned.

About the contest

Telegram allows you to embed mini-applications in the messenger. An example of such an application is Wallet - a tool for storing and exchanging cryptocurrency within the messenger.

The task was to develop a useful mini-application, place the client and server code on GitHub. The application was evaluated both from the user's point of view and from the developer's point of view. According to the competition rules, it was necessary to create your own application, not to follow the provided design and instructions. This required not only working on the code but also on the idea and creating the user interface.

Why participate in contests?

  • Testing your competitiveness by independent evaluators. The contest doesn't care about your job title or how much work experience you have.
  • If you've gotten into a comfort zone while solving familiar tasks at work, a contest is a great way to try something new.
  • A special feature of this contest is the opportunity to earn money by creating your own project from scratch, from idea to implementation.

Idea for the bot

People forget information if they don't repeat it. Memory follows certain patterns, as identified by the scientist Ebbinghaus.

The forgetting curve by Ebbinghaus

The concept is simple but powerful: learning and memorization will be better if it's distributed over time. This is where spaced repetition comes in — a method of memorizing information based on regularly repeating the material at certain intervals. The method is suitable for memorizing various materials, including words, texts, facts, and language grammar rules.

For spaced repetition, I use the Anki program. You create decks, add cards to them, and the app suggests repeating only the information that requires repetition. It's a good tool, but I've found a number of significant drawbacks:

  • A lot of useful functionality in Anki is only available in the form of plugins. Plugins only work in the desktop version, which makes it inconvenient to repeat from your phone. Examples of important plugins are automatic card voicing to hear pronunciation, Heatmap for tracking progress in the style of GitHub, and the ability to go through all the decks at once without having to switch between them.
  • Anki doesn't have automatic push notifications for repetition reminders. Because of this, people simply forget to use it. Setting an alarm for every day is not the best idea, since there aren't cards to repeat every day. I would like to receive smart notifications that only notify me when there is a need.
  • Subjective opinion — Anki simply lacks a human touch. You need to configure and customize it, instead of just using it. For this reason, my acquaintances abandoned this app.

I've long wanted to make an alternative for myself, but couldn't find the time. The contest provided enough motivation to get started.

General information about mini-apps

How mini-apps work

Let's dive into each step of the scheme in detail.

Step 1 - The Telegram client interacts with the mini-application frontend. The Telegram client is any of the clients within which the mini-application runs. The mini-application is your website, publicly accessible with SSL configured. To make the messenger open this site, you need to create a bot through BotFather, get an API key, and specify which URL your bot will open. To do this, when communicating with BotFather, call /setmenubutton or go to Bot Settings > Menu Button to specify your URL.

Step 2 - The frontend interacts with the backend. The frontend will interact with the backend to connect with the database. To get information about the user, the frontend must pass it to the server, and the backend must validate it. Validation is needed to prevent unauthorized access, so the user cannot forge the data when sending it to the backend.

Here, a common question may arise - what's the benefit of mini-applications if the messenger simply opens my website? In fact, Telegram mini-applications offer many conveniences to both developers and users:

  • Authentication. A bot user is already logged in to Telegram, so you don't need to ask them to register. The developer doesn't need to develop or connect authentication or spend money on SMS.
  • Push notifications. They already work in Telegram, and the developer doesn't need to set them up.
  • The bot, like a website, doesn't need to be installed. Because it works inside Telegram, it's easy to share. Just send a link to the bot to your contacts, and they can use it. You can even share specific pages within the bot using the `startapp` parameter, for example, https://t.me/yourbot/app?startapp=product_id
  • A rich SDK that includes many helper elements: Main button, loader for the button, button to return to the previous screen, modal windows, QR code scanning. The full list is available in the documentation.

Technology Stack

Many of my decisions were dictated by limited time. Therefore, I used familiar tools — TypeScript for the frontend and backend, React and Mobx libraries for the user interface. To save time on server setup, I used cloud technologies. Now I realize that this was the right decision, which helped me focus on developing useful functionality. Cloud services:

  • Cloudflare Pages for a Node.js full-stack application. This gave me a domain with a configured SSL certificate, automatic frontend and backend deployment after a 'git push' to GitHub. To do this, it's enough to connect your repository in the Cloudflare Pages settings. The service provides 100,000 free API requests per day.
  • Supabase — an Open-Source database built on top of PostgreSQL, with a user interface for editing both data and its structure. If necessary, Supabase allows the use of raw SQL queries. For me, this was critical, as previous attempts to use cloud databases like Firestore ended in failure due to the inability to use convenient SQL for complex calculations. The service also provides the free use of a cloud database, so I didn't need to host PostgreSQL myself.

I quickly sketched out a database schema in the visual editor:

Supabase visual editor

Then, using the Supabase JavaScript SDK, you can send requests to save data to these tables. A pleasant surprise was that Supabase can generate TypeScript types based on the created schema, which further saved time:

export interface Database {
  public: {
    Tables: {
      card_review: {
        Row: {
          card_id: number
          created_at: string
          ease_factor: number
          interval: number
          last_review_date: string
          user_id: number
        }
        Insert: {
          card_id: number
          created_at?: string
          ease_factor?: number
          interval: number
          last_review_date?: string
          user_id: number
        }
        Update: {
          card_id?: number
          created_at?: string
          ease_factor?: number
          interval?: number
          last_review_date?: string
          user_id?: number
        }

Development and Spaced Repetition Algorithm

The user interface of the mini-app consisted of 3 parts: a list of decks with cards, a form for creating and editing a deck, and a mode for learning/repeating cards.

From left to right - the list of decks, editing a deck, repeating a deck

The essence of the spaced repetition algorithm:

  1. Learning new material
  2. Repeating this material after a certain time (for example, after a day).
  3. If the repetition was successful, the next repetition occurs after a longer time interval (for example, after a week).
  4. If the repetition was not successful, the next repetition occurs after a shorter time interval (for example, after a few hours)
  5. Repetition occurs until the information is memorized.

To implement the algorithm, let's write a function. Let it take as input a card and the result of the pass ("remember", "don't remember"), and return the next repetition time. In its simplest form, the algorithm should calculate the next repetition time of the card according to this scheme:

We see that next to the card, we need to store an interval that will show how many days later the card needs to be repeated. What if the user forgot the card? This means that this interval needs to be reset to its initial value. Let's represent this in code:

export type Result = {
  newInterval: number;
};

export type ReviewOutcome = "correct" | "wrong";
const easeFactor = 2.5;
const startInterval = 0.4;

export const reviewCard = (
  currentInterval: number,
  reviewOutcome: ReviewOutcome,
): Result => {
  const newInterval = reviewOutcome === "correct"
    ? newInterval * easeFactor
    : startInterval;

  return {
    newInterval: parseFloat(newInterval.toFixed(2)),
  };
};

However, this algorithm has a drawback. If the user forgets a card, it means that it is more difficult for them than others. Therefore, this word should be offered more frequently than words that the user has never forgotten. In the code, this means that for a word that the user forgets several times, the interval should grow more slowly. For example, like this:

Pay attention that at the end, we answered "Remember" 3 times in a row, but the interval grows more slowly than in the first example. To achieve this, we will also store in the card the coefficient by which we multiply the interval. With an incorrect answer, the coefficient decreases, which slows down the growth of the interval. With a correct answer, the coefficient increases, which increases the growth of the interval. We will also introduce an upper and lower limit for the coefficient to ensure that the cards do not appear too frequently or too rarely:

export type Result = {
  interval: number;
  easeFactor: number;
};

export type ReviewOutcome = "correct" | "wrong";

const startEaseFactor = 2.5;
const startInterval = 0.4;
const easeFactorDecrement = 0.15;
const minimumEaseFactor = 1.3;
const easeFactorIncrement = 0.1;

export const reviewCard = (
  interval: number | undefined = startInterval,
  reviewOutcome: ReviewOutcome,
  easeFactor: number | undefined = startEaseFactor
): Result => {
  if (reviewOutcome === "correct") {
    interval = interval * easeFactor;
    easeFactor = Math.min(easeFactor + easeFactorIncrement, startEaseFactor);
  } else if (reviewOutcome === "wrong") {
    easeFactor = Math.max(easeFactor - easeFactorDecrement, minimumEaseFactor);
    interval = startInterval;
  }

  return {
    easeFactor: parseFloat(easeFactor.toFixed(2)),
    interval: parseFloat(interval.toFixed(2)),
  };
};

After saving the result to the database, we can use an SQL query to calculate the cards that require repetition at the current moment:

-- Cards that needs to be repeated
SELECT cr.card_id,
       dc.deck_id AS deck_id
FROM card_review cr
INNER JOIN deck_card dc ON dc.id = cr.card_id
INNER JOIN deck d ON d.id = dc.deck_id
WHERE user_id = usr_id
  AND cr.last_review_date + (cr.interval::text || ' days')::INTERVAL < now()
-- Add new cards from decks to which the user has access.
UNION
SELECT c.id,
       ud.deck_id AS deck_id
FROM user_deck ud
LEFT JOIN deck_card c ON c.deck_id = ud.deck_id
LEFT JOIN card_review cr ON cr.card_id = c.id
AND cr.user_id = usr_id
WHERE ud.user_id = usr_id
  AND card_id IS NULL

Card Voiceover

For learning words, it is useful to hear how they are correctly pronounced. For example, in English, there are different pronunciations - American and British. In tonal languages like Thai, you may not be understood at all due to incorrect tone, even if you know how to write the word. Card voiceover is available in Anki only as a plugin, but I have long wanted to try the Speech Synthesis API built into browsers. It allows generating speech in different languages, including taking into account the accent. It is supported by 95% of users and it is very easy to work with:

function speak(text, lang) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = lang;
  window.speechSynthesis.speak(utterance);
}

// По-немецки
speak("Guten Tag, wie geht es Ihnen?", "de-DE");

// По-испански
speak("Hola, ¿cómo estás?", "es-ES");

// По-французски
speak("Bonjour, comment ça va?", "fr-FR");
Voiceover language selection

Challenges

Challenge 1 - Animation. During card repetition, the user decides whether they remember the card or not. It was decided to make the choice of "yes" or "no" using finger swiping. Swiping right would mean that the user remembers the card, swiping left - does not remember. The motivation was that the Telegram interface is well-animated, so I wanted to stand out. This was a mistake - not only did I spend a lot of time synchronizing finger movements and cards, but it also led to problems in the mini-app environment. In mobile clients, swiping left was recognized as swiping down, which unintentionally closed the mini-app. In the contest participants' chat, I learned that this is a common problem and developers use different workarounds, the reliability of which is questionable. I decided not to complicate things and just made 2 buttons - "I got it right" and "Need to review" similar to Anki. The lesson for contests is to first make the important functionality, and only then add details if possible.

Challenge 2 - Authentication. The Telegram SDK passes basic user information to the frontend in the form of global JavaScript variables. However, blindly trusting them on the backend is not safe - an attacker can spoof this data and impersonate another person. Therefore, user data needs to be validated on the backend. Along with the user information, Telegram generates a hash of this information, signed with your bot token. On the backend, you need to recalculate the hash and if they match, then the data can be trusted.

It was a surprise that to work with cryptography in Cloudflare Pages, you need to use the Web Crypto API instead of Node.js modules. The fact is that Cloudflare Workers operate in a unique environment that is neither a browser nor a Node.js server environment. The code runs on the Cloudflare network, whose runtime environment resembles a web browser's Service Worker. I was not familiar with this API, so I had to spend time writing a hash validation module instead of using a ready-made library. In order not to complicate the application with JWT tokens like other participants, it was decided to simply pass user data on each request in the form of an HTTP header:

const response = await fetch(endpoint, {
  method,
  body: bodyAsString,
  headers: {
    hash: WebApp.initData,
  },
});

Next, the backend validates this data and either creates or updates the user information in the database.

Challenge 3 - How to share decks. It seemed like a good idea to me to share decks with someone. For example, you can go through a deck with someone, or a teacher can share decks on a specific topic with students. Telegram allows you to link to a specific entity inside a mini-app using the startapp parameter. Example link: https://t.me/memo_card_bot/app?startapp=<deck_id>

From the documentation, I learned that in Telegram there is an option to request a list of contacts to send text. If the user wants to share something, they can click on a button that will open a list of contacts. However, this button only worked in Telegram mobile clients. I found the same problem in the bots of other participants. However, thanks to a search, a workaround was found - if you open a link like https://t.me/share/url?text=&url=url through a mobile app, Telegram will recognize it and offer a list of contacts to send to. Thus, the full link looks like this: https://t.me/share/url?text=&url=https://t.me/memo_card_bot/app?startapp=<deck_id>

How the deck sharing works

Unfortunately, desktop clients open this link in a browser, which requires an extra click to return to the client. But this is the only approach that works in both desktop and mobile clients.

Testing

The contest conditions stated that works with bugs could be penalized or even excluded from the contest. To check the correctness of individual parts of the code, I used unit testing. This made it faster to detect errors after changes. I consider the use of such tests acceptable even under time constraints. Especially if you need to test code that performs date calculations: it's easier to run a test than to wait for the right day to come to test the algorithm.

On the frontend, thanks to the separation of business logic from presentation, I was able to quickly cover the functionality with unit tests, because isolated code is easy to test. An example of a Mobx store test:

it("basic review", () => {
  const reviewStore = new ReviewStore();
  reviewStore.startDeckReview(deckCardsMock);
  expect(reviewStore.isFinished).toBeFalsy();

  reviewStore.open();
  expect(reviewStore.currentCard?.isOpened).toBeTruthy();
  reviewStore.changeState(CardState.Remember);

  expect(reviewStore.isFinished).toBeFalsy();
  expect(reviewStore.currentCard?.id).toBe(4);

  reviewStore.open();
  reviewStore.changeState(CardState.Forget);

  expect(reviewStore.isFinished).toBeFalsy();

  reviewStore.open();
  reviewStore.changeState(CardState.Remember);
  expect(reviewStore.isFinished).toBeFalsy();
  expect(reviewStore.cardsToReview).toHaveLength(1);

  reviewStore.open();
  reviewStore.changeState(CardState.Remember);
  expect(reviewStore.isFinished).toBeTruthy();

  expect(reviewStore.result.forgotIds).toEqual([4]);
  expect(reviewStore.result.rememberIds).toEqual([3, 5]);
});

I also organized usability testing with minimal effort - I gave the application to a person outside IT and watched how they used it, without my hints. My wife turned out to be an excellent assistant in this matter.

Result

The development took 30 hours. The contest was ending at 23:59 Dubai time, but the results were announced only a few hours later. Due to the difference in time zones, some participants waited late into the night, while others woke up for the results at 4 am. Upon waking up, I ran excitedly to study the list of winners. The bot took second place, the prize was $1000. For developers, this is a small amount, but this is money for developing a completely own product, which can even be spent on its development. The plans are to continue developing the product - add statistics, image support, quick card adding through a browser extension, automatic card generation via ChatGPT.

During development, it was necessary to concentrate on the main thing, avoiding immersion in insignificant details: instead of complex card swiping - two simple buttons, instead of lengthy infrastructure setup - ready-made cloud services. It was an interesting contest in which it was necessary to be a one-man band - drawing a logo in Figma, writing markup, studying the mini-app SDK, designing the database structure, writing the API, and finding what annoys users the most when using it.

Try it and you will definitely succeed.

A letter from Telegram that I had to sign

Links

Telegram bot - https://t.me/memo_card_bot

GitHub repository - https://github.com/kubk/memo-card