Ruby Telegram Mini App

от автора

1.1. Телеграм мини эппы и Руби

С недавних пор функциональность Telegram сильно выросла. Помимо привычных нам ботов, особенно ярко выделяются Telegram Mini Apps. Изучив, как это работает, у авторов данной статьи появилась идея написать небольшое приложение и желание высказать некоторые тезисы, которыми хотелось бы поделиться с сообществом.

Поскольку, по мнению команды, язык программирования Ruby и его фреймворк Ruby on Rails являются наилучшим решением для быстрой разработки и стартапов, они были выбраны в качестве основных инструментов. Также важно отметить, что на GitHub на момент написания статьи было очень мало примеров кода для реализации Mini App на Ruby. Всемирная паутина тоже не особо радовала нас рабочими примерами, поэтому этой статьёй хотелось бы внести свой посильный вклад в сферу, касающуюся разработки на Ruby.

1.2. Финансовая грамотность

Так как участники команды разработки некоторое время посвятили изучению сферы финансовых активов и криптовалют, нами было принято решение создать игру, моделирующую рыночную волатильность на примере одной выдуманной акции. Назовём её «AVA» (an volatile asset), что по-русски означает «нестабильный актив».

Зачем мы хотим моделировать рыночную волатильность и при чем тут финансовая грамотность?

Известные инвесторы Джон Богл и Бенджамин Грэм в своих работах выдвинули тезисы о преимуществах пассивного инвестирования перед активным управлением портфелем. В книге «Разумный инвестор» Грэм описывает активное управление как сложное и часто неэффективное для большинства инвесторов из-за высоких издержек и неопределённости результатов. Он рекомендует инвесторам сосредоточиться на пассивном подходе через инвестирование в долгосрочные индексные фонды или диверсифицированные портфели акций и облигаций, что минимизирует риски и издержки. Богл также поддерживает концепцию индексных фондов как эффективного инструмента пассивного инвестирования, отказываясь от активного управления в пользу доступа к широкому рынку с низкими комиссиями и минимальными издержками.

Резюмируя: по мнению указанных авторов, попытки заработать на спекуляциях на рынке ценных бумаг статистически невыгодны по сравнению с долгосрочным инвестированием по стратегии «купил и забыл». Наша игра создана для того, чтобы продемонстрировать эту концепцию. Чтобы выиграть в ней больше, нужно просто купить актив и держать его, но, как ценители азарта, мы даём игроку возможность поспекулировать. Конечно, как и в реальной жизни, найдутся счастливчики, которые смогут словить удачу и заработать виртуальную валюту, однако спекулянты довольно часто проигрывают на длинных дистанциях.

2.1. Функциональность игры

По описанию выше, игра должна иметь следующую функциональность:

  1. Авторизация с использованием Telegram;

  2. Наличие дат, которые можно «листать», т.е. при нажатии на кнопку «Следующая дата» должен промотаться счетчик времени на n единиц вперед, чтобы игрок не ждал изменений цены;

  3. Цена должна колебаться день ото дня;

  4. График, отражающий колебания;

  5. Возможность покупать и продавать;

  6. Капитал, выданный игроку в начале игры.

3.1. Подготовка

Итак, для того чтобы всё работало, нужно предварительно сделать следующее:

Установить:

  1. Ruby и Ruby on Rails (7 версия);

  2. Redis;

  3. Ngrok;

  4. PostgreSQL;

  5. npm и Flowbite.

Завести Telegram-бота с помощью BotFather и получить его API ключ.

3.2. Общие моменты

Я использую версию rails 7.0.8, ruby 3.3.1, однако вы можете использовать другие варианты.

Запускаю команду для создания rails приложения с postgresql:

rails new investment_game --database=postgresql

Перейдем в папку:

cd investment_game

Добавим необходимые нам библиотеки в Gemfile:

gem "mutex_m" gem "telegram-bot-ruby" gem "redis-rails" gem "dotenv-rails" gem "tailwindcss-rails" gem "hotwire-rails"

Сделаем bundle, чтобы установить зависимости

