1.1. Телеграм мини эппы и Руби
С недавних пор функциональность Telegram сильно выросла. Помимо привычных нам ботов, особенно ярко выделяются Telegram Mini Apps. Изучив, как это работает, у авторов данной статьи появилась идея написать небольшое приложение и желание высказать некоторые тезисы, которыми хотелось бы поделиться с сообществом.
Поскольку, по мнению команды, язык программирования Ruby и его фреймворк Ruby on Rails являются наилучшим решением для быстрой разработки и стартапов, они были выбраны в качестве основных инструментов. Также важно отметить, что на GitHub на момент написания статьи было очень мало примеров кода для реализации Mini App на Ruby. Всемирная паутина тоже не особо радовала нас рабочими примерами, поэтому этой статьёй хотелось бы внести свой посильный вклад в сферу, касающуюся разработки на Ruby.
1.2. Финансовая грамотность
Так как участники команды разработки некоторое время посвятили изучению сферы финансовых активов и криптовалют, нами было принято решение создать игру, моделирующую рыночную волатильность на примере одной выдуманной акции. Назовём её «AVA» (an volatile asset), что по-русски означает «нестабильный актив».
Зачем мы хотим моделировать рыночную волатильность и при чем тут финансовая грамотность?
Известные инвесторы Джон Богл и Бенджамин Грэм в своих работах выдвинули тезисы о преимуществах пассивного инвестирования перед активным управлением портфелем. В книге «Разумный инвестор» Грэм описывает активное управление как сложное и часто неэффективное для большинства инвесторов из-за высоких издержек и неопределённости результатов. Он рекомендует инвесторам сосредоточиться на пассивном подходе через инвестирование в долгосрочные индексные фонды или диверсифицированные портфели акций и облигаций, что минимизирует риски и издержки. Богл также поддерживает концепцию индексных фондов как эффективного инструмента пассивного инвестирования, отказываясь от активного управления в пользу доступа к широкому рынку с низкими комиссиями и минимальными издержками.
Резюмируя: по мнению указанных авторов, попытки заработать на спекуляциях на рынке ценных бумаг статистически невыгодны по сравнению с долгосрочным инвестированием по стратегии «купил и забыл». Наша игра создана для того, чтобы продемонстрировать эту концепцию. Чтобы выиграть в ней больше, нужно просто купить актив и держать его, но, как ценители азарта, мы даём игроку возможность поспекулировать. Конечно, как и в реальной жизни, найдутся счастливчики, которые смогут словить удачу и заработать виртуальную валюту, однако спекулянты довольно часто проигрывают на длинных дистанциях.
2.1. Функциональность игры
По описанию выше, игра должна иметь следующую функциональность:
-
Авторизация с использованием Telegram;
-
Наличие дат, которые можно «листать», т.е. при нажатии на кнопку «Следующая дата» должен промотаться счетчик времени на n единиц вперед, чтобы игрок не ждал изменений цены;
-
Цена должна колебаться день ото дня;
-
График, отражающий колебания;
-
Возможность покупать и продавать;
-
Капитал, выданный игроку в начале игры.
3.1. Подготовка
Итак, для того чтобы всё работало, нужно предварительно сделать следующее:
Установить:
-
Ruby и Ruby on Rails (7 версия);
-
Redis;
-
Ngrok;
-
PostgreSQL;
-
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 порту, соответственно и ngrok нужно запустить на этот порт, запуск необходимо произвести в новой вкладке терминала(если вы в убунту):
ngrok http http://localhost:3000
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/
Добавить комментарий