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 наполнив его следующим кодом:



Профиль пользователя

<% if @user %>

ID: <%= @user.telegram_id %>

Имя: <%= @user.name || 'Не указано' %>

Имя пользователя: <%= @user.username || 'Не указано' %>

<% else %>

Пользователь не найден

<% 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 . Верстку и график мы взяли отсюда


Username: <%= current_user.username %>

Today date: <%= @market.current_date.strftime('%Y-%m-%d') %>

Price: <%= @market.price %>

<%= 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 %>
<%= 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 %>

You have: <%= number_with_precision(current_user.capital, precision: 2) %> dollars

You have: <%= current_user.amount_of_tokens %> tokens

<%= link_to 'Next Day', next_date_market_path(@market), class: 'btn btn-lime' %>

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

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

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

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

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

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

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

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

© Habrahabr.ru