Во время пандемии как никогда стала востребована удаленная работа. Как перенести привычные процессы из офиса в онлайн? Я думаю, что многие команды прошли через свои испытания. Нас пандемия затронула в меньшей степени, потому что почти вся компания состоит из удаленных сотрудников. В прошлом году я начал процесс перевода нашей разработки на Agile. И для улучшения коммуникации в команде я решил попробовать проведение стендап собраний. Т.к. все команды находятся в разных часовых поясах и проводить стендапы не очень удобно в реально времени, решил попробовать автоматизировать весь процесс и созать бота в Slack.
Будем использовать принцип трех «И» (ИИИ) — Идея, Инструментарий, Исполнение. Поехали.
Идея
Организатор запускает бота командой /standup
:
/standup <ID команды>
В организации может быть множество команда, поэтому у каждой будет свой уникальный ID, например /standup sales
.
Далее бот рассылает каждому участнику команды приглашение на участие. Участник может в удобное для себя время заполнить простую форму с тремя вопросами:
- Что делал вчера?
- Планы на текущий день?
- Есть ли какие-то проблемы/блокеры?
Все ответы аккумулируются в базе данных и позволяют команде просматривать как текущие ответы, так и историю ответов за прошедшие периоды.
Инструментарий
Бота будем создавать на JavaScript, используюя библиотеку Bolt. Все это будет работать на Node.js сервере Heroku. В качестве бекенда будем использовать Google Firebase: Firestore для базы данных, Hosting для отчетов. К сожалению, я не смогу детально остановиться на каждом пункте, поэтому где-то информация будет дана в сжатом виде, иначе данная статья превратится в книгу.
Исполнение
Настраиваем Heroku
Прежде чем переходить к созданию бота, нужно настроить Heroku. Устанавливаем Heroku CLI. Я все это тестировал на Ubuntu, поэтому инструкции к нему. А так, есть пакеты установки как для Windows, так и для Mac. Более подробно можно узнать здесь.
# sudo snap install --classic heroku
После установки авторизуемся:
# heroku login
Создаем приложение:
# heroku create
Creating app... done, ⬢ infinite-scrubland-69739
https://infinite-scrubland-69739.herokuapp.com/ | https://git.heroku.com/infinite-scrubland-69739.git
И инициализируем Git репозиторий:
# mkdir slack-bot && cd slack-bot
# git init
# heroku git:remote -a infinite-scrubland-69739
Создаем приложение Slack
В качестве App Name можно выбрать любое название. Его будут видеть все ползователи в Slack (его можно изменить). Development Slack Workspace — это организация, к которой будет привязано приложение (в дальнейшем изменить нельзя).
Для корректной работы бота, необходимо активировать соответствующие модули в Slack.
Разрешения на отправку сообщений
Теперь нам нужно настроить минимально необходимые права для бота, чтобы была возможность авторизовать приложение и сгенерировать ключ OAuth. На странице OAuth & Permissions, в секции Scopes — Bot Token Scopes добавляем возможность отправки сообщений.
После этого бота можно будет установить в организацию, нажав кнопку Install to Workspace наверху страницы.
Когда приложение будет авторизовано, на этой же странице появится Bot User OAuth Access Token. Его и Signing Secret со страницы Basic Information нужно добавить в Heroku:
heroku config:set SLACK_SIGNING_SECRET=<signing-secret>
heroku config:set SLACK_BOT_TOKEN=xoxb-<oauth-token>
Это нужно сделать сейчас, потому что дальнейшие шаги уже будут требовать ответа от серверной части.
Интерактивные компоненты
Чтобы использовать элементы UI (кнопки, формы и т.д.) нужно активировать функцию Interactivity в разделе Interactivity & Shortcuts.
В Request URL нужно добавить URL сервера Heroku из предыдущего шага. Если не получается найти URL, то выполняем следующую команду:
# heroku info
=== infinite-scrubland-69739
Auto Cert Mgmt: false
Dynos:
Git URL: https://git.heroku.com/infinite-scrubland-69739.git
Owner: ********
Region: us
Repo Size: 0 B
Slug Size: 0 B
Stack: heroku-20
Web URL: https://infinite-scrubland-69739.herokuapp.com/
Request URL будет состоять из https://infinite-scrubland-69739.herokuapp.com + /slack/events
Команды
В разеделе Slash Commands созаем новую команду. В поле Request URL вводит тот же URL что и в шаге выше.
Обработка событий
В разделе Event Subscriptions нужно добавить тот же URL как и выше. На данный момент наша серверная часть еще не готова, поэтому будет отображено сообщение, что невозможно верифицировать введенный URL. Не страшно, мы это исправим в следующем шаге.
Так же нужно добавить два события, которые будет отслеживать наш бот — сообщения в открытых и частных каналах. Вот как это должно выглядеть:
Программируем
Выше мы уже создали директорию для нашего бота. Первым делом нам нужно удостовериться, что все наши настройки в Slack работают, поэтому создадим «пустую» серверную часть без функицонала, но даже в таком состоянии сервер уже должен начать отвечать на наши запросы, а не выдавать 404.
Создаем файл package.json и устанавилваем библиотеку Bolt:
# npm init
# npm install @slack/bolt
Чтобы Heroku знал как запускать наше приложение, нужно создать файл Procfile
. Создаем его в папке бота со следующим содержанием:
web: node app.js
Ну и сам app.js
. В базовом виде он выглядит так:
const { App } = require('@slack/bolt');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET
});
(async () => {
await app.start(process.env.PORT || 8000);
})();
Что мы здесь делаем? Импортируем, инициируем библиотеку и ждем запросов на 8000 порту. SLACK_BOT_TOKEN и SLACK_SIGNING_SECRET мы задавали выше.
И последнее. Когда мы push’аем код, Heroku автоматически определит что это за код и попыатеся установить необходимые библиотеки. Поэтому папку node_modules
нужно исплючить. Для этого добавляем файл .gitignore
со следующим содержанием:
node_modules/
package-lock.json
Отгружаем на Heroku:
# git commit -am "Initial release"
# git push heroku master
На данном этапе наша серверная часть уже принимает запросы от бота и мы обработка событий в Slack (см. предыдущий шаг) должна проходить успешно. Если этого не происходит — ищите причину. Возможно неверно заданы ключи.
Еще такой момент. Бесплатные приложения в Heroku могут засыпать и просыпаться только по запросу. Так что если вдруг что-то не работает, попробуйте еще раз через пару секунд.
Cloud Firestore
Создаем новый проект в Firebase и создаем новую базу Firestore. Для поддержки хранилища Firestore, нам нужна SDK библиотека firebase-admin
, устанавилваем:
# npm install firebase-admin
Чтобы использовать данную базу с нашего сервере Heroku, нужен будет сервис аккаунт. Создаем его в консоле Google Cloud.
После создания, нажимаем на имя аккаунта и создаем ключ в формате JSON:
После создания, ключ будет автоматически загружен на компьютер, сохраняем его в папку с нашим ботом и используем его для инициализации SDK:
const firebase = require('firebase-admin');
const serviceAccount = require('./infinite-scrubland-69739-firebase-adminsdk-to2j1-adf3d12429.json');
firebase.initializeApp({
databaseURL: process.env.FIREBASE_DB_URL,
credential: firebase.credential.cert(serviceAccount),
});
const db = firebase.firestore();
FIREBASE_DB_URL
можно найти в настройках проекта Firebase, его добавляем в качестве переменной в Heroku:
# heroku config:set FIREBASE_DB_URL=<databaseURL>
Например, с помощью следующего кода мы можем создавать новые коллекции и документы:
const docRef = db.collection('users').doc('alovelace');
await docRef.set({
first: 'Ada',
last: 'Lovelace',
born: 1815
});
Команды для бота
Следующий шаг — сделать так, чтобы бот отвечал на команду /standup <ID команды>
. Список участников команд будем хранить в переренной. В дальнейшем этот функционал можно расширить, чтобы данные обрабатывались через бота, но в рамках данной статьи это будет перебор. Задаем команды:
const teams = {
sales: ['anton', 'dima', 'katya'],
marketing: ['oleg', 'nikita']
};
Простой объект, где в качестве ключа у нас ID команды, в качестве значения — массив с пользователями в Slack.
Обработка команд работает по следующему принципу:
// Ожидаем команду /standup
app.command('/standup', async ({ ack, body, context }) => {
// На все команды должен поступать ответ, иначе через 3 секунды будет таймаут
await ack();
// Обработка
});
Мы будем использовать следующую логику. Если пользователь не задал команду или команды не существует, бот ответит, мол, так и так, такой команды нет, попробуйте /standup list
, чтобы просмотреть список досупных команд. Если команда существует, то бот разошлет всем пользователям из команды приглашение на участие в стендапе.
В самом базовом виде, чтобы бот мог нам что-то ответить, мы должны воспользоваться командой say()
или через Slack API с помощью chat.postMessage()
. Например, если мы хотим, чтобы бот на вопрос «Как дела?» отвечал «Неплохо!», мы бы воспользовались следующим кодом:
app.message('Как дела?', async ({ message, say }) => {
await say('Неплохо!');
});
Вот что у нас получилось на выходе:
app.command('/standup', async ({ ack, body, context }) => {
await ack();
let error = false;
let msg = "";
// Выводим список доступных команд
if ('list' === body.text) {
error = true;
msg = 'Available teams: ' + Object.keys(teams).join(', ');
}
// Выбранная команда не существует
if (!error && 'undefined' === typeof teams[body.text]) {
error = true;
msg = `Команды _${body.text}_ не существует. Попробуйте с другой командой или напишите \`/standup list\` чтобы получить список доступных команд.`;
}
if (error) {
await app.client.chat.postMessage({
token: context.botToken, // Без этого не хватит прав на отправку сообщения
channel: body.channel_id, // ID канала, куда отправить ответ. Отправляем туда, откуда пришел запрос
text: msg // Сообщение
});
return;
}
for (let i = 0; i < teams[body.text].length; i++) {
await app.client.chat.postMessage({
token: context.botToken, // Без этого не хватит прав на отправку сообщения
channel: `@${teams[body.text][i]}`, // Пользователь Slack, например @anton
blocks: views.standup_request({ body }) // Интерактивный блок (вид)
});
}
});
Интерактивные блоки
Если запустить код выше, то он не будет работать, потому что там есть views.standup_request({ body })
, элемент, содержащий наш интерактивный вид с запросом на участие в стендапе. Все наши виды будут храниться в отдельном файле inc/views.js
.
Запрос на участие выглядит примерно вот так:
На портале для разработичков Slack есть интерактивный конструктор, через который можно создавать различные виды блоков.
Ну или сам код:
module.exports = {
standup_request: context => {
return [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Новый запрос для участия в стендапе от: <@${context.body.user_name}>.*`
}
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: '*Событие:*\nЕжедневный стендап'
},
{
type: 'mrkdwn',
text: '*Команда:*\n'+context.body.text
}
]
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'Участвовать'
},
style: 'primary',
action_id: 'approve_request', // ID для обработки ответа
value: context.body.text // ID команды
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'Отклонить'
},
style: 'danger',
action_id: 'deny_request' // ID для обработки ответа
}
]
}
]
},
};
В файле app.js
нужно будет импортировать наши блоки:
const views = require('./inc/views');
Сохраняем все изменения на Heroku и проверяем, что бот работает.
# git commit -am "Add /standup command"
# git push heroku master
Открываем бота в Slack и проверяем правильно ли он отвечает на наши команды /standup
.
Если все работает корректно, создадим обработчики событий для участия и отказа от участия. Если посмотреть на код нашего блока, то там будут заданы два action ID: approve_request
и deny_request
. Бот всегда ожидает какого-то события и зная ID, мы можем с легкостью написать обработчик:
app.action('deny_request', async ({ ack, body, client, say }) => {
await ack();
await client.chat.delete({
channel: body.container.channel_id,
ts: body.container.message_ts
});
await say('Запрос отклонен 👍');
});
Это пример для обработки отклоненного запроса. Все достаточно просто. Мы ждем когда сработает deny_request
(пользователь нажал на кнопку «Отклонить» в запросе) и высылаем подтверждение пользователю — «Запрос отлклонен». Здесь можно дать волю фантазии — отклоненные запросы сохранять в базу данных, высылать уведомление руководителю группы или напоминть через пару часов.
Обработчик approve_request
очень похож на deny_request
, с одной лишь разницой — мы получаем знаечение команды, которое раньше передавали в форме standup_request
через значение context.body.text
, и выводим форму для ответа:
app.action('approve_request', async ({ ack, body, client }) => {
await ack();
const team = body.actions[0]['value'];
await client.chat.delete({
channel: body.container.channel_id,
ts: body.container.message_ts
});
await client.views.open({
trigger_id: body.trigger_id,
view: views.form_submission( team )
});
});
При принятии приглашения на участие, пользователю будет показана форма с вопросами.
В файле inc/views.js после нашего вида запроса добавляем форму ответа:
module.exports = {
standup_request: context => {
// Запрос на участие
},
form_submission: team => {
return {
type: 'modal',
callback_id: 'form_submission',
title: {
type: 'plain_text',
text: 'Отправить отчет'
},
private_metadata: team, // ID команды
blocks: [
{
type: 'section',
text: {
type: 'plain_text',
text: 'Данная форма будет использована для создания отчета на день.'
}
},
{
type: 'divider'
},
{
type: 'input',
block_id: 'yesterday',
element: {
action_id: 'yesterday_action',
type: 'plain_text_input',
multiline: true
},
label: {
type: 'plain_text',
text: 'Что делали вчера?'
}
},
{
type: 'input',
block_id: 'today',
element: {
action_id: 'today_action',
type: 'plain_text_input',
multiline: true
},
label: {
type: 'plain_text',
text: 'Планы на сегодня?'
}
},
{
type: 'input',
block_id: 'blocker',
element: {
action_id: 'blocker_action',
type: 'plain_text_input',
multiline: true
},
label: {
type: 'plain_text',
text: 'Есть ли препятствия для достижения целей?'
}
}
],
submit: {
type: 'plain_text',
text: 'Отправить'
},
close: {
type: 'plain_text',
text: 'Отмена'
}
}
}
};
Сохраняем все на Heroku, и проверяем.
# git commit -am "Add approve/deny request actions"
# git push heroku master
Завершающий элемент — обработка формы. В обработчике мы получим название команды, которое мы передавали из запроса через value: context.body.text
в форму ответа через private_metadata: team
.
Структура нашей базы данных будет иметь следующий вид:
- множество коллекций в виде <ID команды> _<дата в формате ДДММГГГГ>
- в каждой коллекции будет документ в виде <имя пользователя>
- каждый документ будет содержать три поля — yesterday, today, blockers
В ответ на отправку формы, пользователь будет получать ответ о статусе (успешно сохранилась или ошибка).
app.view('form_submission', async ({ ack, body, view, client }) => {
await ack();
// Получаем дату
const today = new Date();
const dd = String(today.getDate()).padStart(2, '0');
const mm = String(today.getMonth() + 1).padStart(2, '0'); // January is 0
const yyyy = today.getFullYear();
// Сохраняем в БД
const collection = view.private_metadata + '_' + dd + mm + yyyy;
const docRef = db.collection(collection).doc(body.user.username);
const values = Object.values(view.state.values).map(element => {
const key = Object.keys(element)[0];
return element[key]['value'];
});
const results = await docRef.set({
yesterday: values[0],
today: values[1],
blockers: values[2]
});
// Отправляем ответ пользователю
let msg = 'Ошибка при сохранении ответов';
if (results) {
msg = 'Спасибо за участие :tada:';
}
await client.chat.postMessage({
channel: body.user.id,
blocks: views.success_view({ msg })
});
});
Сохраняем все на Heroku, и проверяем.
# git commit -am "Add form processing"
# git push heroku master
Выводы
Процесс создания бота не самый сложный, но уровень документации в некоторых моментах оставляет желать лучшего.
Эта статья лишь поверхностно затрагивает процесс, и многие моменты здесь не раскрыты полностью, но надеюсь этого будет достаточно, чтобы подтолкнуть в нужном направлении.