bundle

Далее создадим бд:

rails db:create

Для корректной работы с ngrok добавим в app/config/environments/development.rb следующую строку:

config.hosts << /.*.ngrok-free.app/

Для регистрации, авторизации и других необходимых нам фич, нам нужно создать пользователей, сгенерируем модель юзера(используем в следующей команде --skip-test-framework — для того, чтобы не генерировать тесты):

rails g model User --skip-test-framework

В db/migrate/ у нас появилась миграция XXXXXXXXXXXXX_create_users.rb следующим образом:

class CreateUsers < ActiveRecord::Migration[7.0]   def change     create_table :users do |t|       t.string  :name       t.string  :username       t.bigint  :telegram_id       t.decimal :capital, precision: 10, scale: 2, default: 10000.to_d       t.integer :amount_of_tokens        t.timestamps     end   end end 

Прогоняем миграцию

rails db:migrate

Прежде чем писать бота, нам нужно установить все переменные окружения. Создадим файл .env для их определения, почти наверняка ваши переменные окружения будут отличаться от наших, потому предварительно проверьте их прежде чем заносить их в .env файл. Также хотелось бы отметить, что NGROK_URL изменяется каждый раз при запуске данного сервиса, поэтому его нужно постоянно обновлять. Итак, наш .env пока что будет следующим:

REDIS_URL = 'redis://localhost:6379/0' TOKEN = 'TOKEN' NGROK_URL = 'NGROK_URL'

3.3. Redis, бот, регистрация и авторизация в telegram mini app

Первая задача звучит так: «Нам нужна авторизация, но через телеграмм и используя бота». Что же, хорошо. Давайте воплощать.

Создадим файл redis.rb в config/initializers/ и добавим туда следующие строки(далее я буду приводить код приложения с комментариями, больше раскрывающими суть):

# Здесь мы апускаем Redis, важно предварительно  # его установить и  узнать адрес. Чтобы узнать адрес  # используйте команду: 'sudo netstat -tlnp | grep redis' REDIS = Redis.new(url: ENV['REDIS_URL']) 

По своему концептуальному воплощению, наш бот является обработчиком сообщений от telegram api, подключаясь к нему, вы как разработчик должны определить основные сценарии для ответов на приходящие от API сообщения и выдавать свои, создадим папку bot и в ней файл bot.rb:

# Импортируем конфиг окружения и библиотеку require File.expand_path('../config/environment', __dir__) require 'telegram/bot'  class TelegramBot   def initialize     # При инициализации важно указать API токен его можно      # получить в тг создав бота с помощью https://t.me/BotFather     @bot = Telegram::Bot::Client.new(ENV['TOKEN'])   end    def run     # В данном методе мы "ловим" основные типы      # апдейтов, которые могут прийти к нам от ТГ     @bot.listen do |update|       case update       when Telegram::Bot::Types::Message         handle_message(update)       when Telegram::Bot::Types::CallbackQuery         handle_callback_query(update)       when Telegram::Bot::Types::ChatMemberUpdated         handle_chat_member_updated(update)       else         puts "Необработанное обновление типа: #{update.class}"       end     end   end    private    def handle_message(message)     case message.text     when '/start'       start_command(message)     when '/stop'       stop_command(message)     else       handle_unknown_command(message)     end   end    def handle_callback_query(callback_query)     puts "Получен callback query: #{callback_query.data}"   end    def handle_chat_member_updated(chat_member_updated)     puts "Обновление для пользователя: #{chat_member_updated.from.id}"   end    def start_command(message)     # Здесь мы вызываем генерацию токена и отправляем      # пользователю сообщение содержащее кнопку с ссылкой на наш      # сервис. Важно отметить, что сгенерированный токен мы кладём в      # Редис, чтобы потом произвести аутентификацию пользователя      auth_token = generate_auth_token(message)     webapp_url = "#{ENV['NGROK_URL']}?tg_token=#{auth_token}"          keyboard = Telegram::Bot::Types::InlineKeyboardMarkup.new(       inline_keyboard: [         [           Telegram::Bot::Types::InlineKeyboardButton.new(             text: 'Investment game app',             web_app: { url: webapp_url }           )         ]       ]     )      @bot.api.send_message(       chat_id: message.chat.id,       text: "Играть в один клик!",       reply_markup: keyboard     )   end    def stop_command(message)     @bot.api.send_message(chat_id: message.chat.id, text: "До свидания, #{message.from.first_name}!")   end    def handle_unknown_command(message)     @bot.api.send_message(chat_id: message.chat.id, text: "Неизвестная команда.")   end    def generate_auth_token(message)     token = SecureRandom.hex(16)     user_info = {       telegram_id: message.from.id,       name: message.from.first_name,       username: message.from.username     }      REDIS.set(token, user_info.to_json)     token   end end  bot = TelegramBot.new bot.run 

