Привет, читатели, это вторая часть обучающих постов о написании Slack App с использованием чистого Ruby.
Если вы не знакомы со списком частей, то вот он (со ссылками):
Добавление чартов, или как делать рендер фронта на сервере (Мы здесь).
Во время разработки своего приложения в качестве проекта внутри компании, поступил запрос на постройку графика по некоторой выборке данных, чтобы по команде Slash Command происходила постройка графика и его публикация в чат.
Первым делом я обратился к уже существующим решениям, графики через API Google, готовые гемы для Ruby. Основной минус в том, что не было возможности убрать или добавить легенду в том формате, который мне нужен, сложно кастомизировать внешний вид этих графиков и, к примеру, нет возможностей строить график по значению timestamp, а выводить уже значения в формате DateTime.
когда я говорю про сложность действия, то имею в виду в сравнении с альтернативным моим решением, где это делается проще и быстрее
Имея на вооружении тот метод, который освещает эта статья, любой сможет строить какую угодно страницу полностью на сервере, получать фото этой страницы и использовать её в коде. В будущем можно приспособить этот подход для, например, для предпоказа тем на своем движке, генерации каких-то изображений с подвязкой к внешнему API и заключении всего в html документ. В целом, применений реально много, собственно поэтому и решил поделится solution'ом.
Поэтому предлагаю вам скорее ознакомится с материалом, мы научимся сначала делать график с использованием своего js скрипта, а потом поймём как его рендерить в коде. Представляю содержание:
Скажу сразу, что в этом уроке я буду использовать термины из прошлой части, и вообще описывать некоторые процессы так, как если бы вы уже прочитали первую часть.
Флоучарт для этого таска я вижу таким:
Настройка модального окна через Slash Command для вызова построения графика
Я вижу такую картину: сначала происходит запрос на Slash Command, мы его обрабатываем и в качестве ответа возвращаем view, в котором внутри уже находится график. Если пользователь хочет этот график пошарить в чат, то нажимает соответствующую кнопку, если нет - просто закрывает окно и уходит по своим делам.
Первым делом добавляем ендпоинт на панели управления приложением для команды : `/graph`.
Затем пишем простенький шаблон вывода, чтобы проверить, что всё норм работает, обработка вызова команды выглядит так:
post '/graph' do
get_input
Database.init
access_token = Database.find_access_token input['team_id']
client = create_slack_client access_token
triger_id = input['trigger_id']
template = File.read './Components/View/graph.erb'
view = ERB.new(template).result(binding)
client.views_open(
trigger_id: triger_id,
view: view
)
status 200
end
Сам файл шаблона graph.erb как-то так :
{
"type": "modal",
"title": {
"type": "plain_text",
"text": "Граф"
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Тут будет график "
}
}
]
}
Собственно запускаем сервер, пишем в чате (в любом) `/graph` и видим такой результат:
Если у вас такой же результат, значит всё делаете правильно! Переходим к следующему этапу.
Пишем html/js/css шаблон для графика
Какая задумка дальше, опускаемся на уровень ниже. Пользователь имеет некоторые данные, по которым он хочет построить свой кастомный график, нужно решить:
написать скрипт, который по данным будет рисовать на html сам график
самое главное - полученый результат нужно как-то отобразить в slack.
В этом разделе я планирую рассмотреть первый и второй пункт.
Создадим папку в директории Components - Graph. В папке Graph нам нужно будет 3 файла : style.css, index.html и main.js (Изображение 3)
Данные проще всего сохранить в формате json, в файл. В js скрипте будем считать, что на момент запуска файл существует, так как мы сами и будем гарантировать существования данных через код. Значит - нужно написать функцию по получению данных в js скрипте:
function readTextFile(file, callback) {
var rawFile = new XMLHttpRequest();
rawFile.overrideMimeType("application/json");
rawFile.open("GET", file, false);
rawFile.onreadystatechange = function () {
if (rawFile.readyState === 4 && rawFile.status == "200") {
callback(rawFile.responseText);
}
}
rawFile.send(null);
}
будем использовать функцию чтения из файла таким образом:
readTextFile('data.json', function (text) {
dataJson = (JSON.parse(text))
//Тут работа с данными которые записаны в dataJson переменную
});
Следующий шаг - написать функцию, которая бы сохраняла данные в промежуточный файл - data.json. Данные будут в виде:
{
"2021":
{
"Jun":8207,
"Jul":12455,
"Aug":10086
},
"2022":
{
"Jul":1234,
"Oct":5678,
"Jan":9123
}
}
Первый уровень обозначает год, второй уровень - месяц и значение месяца - timestamp какой-то операции. Я хочу увидеть статистику по месяцам, по данной операции. Если вы не против, я запишу эти данные как константу и не буду придумывать кейсы, где эти данные взять и что они означают :)
Запишем данные в файл, в директорию - Graph. Коротко это можно сделать так:
DATA_TO_JSON = {"2021": {"Jun": 8207, "Jul": 12455, "Aug": 10086}, "2022": {"Jul": 1234, "Oct": 5678, "Jan": 9123}}
File.open('./Components/Graph/data.json', 'w') do |file|
file.write DATA_TO_JSON.to_json
end
ОБЯЗ! Данные, не важно в каком они формате записаны в Ruby - переводим в json для того что бы js мог их считать.
Теперь опишу сам скрипт и что там к чему
В индекс файле лежит div, который имеет id = chart. В скрипте я строю Dom элемент, он и есть график, и просто засовываю его в этот div с чартом. В будущем можна в любом месте, на любом сайте поставить такой div=chart и туда подгрузится график (файл style.css и main.js тоже должны присутствовать, так как они отвечают за работоспособность).
Сначала создается элемент div, в нём таблица, добавляются по одному столбцы с динамической высотой, равной timestamp, потом в зависимости от того, в каком диапазоне находится время , столбцу присваивается цвет, потом легенда сверху и ленеда снизу. Перед тем, как ставить легенду сверху - превращаем её во время формата HH:MM:SS. Значение в нижней легенде равно ключу второго уровня + ключ первого уровня.
Собственно и всё. Получаем данные из файла, в цикле создаём по ним график, добавляем график на html.
Рендерим и получаем фото
Собственно ключевая вещь в этом туториале. Когда я захотел сделать такой чарт, то не задумался о том, как же собственно html будет обрабатыватся сервером. Ну, типо, есть теги, это всё круто отображается в браузере как надо.
И тут то я прозрел, что html - язык разметки браузера, а браузера то на сервере нет :)
Много времени лично я потратил, чтобы понять, что делать с этим вот всем и нашёл решение в лице Chrom Headless, что буквально значит хром, но без head (head-морда, фронтенд, оболочка). Работает всё очень классно и просто, движок хрома может отпарсить и рендерить любую страницу, js встроенный есть и все дела. Вообще всё то же самое, что и в вашем браузере, но общаетесь с ним на кликами, а командами.
Нам нужен новый гем - `gem "ferrum"`, добавьте его к себе в Gemfile и запустите bundle update.
Далее в обработчике команды пропишем:
browser = Ferrum::Browser.new
browser.go_to("https://#{request.host}/render_graph")
browser.screenshot(path: "Components/Graph/result.png")
Собственно всё просто и понятно, вот только когда мы попытаемся попасть на адресс
"https://#{request.host}/render_graph"
то явно не попадём на index.html, чтобы это исправить - добавим в файл command.rb обработку get запроса для страницы render_graph, и тут же для остальных наших файлов, так как обращение к ним будет идти по такому же "неточному" пути.
get '/render_graph' do
send_file 'Components/Graph/index.html'
end
get '/main.js' do
send_file 'Components/Graph/main.js'
end
get '/style.css' do
send_file 'Components/Graph/style.css'
end
get '/data.json' do
send_file 'Components/Graph/data.json'
end
Всё готово к запуску, добавим require к классу, в котором будем вызывать библиотеку Headless браузера :
require 'sinatra/base'
require 'slack-ruby-client'
class API < Sinatra::Base
require 'ferrum'
Перезапускаем сервер и пишем /graph, затем смотрим в папку Graph и наблюдаем файл result.png который выглядит вот так:
Вывод результата в модальное окно и отправка в чат
Теперь дело за малым:
Строим модальный блок
Отправляем этот блок пользователю
Строим модальный блок
Для изображений блок будет примерно таким :
{
"type": "image",
"image_url": "http://<%= host %>/graph_image.png",
"alt_text": "Сер Граф"
}
Редактируем весь файл graph.erb к такому виду :
{
"type": "modal",
"title": {
"type": "plain_text",
"text": "Граф"
},
"blocks": [
{
"type": "image",
"image_url": "http://<%= host %>/graph_image.png",
"alt_text": "Сер Граф"
}
]
}
Тут, по аналогии с примером index.html страницы, необходимо по запросу {host}/graph_image.png вернуть result.png, настроить этот запрос-ответ с помощью такого кода :
get '/graph_image.png' do
send_file 'Components/Graph/result.png'
end
Так же, для заполнения <%= host %> , необходимо, перед вызовом шаблона, добавить инициализацию переменной host
host = request.host
Отправляем этот блок пользователю
Собственно мы уже настроили отправление модального окна пользователю, теперь ничего менять не нужно, так как мы не вмешивались в этот процесс, если не считать переделку шаблона для модального окна.
Могу лишь добавить, что после отправки результата на Slack API, нам не нужен ни результат, ни data.json. Удаляем их:
File.delete "./Components/Graph/result.png"
File.delete "./Components/Graph/data.json"
Результаты
Запустим в чате команду /graph и понаблюдаем что будет в логе и что будет видно юзеру :
Собственно результат ровно тот, что мы ожидали. Ладно, не ровно такой, я говорил о кнопке отправки в чат, но есть ли смысл мне это реализовывать, если в этом и в прошлом уроке я подробно описал как постить сообщения, как обрабатывать модальные команды и всякие такие штуки. Даже блоки уже готовы, нужно лишь поменять в graph.erb хидер с модального окна на нужный вам.
В итоге мы научились :
Рендерить html/css/js c помощью Headless браузера на сервере.
Строить график с необходимыми нам параметрами.
Выводить изображения в модальные окна.
Отправлять файлы по запросу на сервер.
Сохранять данные в файл.
Файл command.rb
Поскольку, вся реализация происходит с постоянной модификацией файла command.rb, я считаю нужным выложить его финальный код, чтобы любой мог сверить результат :
require 'sinatra/base'
require 'slack-ruby-client'
class API < Sinatra::Base
require 'ferrum'
attr_accessor :access_token
attr_accessor :input
def get_input
self.input = Rack::Utils.parse_nested_query(request.body.read)
puts 'Получена входная строка'
end
get '/test' do
status 302
redirect "https://slack.com/oauth/v2/authorize?&client_id=#{SLACK_CONFIG[:slack_client_id]}&scope=app_mentions:read,channels:history,channels:read,chat:write,chat:write.customize,commands,files:write,groups:history,groups:read,im:history,im:read,mpim:read,users.profile:read,users:read&user_scope=channels:history,channels:read&redirect_uri=#{SLACK_CONFIG[:redirect_uri]}"
end
post '/who_am_i' do
get_input
Helper.displayUserInfo input['team_id'], input['user_id'], input['channel_id']
status 200
end
post '/menu' do
get_input
Database.init
access_token = Database.find_access_token input['team_id']
client = create_slack_client access_token
triger_id = input['trigger_id']
metadata = input['channel_id']
template = File.read './Components/View/menu.erb'
view = ERB.new(template).result(binding)
client.views_open(
trigger_id: triger_id,
view: view
)
status 200
end
get '/render_graph' do
send_file 'Components/Graph/index.html'
end
get '/main.js' do
send_file 'Components/Graph/main.js'
end
get '/style.css' do
send_file 'Components/Graph/style.css'
end
get '/data.json' do
send_file 'Components/Graph/data.json'
end
get '/graph_image.png' do
send_file 'Components/Graph/result.png'
end
post '/graph' do
get_input
DATA_TO_JSON = {"2021": {"Jun": 8207, "Jul": 12455, "Aug": 10086}, "2022": {"Jul": 1234, "Oct": 5678, "Jan": 9123}}
File.open('./Components/Graph/data.json', 'w') do |file|
file.write DATA_TO_JSON.to_json
end
browser = Ferrum::Browser.new
browser.go_to("https://#{request.host}/render_graph")
browser.screenshot(path: "Components/Graph/result.png")
Database.init
access_token = Database.find_access_token input['team_id']
client = create_slack_client access_token
triger_id = input['trigger_id']
host = request.host
template = File.read './Components/View/graph.erb'
view = ERB.new(template).result(binding)
client.views_open(
trigger_id: triger_id,
view: view
)
File.delete "./Components/Graph/result.png"
File.delete "./Components/Graph/data.json"
status 200
end
end
Часть кода осталась с прошлого урока :)
Ресурсы
Ссылка на Github репозиторий с реализацией этой части + прошлой части
(ветка my_graph)