Привет, Хабр! В этой статье-инструкции вы узнаете, как с нуля сделать свою собственную Telegram-тапалку на современном стеке. Важный дисклеймер: тапалка, кликер и прочее — это всего лишь форма. Цель статьи — дать всеобъемлющий практикум по современному стеку и деплою проектов в облако.
Внутри статьи — полноценный Serverless-подход, разработка бота на Node и полный цикл создания FE-приложения. А еще комментарии по архитектурным и тактическим решениям, чтобы вы прокачали уровень программирования и насмотренности. Подробности под катом!
Зачем изучать
Для начинающих. Вы сможете окунуться в полный цикл разработки приложений, увидеть пошаговое появление и соединение технологий.
Для более опытных (Junior+). В целом, тот же смысл. А также вы сможете изучить Vue, Telegram SDK, Supabase и Ubuntu setup.
Какое приложение получится по итогу статьи.
На выходе у вас получится на 98% production ready приложение и шаблон, который вы можете адаптировать под свою игру, бизнес-приложение или сервис.
Полный технологический стек, который мы будем использовать: Vue 3, Supabase, Firebase Deploy, Pinia, Docker, виртуальные серверы Selectel, Node.js, Telegram Mini Apps, Lodash, Telegraf.
Функционал, который мы создадим: Single Page Application (SPA) для Telegram на Vue 3, реферальная система, выполнение заданий.
С введением закончили — давайте приступать к действию!
Качаем исходный код со стилями
Для начала подготовим шаблон интерфейса тапалки: загрузим стили, создадим главную страницу и настроим отображение текущего счета. Полный код проекта из предыдущего видео расположен в репозитории на GitHub.
Клонируйте из репозитория исходный код со стилями в свою папку, затем выполните две команды в консоли:
npm install
npm run dev
Делаем главную страницу и счет
Следующим шагом необходимо создать главную страницу и отображение текущего счета. Для этого понадобится вычислить текущий уровень игры и набранный счет, а также найти формулу по подсчету уровня.
Вычисление уровня: store/score.js
// базовый счет первого уровня
const baseLevelScore = 25
// формула по вычислению уровней и сколько нужно очков для каждого
const levels = new Array(15)
.fill(0)
.map((_, i) => baseLevelScore * Math.pow(2, i))
// вычисляем сумму, сколько нужно суммарно очков на каждый уровень
const levelScores = levels.map((_, level) => {
let sum = 0
for (let [index, value] of levels.entries()) {
if (index >= level) {
return sum + value
}
sum += value
}
return sum
})
// вычисляем уровень в зависимости от текущего счета
function computeLevelByScore(score) {
for (let [index, value] of levelScores.entries()) {
if (score <= value) {
return {
level: index,
value: levels[index],
}
}
}
}
Организация store: store/score.js
export const useScoreStore = defineStore('score', {
state: () => ({
score: 0, // базовый уровень, после будем получать по API
}),
getters: {
level(state) {
return computeLevelByScore(state.score)
},
// этот счет нужен для отображения текущего прогресса
currentScore(state) {
if (this.level.level === 0) {
return state.score
}
return state.score - levelScores[this.level.level - 1]
},
},
actions: {
add(score = 1) {
this.score += score
},
setScore(score) {
this.score = score
},
},
})
Главная страница: HomeView.vue
<template>
<div class="game-container">
<ScoreProgress />
<div class="header">
<img src="../assets/coin.png" alt="coin" />
<h2 class="score" id="score">{{ store.score }}</h2>
</div>
<div class="circle">
<img @click="increment" ref="img" id="circle" :src="imgSrc" />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import frog from '../assets/frog.png'
import lizzard from '../assets/lizzard.png'
import { useScoreStore } from '@/stores/score'
import ScoreProgress from '@/components/ScoreProgress.vue'
// получаем текущий счет
const store = useScoreStore()
// если счет больше 25 меняем картинку. Первый уровень
const imgSrc = computed(() => (store.score > 25 ? lizzard : frog))
const img = ref(null)
function increment(event) {
// при клике увеличиваем счет
store.add(1)
// дальше логика анимации из прошлого ролика
// https://youtu.be/vT-XwvcK2NI
const rect = event.target.getBoundingClientRect()
const offfsetX = event.clientX - rect.left - rect.width / 2
const offfsetY = event.clientY - rect.top - rect.height / 2
const DEG = 40
const tiltX = (offfsetY / rect.height) * DEG
const tiltY = (offfsetX / rect.width) * -DEG
img.value.style.setProperty('--tiltX', `${tiltX}deg`)
img.value.style.setProperty('--tiltY', `${tiltY}deg`)
setTimeout(() => {
img.value.style.setProperty('--tiltX', `0deg`)
img.value.style.setProperty('--tiltY', `0deg`)
}, 300)
const plusOne = document.createElement('div')
plusOne.classList.add('plus-one')
plusOne.textContent = '+1'
plusOne.style.left = `${event.clientX - rect.left}px`
plusOne.style.top = `${event.clientY - rect.top}px`
img.value.parentElement.appendChild(plusOne)
setTimeout(() => plusOne.remove(), 2000)
}
</script>
И добавляем визуальное отображение прогресса в ScoreProgress.vue.
components/ScoreProgress.vue
<template>
<div class="progress">
<h4 class="progress-level">
<span>{{ store.currentScore }}/{{ store.level.value }}</span>
<span>{{ store.level.level + 1 }}</span>
</h4>
<div class="progress-container">
<div class="progress-value" :style="{ width: progress + '%' }"></div>
</div>
</div>
</template>
<script setup>
import { useScoreStore } from '@/stores/score'
import { computed } from 'vue'
const store = useScoreStore()
const progress = computed(() => (100 * store.currentScore) / store.level.value)
</script>
Страница задач и друзей-рефералов
Деплоим фронтенд
Прежде зальем приложения на Firebase — в данном случае платформа выступит в роли бесплатного и удобного хостинга для фронтенда.
firebase init
firebase deploy
По итогу получаем url приложения.
Создаем бота на Node.js
Бот нам понадобится для реализации реферальной программы.
1. Получаем API-токен в боте @BotFather через команду /newbot.
2. Далее устанавливаем telegraf и описываем базовую настройку бота в файле bot/app.js:
/bot/app.js
import { Telegraf, Markup } from 'telegraf'
const token = 'YOUR TELEGRAM TOKEN'
const webAppUrl = 'APP REMOTE URL'
const bot = new Telegraf(token)
bot.command('start', (ctx) => {
ctx.reply(
'Привет! Нажми, чтоб запустить',
Markup.inlineKeyboard([
Markup.button.webApp(
'Открыть мини-приложение',
`${webAppUrl}?ref=${ctx.payload}` // Здесь в параметре ref передаем реферала в мини-приложение
),
])
)
})
bot.launch()
Для запуска бота используем команду:
node app
Деплой бота на сервер
Сейчас бот запущен на компьютере. Однако если вы хотите, чтобы кликер был доступен круглосуточно, придется поддерживать его бесперебойную работу и непрерывное соединение с интернетом. Оптимальный вариант — перенести бота в облако.
Будем деплоить бота в Docker — для этого добавим два файла:
Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV PORT=3000
EXPOSE $PORT
CMD ["node", "app.js"]
Makefile
build:
docker build -t tgbot .
run:
docker run -d -p 3000:3000 --name tgbot --rm tgbot
Далее зальем весь проект в GitHub-репозиторий, чтобы можно было легко перенести проект на сервер — без использования FTP и scp. Перейдем к следующему этапу.
1. Переходим в раздел Облачная платформа внутри панели управления.
2. Создаем сервер. Для работы нашего приложения не нужно много мощностей, поэтому достаточно одного ядра vCPU с долей 20% и 512 МБ оперативной памяти. И обязательно добавляем публичный IP-адрес, чтобы к серверу можно подключиться через интернет.
3. Авторизуемся на сервере через консоль посредством команды ssh root@. Публичный адрес виртуальной машины можно посмотреть в разделе Порты.
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 запущен.
Разработка функционала
Разработка функционала тапалки — многоуровневый процесс, который сложно полностью отразить в текстовом формате. Если у вас на каком-то из шагов возникли вопросы, обратитесь к видео на YouTube — там все показано и разбито на таймкоды.
Шаг 1. Подключение базы данных к клиенту
В качестве базы данных будем для хранения пользовательских баллов будем использовать Supabase. Это open source-аналог решения от Google.
Инициализируем проект и создаем две таблицы: User (список пользователей) и Task (список задач).
Создание таблицы User в Supabase.
Создание таблицы Task в Supabase.
На стороне инициализируем дополнительный сервис services/supabase.js:
const SUPABASE_URL = 'https://yodsvoxtwmjearffyxhj.supabase.co'
const SUPABASE_API_KEY = 'SECRET'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_API_KEY)
export default supabase
Шаг 2. Подключение Telegram-библиотеки
Для того, чтобы соединить веб-приложение с функционалом Telegram Mini Apps, подключим библиотеку:
<script src="https://telegram.org/js/telegram-web-app.js"></script>
Чтобы ей было удобней пользоваться, делаем отдельный хук:
services/telegram.js
export function useTelegram() {
const tg = window.Telegram.WebApp
return { tg, user: tg.initDataUnsafe?.user }
}
Шаг 3. Создание API-запросов к базе
Опишем все запросы, которые будет отправлять наше веб приложение для взаимодействия с базой данных.
api/app.js
import supabase from '@/superbase'
import { useTelegram } from '@/telegram'
import { useScoreStore } from '../stores/score'
const { user } = useTelegram()
const MY_ID = user?.id ?? 'YOUR DEV ID' // Для разработки. В вебе нет user.id — он появляется только тогда, когда приложение запущено в рамках Telegram
// Авторизация пользователя
export async function getOrCreateUser() {
const potentialUser = await supabase
.from('users')
.select()
.eq('telegram', MY_ID)
// Проверяем, существует ли уже текущий пользователь
if (potentialUser.data.length !== 0) {
return potentialUser.data[0]
}
// Если нет, то создаем нового
const newUser = {
telegram: MY_ID,
friends: {},
tasks: {},
score: 0,
}
await supabase.from('users').insert(newUser)
return newUser
}
// Добавляем обновление счета у текущего пользователя
export async function updateScore(score) {
await supabase.from('users').update({ score }).eq('telegram', MY_ID)
}
// Завершаем задачу и начисляем бонусные баллы за ее выполнение
export async function completeTask(user, task) {
await supabase
.from('users')
.update({ tasks: { ...user.tasks, [task.id]: true } })
.eq('telegram', MY_ID)
const score = useScoreStore()
const newScore = score.score + task.amount
await updateScore(newScore)
score.setScore(newScore)
}
// Регистрируем реферала
export async function registerRef(userName, refId) {
// Получаем данные пользователя, который поделился своей реферальной ссылкой
const { data } = await supabase.from('users').select().eq('telegram', refId)
const refUser = data[0]
// Добавляем нас в список его рефералов, а также начисляем баллы
await supabase
.from('users')
.update({
friends: { ...refUser.friends, [MY_ID]: userName },
score: refUser.score + 50,
})
.eq('telegram', refId)
}
// Получаем список всех задач (он статический)
export async function getTasks() {
const { data } = await supabase.from('tasks').select('*')
return data
}
Шаг 4. Создание store для приложения
Создаем еще одно хранилище данных для пользователя и задач, чтобы было удобнее с ними работать и можно было вынести логику в Pinia из самих компонентов.
stores/app.js
import { defineStore } from 'pinia'
import {
getOrCreateUser,
completeTask,
registerRef,
getTasks,
} from '@/api/app'
import { useScoreStore } from './score'
import { useTelegram } from '@/services/telegram'
const { user } = useTelegram()
export const useAppStore = defineStore('app', {
state: () => ({
user: {}, // настройки по умолчанию
tasks: [],
}),
actions: {
// С этого метода начинается авторизация и идентификация пользователя
async init(ref) {
// Получаем существующего пользователя либо создаем нового
this.user = await getOrCreateUser()
const score = useScoreStore()
// Задаем данные, которые были в базе у этого пользователя
score.setScore(this.user.score)
// проверяем, является ли он рефералом
if (ref && +ref !== +this.user.telegram) {
await registerRef(user.first_name, ref)
}
},
// Выполнение задачи
async completeTask(task) {
await completeTask(this.user, task)
},
// Получаем список всех задач
async fetchTasks() {
this.tasks = await getTasks()
},
},
})
Шаг 5. Запуск приложения
Добавим код который будет инициализировать приложение и разворачивать его на весь экран, считывать параметры реферала (если новый пользователь его имеет) и загружать данные о текущем пользователе и его счете.
<template>
<main class="game" v-if="loaded">
<div class="page">
<RouterView />
</div>
<TheMenu />
</main>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import TheMenu from './components/TheMenu.vue'
import { useAppStore } from './stores/app'
import { useTelegram } from './telegram'
const { tg } = useTelegram()
const app = useAppStore()
const loaded = ref(false)
// Получаем данные, которые нам прокинул бот по рефералам
const urlParams = new URLSearchParams(window.location.search)
// Передаем рефку в инициализацию и получаем данные пользователя
app.init(urlParams.get('ref')).then(() => {
loaded.value = true
})
onMounted(() => {
// Сигнализируем о том, что приложение готово
tg.ready()
// Разворачиваем на весь экран
tg.expand()
})
</script>
Шаг 6. Сохранение счета в базе
Реализуем функционал, который соединит локальное изменение счета с данными в базе данных.
Для защиты от спама добавим debounce, чтоб не заспамить базу запросами. Задержку в 500 мс. Если на каждый клик отправлять запрос, это не будет эффективным решением.
npm i lodash.debounce
@/store/score
import { defineStore } from 'pinia'
import debounce from 'lodash.debounce'
import { updateScore } from '@/api/app'
const debouncedSave = debounce(updateScore, 500)
// =======
export const useScoreStore = defineStore('score', {
actions: {
add(score = 1) {
this.score += score
debouncedSave(this.score)
},
setScore(score) {
this.score = score
},
},
})
Шаг 7. Создание страницы друзей
Добавим страницу друзей, на которой будем загружать список пользователей, присоединенных по реферальной ссылке текущего. Также на этой странице можно будет скопировать свою реферальную ссылку.
views/FriendView.vue
<template>
<div class="text-content">
<h1>Your Friends</h1>
<div class="center">
<button class="referal" @click="copy">{{ referalText }}</button>
</div>
<h3 v-if="friends.length === 0">No friends yet</h3>
<ul class="list">
<li class="list-item" v-for="friend in friends" :key="friend.id">
{{ friend.name }}
<span class="list-btn done"> 50 </span>
</li>
</ul>
</div>
</template>
<script setup>
import { useTelegram } from '@/telegram'
import { useAppStore } from '@/stores/app'
import { ref, computed } from 'vue'
const { user } = useTelegram()
const app = useAppStore()
// Удобный формат для вывода в шаблон (объект -> массив)
const friends = computed(() => Object.keys(app.user.friends).map((id) => ({
id,
name: app.user.friends[id],
})))
const referalText = ref('Your referal')
// Копируем в буфер и меняем текст
function copy() {
navigator.clipboard.writeText(
'https://t.me/YOUR_BOT_NAME_IN_TELEGRAM?start=' + user?.id
)
referalText.value = 'Copied!'
}
</script>
Страница друзей, результат.
Шаг 8. Создание страницы задач
Добавим страницу задач, на которой будем подгружать список выполненных пользователем заданий и количество полученных очков, а также еще невыполненные задачи.
views/TasksView.vue
<template>
<div class="text-content">
<h1>Your tasks</h1>
<p v-if="app.tasks.length === 0">Loading tasks...</p>
<ul class="list">
<li class="list-item" v-for="task in app.tasks" :key="task.id">
{{ task.title }}
<span>
<a
@click.prevent="openTask(task)"
target="_blank"
class="list-btn"
:class="{ done: app.user?.tasks?.[task.id] }"
>
{{ task.done ? 'Done' : task.amount }}
</a>
</span>
</li>
</ul>
</div>
</template>
<script setup>
import { useTelegram } from '@/telegram'
import { onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
const app = useAppStore()
const { tg } = useTelegram()
onMounted(() => {
// Если компонент готов, загружаем его с сервера
app.fetchTasks()
})
function openTask(task) {
// Запускаем цикл выполнения задачи
app.completeTask(task)
if (task.url.includes('t.me')) {
// Открываем как внутреннюю ссылку
tg.openTelegramLink(task.url)
} else {
// И как внешнюю
tg.openLink(task.url)
}
}
</script>
Страница задач, результат.
Заключение
Готово! У нас есть запущенный фронтенд, Telegram-бот и сама игра. Теперь вы можете использовать шаблон этого приложения для реализации собственных проектов.