Для аутентификации нам понадобится middleware, который должен обработать HTTP-запрос до того, как он попадёт в контроллер. Делаем мы это, для того, чтобы разделить логику и в полной мере использовать преимущества middleware.

В app/ создадим папку middleware/ и внутри неё telegram_auth.rb

class TelegramAuth   def initialize(app)     @app = app   end    def call(env)     # Здесь, используя redis мы находим или создаём      # по токену пользователя и сохраняем его user_id в сессию      request = Rack::Request.new(env)      token = request.params['tg_token']      if token.present?       user_info_json = REDIS.get(token)       user_info = JSON.parse(user_info_json)       user = User.find_or_initialize_by(telegram_id: user_info['telegram_id'])       user.update(         name: user_info['name'],         username: user_info['username']       )       env['rack.session'][:user_id] = user.id       REDIS.del(token)       return [302, {'Location' => '/'}, []]     else       puts "Some error"     end          @app.call(env)   end end 

Установим настройку для middleware в config/application.rb, вот как должен выглядеть application.rb :

require_relative "boot" require_relative '../app/middleware/telegram_auth' require "rails/all"  # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups)  module InvestmentGame   class Application < Rails::Application     # Initialize configuration defaults for originally generated Rails version.     config.load_defaults 7.0      # Configuration for the application, engines, and railties goes here.     #     # These settings can be overridden in specific environments using the files     # in config/environments, which are processed later.     #     # config.time_zone = "Central Time (US & Canada)"     # config.eager_load_paths << Rails.root.join("extras")          config.middleware.use TelegramAuth   end end 

Изменим руты:

Rails.application.routes.draw do   root 'users#index'   get 'users/new', to: 'users#new', as: :new_user end 

Внесём в app/controllers/application_controller.rb следующие изменения

class ApplicationController < ActionController::Base   helper_method :current_user    def current_user     @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]   end end 

В app/controllers создадим users_controller.rb и создадим экшен index:

class UsersController < ApplicationController   def index     @user = current_user   end end 

Далее сделаем нужное view, чтобы можно было проверить, проходит ли аутентификация. В app/views/ инициализируем папку users и создадим index.html.erb наполнив его следующим кодом:

<style>   body {     background-color: white;    } </style>  <h1>Профиль пользователя</h1>  <% if @user %>   <p>ID: <%= @user.telegram_id %></p>   <p>Имя: <%= @user.name || 'Не указано' %></p>   <p>Имя пользователя: <%= @user.username || 'Не указано' %></p> <% else %>   <p>Пользователь не найден</p> <% end %> 

Теперь мы можем протестировать авторизацию в телеграм, работу с ngrok и телеграм бота:

Первой что нам нужно сделать, это запустить рельсу:

rails s

Запущенная на 3000 рельса

Запущенная на 3000 рельса

По умолчанию локально сервер запускается на 3000 порту, соответственно и ngrok нужно запустить на этот порт, запуск необходимо произвести в новой вкладке терминала(если вы в убунту):

ngrok http http://localhost:3000

Запущенный ngrok

Запущенный ngrok

Ngrok запущен, а это значит, что теперь ваш проект доступен в сети по сгенерированному адресу

