Создаем бота в Slack для Agile стендап (Node.js + Bolt + Firebase)

Во время пандемии как никогда стала востребована удаленная работа. Как перенести привычные процессы из офиса в онлайн? Я думаю, что многие команды прошли через свои испытания. Нас пандемия затронула в меньшей степени, потому что почти вся компания состоит из удаленных сотрудников. В прошлом году я начал процесс перевода нашей разработки на 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

Выводы

Процесс создания бота не самый сложный, но уровень документации в некоторых моментах оставляет желать лучшего.

Эта статья лишь поверхностно затрагивает процесс, и многие моменты здесь не раскрыты полностью, но надеюсь этого будет достаточно, чтобы подтолкнуть в нужном направлении.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *