Как я сделал проект для себя и получил приз от Telegram
Всем привет! Telegram устроил конкурс на разработку мини-приложений, где моя работа заняла призовое место. В конкурсе была высокая конкуренция, так как выбор технологий и идеи для приложения был свободный. В связи с этим Telegram даже увеличил общую стоимость призовых. В этой статье разберём как проходила разработка, с какими сложностями столкнулся и чему научился.
О конкурсе
Telegram позволяет встраивать в мессенджер мини-приложения. Примером такого приложения является Wallet — инструмент для хранения и обмена криптовалюты внутри мессенджера.
Задача — разработать полезное мини-приложение, разместить код клиента и сервера на GitHub. Приложение оценивалось как с точки зрения пользователя, так и с точки зрения разработчика. По условиям конкурса нужно создать своё приложение, а не следовать предоставленным дизайну и инструкциям. Это требовало потрудиться не только над кодом, но и над придумыванием идеи и созданием пользовательского интерфейса.
Зачем участвовать в конкурсах?
Проверка своей конкурентоспособности независимыми проверяющими. Для конкурса не важно какую должность вы занимаете и сколько у вас опыта работы.
Если решая привычные задачи на работе, вы попали в зону комфорта, то конкурс — это хороший способ попробовать что-то новое.
Особенность данного конкурса — возможность заработать на создании полностью своего проекта, от идеи до реализации.
Идея для бота
Люди забывают информацию если её не повторять. Память подчиняется определенным паттернам, которые вывел ученый Эббингауз.
Кривая забывания Эббингауза
Концепция простая, но мощная: обучение и запоминание будет лучше, если оно распределено во времени. Для этого используется интервальное повторение — метод запоминания информации, основанный на регулярном повторении материала через определенные интервалы времени. Этот метод эффективен для запоминания различного рода материалов, включая слова, тексты, факты и языковые грамматические правила.
Для интервального повторения я пользуюсь программой Anki. Вы создаёте колоды, добавляете туда карточки, и приложение предлагает повторять только ту информацию, что требует повторения. Это хороший инструмент, однако я обнаружил ряд существенных недостатков:
Много полезного функционала доступно в Anki только в виде плагинов. Плагины работают только в настольной версии, что делает неудобным повторение с телефона. Пример важных плагинов — автоматическая озвучка карточек чтобы слышать произношение, Heatmap для отслеживания прогресса в стиле GitHub, возможность проходить все колоды за раз без необходимости переключаться между ними.
У Anki нет автоматических push-уведомлений для напоминаний о повторении. Из-за этого люди просто забывают им пользоваться. Ставить будильник на каждый день — не лучшая идея, так как не на каждый день есть карточки для повторения. Я бы хотел получать умные уведомления, которые уведомляют только если в этом есть необходимость.
Субъективное мнение — Anki просто не хватает человеческого лица. Его нужно конфигурировать и настраивать, вместо того чтобы просто пользоваться. По этой причине мои знакомые бросали это приложение.
Я давно хотел сделать альтернативу для себя, но не находил времени. Конкурс дал достаточно мотивации для старта.
Общая информация о мини-приложениях
Схема работы мини-приложения
Подробно остановимся на каждом из шагов схемы.
Шаг 1 — Телеграм клиент обращается к фронтенду мини-приложения. Телеграм клиент это любой из клиентов внутри которого запускается мини-приложение. Мини-приложение это ваш сайт, доступный публично с настроенным SSL. Для того чтобы мессенджер открывал этот сайт нужно создать бота через BotFather, получить API-ключ и указать какой URL будет открывать ваш бот. Для этого при общении с BotFather вызываем /setmenubutton
или переходим в Bot Settings > Menu Button для указания вашего URL.
Шаг 2 — Фронтенд обращается к бекенду. Фронтенд будет обращаться к бекенду для взаимодействия с базой данных. Для получения информации о пользователе фронтенд должен передавать их на сервер, а бекенд должен их провалидировать. Валидация нужна для предотвращения неавторизованного доступа, чтобы пользователь не смог подменить данные при отправке на бекенд.
Тут может возникнуть закономерный вопрос —, а какая польза от мини-приложений если мессенджер просто запускает мой сайт? На самом деле, мини-приложения Telegram дают множество удобств как разработчикам, так и пользователям:
Аутентификация. Пользователь бота уже залогинен в Телеграм, вам не нужно просить его зарегистрироваться. Разработчику не нужно разрабатывать или подключать аутентификацию, тратиться на СМС.
Пуш-уведомления. Они уже работают в Телеграм и разработчику не нужно их настраивать.
Бота как и сайт не нужно устанавливать. Из-за того что он работает внутри Телеграм — им легко делиться. Просто отправьте ссылку на бота своим контактам и они смогут им пользоваться. Можно даже делиться определёнными страницами внутри бота с помощью параметра
startapp
, например https://t.me/yourbot/app? startapp=product_idБогатый SDK, включающий в себя множество вспомогательных элементов: Главная кнопка, лоадер для кнопки, кнопка возврата на предыдущий экран, модальные окна, сканирование QR-кодов. Полный список доступен в документации.
Технологический стек
Многие из моих решений были продиктованы ограниченным временем. Поэтому я использовал знакомые инструменты — язык TypeScript для фронтенда и бекенда, библиотеки React и Mobx для пользовательского интерфейса. Для экономии времени на настройку сервера воспользовался облачными технологиями. Сейчас осознаю, что это было правильным решением, которое помогло сконцентрироваться на разработке полезной функциональности. Облачные сервисы:
Cloudflare Pages для Node.js full-stack приложения. Это дало мне домен с настроенным SSL-сертификатом, автоматический деплой фронтенда и бекенда после git push на GitHub. Для этого достаточно подключить ваш репозиторий в настройках Cloudflare Pages. Сервис даёт 100000 бесплатных запросов к API в день.
Supabase — Open-Source база данных поверх PostgreSQL, с пользовательским интерфейсом для редактирования как данных, так их структуры. В случае необходимости Supabase позволяет использовать сырые SQL-запросы. Для меня это было критично, так как предыдущие попытки использовать облачные БД вроде Firestore заканчивались неудачами из-за невозможности использовать удобный SQL для сложных вычислений. Также сервис предоставляет бесплатную возможность использования облачной базы данных, поэтому мне не нужно было хостить PostgreSQL самому.
Я быстро набросал схему БД в визуальном редакторе:
Визуальный редактор Supabase
Далее через JavaScript SDK Supabase можно отправлять запросы на сохранение данных в эти таблицы. Приятной неожиданностью стало то, что Supabase может генерировать TypeScript типы на основе созданной схемы, что дополнительно сэкономило время:
Отрывок сгенерированного TypeScript кода
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
}
...
Разработка и алгоритм интервального повторения
Пользовательский интерфейс мини-приложения состоял из 3-х частей: список колод с карточками, формы создания и редактирования колоды, режим изучения / повторения карточек.
Cлева направо — список колод, редактирование колоды, повторение колоды
Суть алгоритма интервального повторения:
Изучение нового материала
Повторение этого материала через определенное время (например, через день).
Если повторение прошло успешно, то следующее повторение происходит через более длительный интервал времени (например, через неделю).
Если повторение не было успешным, то следующее повторение происходит через более короткий интервал времени (например, через несколько часов)
Повторение происходит до тех пор, пока информация не будет запомнена.
Для реализации алгоритма напишем функцию. Пусть она принимает на вход карточку и результат прохождения («помню», «не помню»), а отдаёт следующее время повторения. В самом простом виде алгоритм должен рассчитывать следующее время повторения карточки по такой схеме:
Дата | Ответ пользователя | Результат функции (дни) |
Сегодня | Помню | 1 |
Через 1 день | Помню | 2,5 |
Через 2,5 дня | Помню | 6,25 |
Через 6,25 дней | Помню | 15,63 |
Видим, что рядом с карточкой нужно хранить интервал, который будет показывать через сколько дней нужно нужно повторить карточку. Что делать если пользователь забыл карточку? Значит этот интервал нужно сбросить в изначальное значение. Представим это в коде:
import { DateTime, Settings } from "luxon";
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)),
};
};
Но у этого алгоритма есть недостаток. Если пользователь забывает карточку, значит она для него сложнее других. Следовательно, это слово должно предлагаться чаще слов, которые пользователь ни разу не забывал. В коде это означает, что у слова, которое пользователь забывает несколько раз, интервал должен расти медленнее. Например так:
Дата | Ответ пользователя | Результат функции (дней) |
Сегодня | Не помню | 0,4 |
Через 0,4 дня | Помню | 0,94 |
Через 0,94 дня | Не помню | 0,4 |
Через 0,4 дня | Помню | 0,92 |
Через 0,92 дня | Помню | 2,21 |
Через 2,21 дня | Помню | 5,53 |
Обратите внимание, что в конце мы 3 раза подряд ответили «Помню», но интервал растёт медленнее, чем в первом примере. Для этого коэффициент, на который умножаем интервал, тоже будем хранить в карточке. При неправильном ответе коэффициент уменьшается, что замедляет рост интервала. При правильном ответе коэффициент увеличивается, что увеличивает рост интервала. Также введём верхнюю и нижнюю границу коэффициента, чтобы карточки не попадались слишком часто или слишком редко:
import { DateTime, Settings } from "luxon";
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)),
};
}
После сохранения результата в базу данных, мы можем использовать SQL запрос для расчёта карточек, требующих повторения на текущий момент:
-- Ищем карточки, которые пора повторять
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()
-- Добавляем новые карточки из колод, к которым пользователь имеет доступ
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
Озвучка карточек
Для изучения слов полезно слышать как их правильно произносить. Например, в английском есть разные произношения — американский и британский. В тоновых языках вроде тайского из-за неправильного тона вас могут просто не понять, даже если вы знаете как пишется слово. Озвучка карточек доступна в Anki только в виде плагина, однако я давно хотел попробовать встроенный в браузеры Speech Synthesis API. Он позволяет генерировать речь на разных языках, в том числе с учётом акцента. Он поддерживается у 95% пользователей и работать с ним очень просто:
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");
Интерфейс выбора языка
Сложности
Сложность 1 — Анимация. Во время повторения карточки пользователь решает помнит ли он её или нет. Выбор «да» или «нет» было решено сделать с помощью анимации смахивания. Cмахивание вправо означало бы, что пользователь помнит карточку, cмахивание влево — не помнит. Мотивация — интерфейс Telegram хорошо анимирован, поэтому хотелось выделиться. Это было ошибкой — мало того что я потратил много времени на синхронизацию движений пальца и карточки, так это приводило к проблемам в среде мини-приложений. В мобильных клиентах смахивание влево распознавалось как смахивание вниз, а это непреднамеренно закрывало мини-приложение. В чате участников конкурса я узнал, что это распространённая проблема и разработчики используют разные обходные пути, надёжность которых под вопросом. Я решил не усложнять и сделал просто 2 кнопки — «I got it right» и «Need to review» по аналогии с Anki. Урок для конкурсов — сначала делайте важный функционал, а лишь потом по возможности добавляйте детали.
Сложность 2 — Аутентификация. Telegram SDK передаёт базовую информацию о пользователе на фронтенд в виде глобальных переменных JavaScript. Однако слепо доверять им на бекенде нельзя — злоумышленник может подменить эти данные и выдать себя за другого человека. Поэтому данные пользователя нужно валидировать на бекенде. Вместе с информацией о пользователе Телеграм генерирует хеш этой информации, подписанный токеном вашего бота. На бекенде нужно посчитать хеш заново и если они совпадают, то данным можно доверять.
Неожиданностью стало то, что работы с криптографией в Cloudflare Pages нужно использовать Web Crypto API вместо модулей Node.js. Дело в том, что Cloudflare Workers работают в уникальной среде, которая не является ни браузером, ни серверной средой Node.js. Код работает в сети Cloudflare, чья среда выполнения напоминает Service Worker веб-браузера. Этот API был мне не знаком, поэтому пришлось потратить время на написание модуля валидации хеша вместо того, чтобы воспользоваться готовой библиотекой. Чтобы не усложнять приложение JWT-токенами как у других участников было решено просто передавать данные о пользователе на каждый запрос в виде HTTP-заголовка:
const response = await fetch(endpoint, {
method,
body: bodyAsString,
headers: {
hash: WebApp.initData,
},
})
Далее бекенд валидирует эти данные и создаёт либо обновляет информацию о пользователе в базе данных.
Сложность 3 — Как делиться колодами. Мне показалось удачной идеей делиться колодами с кем-то. Например, можно вместе с кем-то проходить колоду, либо учитель может делиться колодами на определённую тему с учениками. Telegram позволяет ссылаться на определённую сущность внутри мини-приложения с помощью параметра startapp
. Пример ссылки: https://t.me/memo_card_bot/app?startapp=
Из документации узнал, что в Telegram есть возможность запросить список контактов для отправки текста. Если пользователь хочет чем-то поделиться, то он может кликнуть на кнопку, которая откроет список контактов. Однако эта кнопка работала только в мобильных клиентах Telegram. Такую же проблему я обнаружил в ботах других участников. Однако благодаря поиску был найден обходной путь — если через мобильное приложение открыть ссылку вида https://t.me/share/url?text=&url=url
, то Telegram распознает её и предложит список контактов для отправки. Таким образом полная ссылка выглядит так: https://t.me/share/url?text=&url=https://t.me/memo_card_bot/app?startapp=
Окно выбора контактов и результат
К сожалению, десктопные клиенты открывают эту ссылку в браузере, что требует лишнего клика для возврата в клиент. Но это единственный подход, работающий как в десктопных клиентах, так и в мобильных.
Тестирование
В условиях конкурса было указано, что работы с багами могут быть оштрафованы либо вообще исключены из конкурса. Для проверки корректности отдельных частей кода я использовал юнит-тестирование. Это позволяло быстрее обнаружить ошибки после изменений. Считаю использование таких тестов допустимым даже в условиях ограниченного времени. Особенно если нужно тестировать код, выполняющий расчёт дат: проще запустить тест, чем ждать когда наступит нужный день для проверки алгоритма.
На фронтенде благодаря отделению бизнес-логики от представления, я смог быстро покрыть функционал юнит-тестами, потому что изолированный код легко тестировать. Пример теста Mobx стора:
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]);
});
Также организовывал юзабилити тестирование на минималках — давал приложение в руки человеку не из IT и смотрел как он им пользуется, без моих подсказок. Отличным помощником в этом деле оказалась моя жена. Для общей проверки бота на разных устройствах был составлен чек-лист:
Тесткейсы для разных устройств
Результат
На разработку ушло 30 часов. Конкурс завершался в 23:59 по Дубайскому времени, но результаты были оглашены лишь спустя несколько часов. Из-за разницы часовых поясов кто-то из участников ждал допоздна, кто-то просыпался ради результатов в 4 утра. Я, проснувшись, побежал взволнованно изучать список победителей. Бот занял второе место, приз — $1000. Для разработчиков это небольшая сумма, однако это деньги за разработку полностью своего продукта, которые даже можно потратить на его развитие. В планах продолжать развивать продукт — добавить статистику, поддержку изображений, быстрое добавление карточек через браузерное расширение, автоматическую генерацию карточек через ChatGPT.
Во время разработки пришлось концентрироваться на главном, избегая погружения в незначительные детали: вместо сложного смахивания карточек — две простые кнопки, вместо длительной настройки инфраструктуры — готовые облачные сервисы. Это был интересный конкурс, в котором нужно было быть человеком-оркестром — рисовать логотип в Figma, верстать, изучать SDK мини-приложений, проектировать структуру базы данных, писать API и искать что больше всего раздражает пользователей при использовании.
Пробуйте и у вас обязательно получится.