Отлично, теперь нам нужно в .env добавить адрес ngrok и токен для бота:

REDIS_URL = 'redis://localhost:6379/0' TOKEN = '6294394111:AAGm2qRHrd9GGms7PwZxVUL0DkQ4mFVPWIQ' NGROK_URL = 'https://cbf3-188-169-10-159.ngrok-free.app'

Отлично, и в соседней вкладке терминала теперь запускаем бота:

ruby bot/bot.rb

Проходим в нашего бота и вводим /start в чате с ним:

Он предлагает нам пройти по ссылке

Он предлагает нам пройти по ссылке
Наш сервис в сети

Наш сервис в сети

Жмём по «Visit Site» и вуаля, авторизация в телеграм мини эпп без пароля работает:

Авторизация в приложении без пароля работает

Авторизация в приложении без пароля работает

Далее учитывайте, что размещая локально приложения и запуская бота каждый раз необходимо будет запускать рельсовый сервис, включать ngrok, вставлять новый адрес в переменные окружения .env и запускать бота.

Пока давайте выключим сервер, ngrok и бота и пойдем дальше.

3.4. Реализация механики игры

Для реализации игровой механики нам понадобится модель рынка с соответствующими полями.

Нам необходим роутинг, соответственно генерируем модель: rails g model Market --skip-test-framework

Изменяем созданную миграцию следующим образом:

class CreateMarkets < ActiveRecord::Migration[7.0]   def change     create_table :markets do |t|       t.date        :current_date       t.decimal     :price, precision: 10, scale: 2       t.references  :user, null: true, foreign_key: true       t.json        :price_history, default: []        t.timestamps     end   end end 

Снова прогоняем миграцию: rails db:migrate

Устанавливаем отношения между моделями User и Market:

В модели Market устанавливаем связь с User:

belongs_to :user

А модель User изменяем координальнее:

class User < ApplicationRecord   # Устанавливаем связь с моделью Market   has_one  :market, dependent: :destroy    private    def create_market     Market.create(user: self, current_date: 5.years.ago, price: 10)   end end 

Отлично, бд изменена, модели готовы, теперь нужно отредактировать роутинг и добавить контроллер, изменим файл config/routes, теперь он должен выглядеть так:

Rails.application.routes.draw do   root 'markets#show'   get 'users/new', to: 'users#new', as: :new_user    resources :markets, only: [:show] do     member do       post 'buy', to: "markets#buy"       post 'sell', to: "markets#sell"       get 'next_date', to: "markets#next_date"     end   end end 

Для изменения состояния игры нам нужно всего 3 действия: купить, продать и перемотать на следующий день.

Соответственно в app/controllers создаём контроллер users_controller.rb и редактируем его следующим образом:

class MarketsController < ApplicationController   before_action :set_market, only: [:show, :buy, :sell, :next_date]    def show   end    def buy         current_user.buy_tokens(params[:amount_of_dollars_for_buying], @market)      redirect_to market_path(@market), notice: 'Transaction completed successfully.'   end    def sell     current_user.sell_tokens(params[:amount_of_tokens_for_selling], @market)      redirect_to market_path(@market), notice: 'Transaction completed successfully.'   end    def next_date     @market.calculate_next_date      redirect_to market_path(@market), notice: "Today's date is #{@market.current_date}"   end    private    def set_market     @market = current_user.market   end end 

Так как именно юзер производит покупку, вынесем код связанный с куплей/продажей в модель юзера

app/models/user.rb:

def buy_tokens(amount_of_dollars, market)   tokens_to_buy = amount_of_dollars / market.price   if capital >= amount_of_dollars     update(capital: capital - amount_of_dollars, amount_of_tokens: amount_of_tokens + tokens_to_buy)   else     flash[:alert] = 'Not enough capital to buy tokens.'   end end  def sell_tokens(amount_of_dollars, market)   dollars_to_receive = amount_of_dollars * market.price   if amount_of_tokens >= amount_of_dollars     update(capital: capital + dollars_to_receive, amount_of_tokens: amount_of_tokens - amount_of_dollars)   else     flash[:alert] = 'Not enough tokens to sell.'   end end 

Перемотка на следующий день позволяет нам не ждать в режиме реального времени изменения цены. Мы просто перематываем время на следующие сутки вперед и выдаём игроку цену в соответствии с нужной нам формулой.

class Market < ApplicationRecord   belongs_to :user    def calculate_next_date     new_date = current_date + 1.day      new_price = VolatilityService.simulate      update(current_date: new_date, price: new_price)     price_history << { date: new_date.strftime('%d %b'), price: new_price.to_f }     save   end end 

Сделаем сервис, с помощью которого мы сможем имитировать изменение цены приближенное к нормальному распределению и с возможностью устанавливать волатильность и тренд. Создадим в app папку services и в ней файл volatility_service.rb

module VolatilityService   def self.simulate(volatility_coefficient = 0.1, price = 100, trend = 0.001, time_step = 1)         drift = trend * time_step     diffusion = volatility_coefficient * gaussian_distribution * Math.sqrt(time_step)     new_price = price * Math.exp(drift + diffusion)     new_price   end    # Метод позволяет нам сгенерировать случайное число    # в соответствии с нормальным распределением   def self.gaussian_distribution     theta = 2 * Math::PI * rand     rho = Math.sqrt(-2 * Math.log(1 - rand))     scale = 0.4          scale * rho * Math.cos(theta)   end end 

3.5. Устанавливаем flowbite, tailwind и пишем вьюхи

В разделе 3.1 я посоветовал подключить npm и flowbite, они нужны нам для корректной работы пользовательского интерфейса, для написания и задания вьюх, стилей и js. Далее я опишу процесс установки.

Установим tailwind:

./bin/rails tailwindcss:install

Отлично. Теперь нам понадобится flowbite, который является набором пользовательского интерфейса на базе tailwind, очень крутая вещь если вы хотите быстро написать фронтовую чать приложения.

Устанавливаем flowbite через команду npm install flowbite

В config/tailwind.config.js в plugins добавляем flowbite: require('flowbite/plugin')

В content добавляем ./node_modules/flowbite/**/*.js'
Наш tailwind.config.js должен выглядеть так:

const defaultTheme = require('tailwindcss/defaultTheme')  module.exports = {   content: [     './public/*.html',     './app/helpers/**/*.rb',     './app/javascript/**/*.js',     './app/views/**/*.{erb,haml,html,slim}',     './node_modules/flowbite/**/*.js'   ],   theme: {     extend: {       fontFamily: {         sans: ['Inter var', ...defaultTheme.fontFamily.sans],       },     },   },   plugins: [     require('@tailwindcss/forms'),     require('@tailwindcss/typography'),     require('@tailwindcss/container-queries'),     require('flowbite/plugin')({       charts: true,     }),   ] } 

В config/importmap.rb добавим следующее:

pin "flowbite", to: "https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.turbo.min.js"

В app/javascript/application.js добавляем: import 'flowbite';

Далее создаём папку markets и show.html.erb в app/view/markets . Верстку и график мы взяли отсюда

<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script> <div class="container">   <div class="chart-container">     <div id="chart-data"         data-prices="<%= @market.price_history.last(10).map { |entry| entry['price'] }.join(',') %>"         data-dates="<%= @market.price_history.last(10).map { |entry| entry['date'] }.join(',') %>">     </div>     <div id="line-chart"></div>   </div>    <div class="mt-4 mx-auto max-w-md">     <h2 class="text-xl font-bold">Username: <%= current_user.username %></h2>     <p class="text-lg">Today date: <%= @market.current_date.strftime('%Y-%m-%d') %></p>     <p class="text-lg">Price: <%= @market.price %></p>      <div class="grid grid-cols-2 gap-4">       <div>         <%= form_with url: buy_market_path(@market), method: :post, local: true do |form| %>           <%= form.label :amount_of_dollars_for_buying, "Amount of dollars:", class: 'block text-base font-medium mb-2' %>           <%= form.number_field :amount_of_dollars_for_buying, step: 'any', class: 'w-full bg-gray-800 text-white h-10 px-3 rounded mb-2' %>           <%= form.submit "Buy Tokens", class: 'btn btn-green' %>         <% end %>       </div>        <div>         <%= form_with url: sell_market_path(@market), method: :post, local: true do |form| %>           <%= form.label :amount_of_tokens_for_selling, "Amount of tokens:", class: 'block text-base font-medium mb-2' %>           <%= form.number_field :amount_of_tokens_for_selling, step: 'any', class: 'w-full bg-gray-800 text-white h-10 px-3 rounded mb-2' %>           <%= form.submit "Sell Tokens", class: 'btn btn-red' %>         <% end %>       </div>     </div>   </div>    <div id="user-info" class="mt-4 mx-auto max-w-md">     <p class="text-base">You have: <%= number_with_precision(current_user.capital, precision: 2) %> dollars</p>     <p class="text-base">You have: <%= current_user.amount_of_tokens %> tokens</p>   </div>    <div class="mt-4 w-full max-w-md px-4">     <%= link_to 'Next Day', next_date_market_path(@market), class: 'btn btn-lime' %>   </div> </div> <script>   function initializeChart() {     const chartDataElement = document.getElementById("chart-data");     const prices = chartDataElement.getAttribute("data-prices").split(',').map(Number);     const dates = chartDataElement.getAttribute("data-dates").split(',');      const options = {       series: [{         name: "Price",         data: prices,         color: "#00FF00",       }],       chart: {         height: 300,         type: "area",         fontFamily: "Inter, sans-serif",         dropShadow: {           enabled: false,         },         toolbar: {           show: false,         },       },       dataLabels: {         enabled: true,         style: {           cssClass: 'text-xs text-white font-medium',           fontSize: '9px',           colors: ['#049804'],         },         formatter: function (value) {           return value.toFixed(2);         }       },       stroke: {         width: 2,       },       grid: {         show: true,         strokeDashArray: 4,         padding: {           left: 16,           right: 16,           top: 0,         },       },       tooltip: {         enabled: true,         x: {           show: false,         },       },       fill: {         type: "gradient",         gradient: {           opacityFrom: 0.55,           opacityTo: 0,           shade: "#00FF00",           gradientToColors: ["#00FF00"],         },       },       xaxis: {         categories: dates,         labels: {           show: true,           style: {             colors: '#FFFFFF',             fontFamily: "Inter, sans-serif",             cssClass: "text-xs font-normal",           },         },         axisBorder: {           show: false,         },         axisTicks: {           show: false,         },       },       yaxis: {         show: false,       },     };      const chartElement = document.querySelector("#line-chart");     if (chartElement) {       chartElement.innerHTML = '';        const chart = new ApexCharts(chartElement, options);       chart.render();     }   }    document.addEventListener("DOMContentLoaded", initializeChart); </script> 

Запускаем билд: bin/dev , запускаем ngrok, подставляем нужные значения в переменные и снова включаем бота. Получаем следующий результат:

Отлично, всё работает!

Отлично, всё работает!

4. Послесловие, как и кому это может пригодиться

Разработка mini-app довольно интересная и пользующаяся популярностью сфера веб разработки которая появилась сравнительно недавно. Представляется, что telegram-mini app это отличная «база» для создания прототипов мобильных приложений и минимально жизнеспособных продуктов(MVP).

Вы можете собрать команду из мобильных разработчиков и бэк разработчиков, но зачем, если можно написать Telegram Mini App, который будет выглядеть 1 в 1 как мобильное приложение и будет написан фулстек разработчиками. Создание прототипа вследствие этого окажется дешевле, продукт будет протестирован в рамках аудитории телеграм.

Конечно это не заменяет полноценную мобильную разработку и имеет свои нюансы, в том числе минусы. Но об этом как-нибудь в другой раз.

Статья написана при участии
Годунов Михаил
Летяга Данил
Баев Георгий


ссылка на оригинал статьи https://habr.com/ru/articles/829520/