11
10

More than 3 years have passed since last update.

はじめに

この記事は 株式会社ピーアールオー(あったらいいな!を作ります) Advent Calendar 2019 の11日目の記事です。
今回は最近困っていることを解決!とまではいきませんが、解決のための事前準備(学習)をしていきたいと思います。

Slackで勤怠アプリを作りたい!

私の所属しているチームはbacklogに日報をあげているのですが、
「backlogを開いて勤怠を書くのが若干手間」や「案件ごとの勤務時間を集計し辛い」などの不満がありました。

そこで、「試しにSlackで日報やってみる?」という話が出たので、Slackに移行することになりました。
ですが、現状はとりあえず移行しただけで集計など自動で行っておらず、フォーマットを揃えてSlackに記載しているだけです。

今後、

  • Slackでハッシュタグ アプリのHome画面やモーダルなどで日報を提出(書き込み)
  • DBなどに保存(集計用)
  • Slackで提出(書き込み)した日報をBacklogへ自動で転記
  • Slackで提出した勤務時間を集計

などやろうと思っているのですが、いきなりSlackアプリを作るのはキツイ...(作り方知らない。)
ということで、とりあえずSlackの学習を兼ねてSlackアプリのホーム画面を作成するチュートリアルをやっていこうと思います。

やること

Slackのチュートリアル「Building a home for your app」ベースでとりあえず動かしてみる & ある程度ソースを見ていければと思います。

※細かい解説などするほど知識がないので、やってみた中で自分なりのポイントだけ書いていこうと思います。

前提知識(使ったもの)

glitch
無料でNodeなどのウェブアプリを公開できるサービスを利用しました。
色々な方が記事を書いていると思いますので、以下などを参考にしてください。
ブラウザだけで完結するウェブアプリ作成環境 Glitch

Slackアプリの流れ

チュートリアル曰く、Slackアプリの流れは以下のようになっているようです。

1.クライアントでアプリホームの表示やボタンなどのアクションを押した際、Slackサーバへリクエストを発行。
2.Slackサーバはそれをトリガーにアプリ用のサーバへリクエストを送信。
3.アプリ用のサーバはそのリクエストをトリガーに結果をSlackのサーバへ返信。
4.Slackのサーバはクライアントに結果を送信。
5.クライアントがSlackサーバから受信した内容を描写。

image.png

やってみよう!

アプリ用サーバの作成(glitchでチュートリアルのソースをフォーク)

とりあえず動かしたいので、アプリ用のサーバ(nodejs、express)をglitchで作成して動かしていきます。
チュートリアルにある、ソースコードをフォークさせます。

ソースコードのリンク先(glitch)に移動して、以下よりソースをフォークさせます。

チュートリアルのソースをRemixProjectを押してフォークさせる
image.png

一旦サーバ側は完了です。
環境変数の設定が必要ですが、アプリを作らないと設定できないので後回しにします。

.env
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=

アプリ作成

Your App画面からアプリを作っていきます。
「Create an App」から、アプリ名と対象のワークスペースを選択すれば作成できます。
※キャプチャー撮り忘れましたが、利用規約をいくつか承認しないと作成できなかったと思います。

image.png

image.png

設定色々

Slackのチュートリアル「Building a home for your app」ベースで各種設定をしていきます。

ホーム画面設定

ホーム画面を使うために必要?だと思うのでサインインだけしておきます。
ホームタブやメッセージタブの表示などの設定もできるようです。

「Features」 > 「App Home」 > 「Sign up」

イベントAPIの設定

イベントAPIを有効にするための設定をします。

  1. 「Features」 > 「Event Subscriptions」 > 「ON」
  2. 「Request URL」に先ほどフォークさせたソースのLiveAppのURL + /slack/events
    例: https://slack-kintai.glitch.me/slack/events
  3. 「Subscribe to bot events」に「app_home_opened」イベント追加
  4. 「Save Change」で保存

先ほどglitchでフォークさせたソースのLiveURLを元にエントリーポイントとして設定します。
「LiveURL/slack/events」としているのは、expressのルート設定が/slack/eventsで、eventsを処理するように組まれているからです。

「Subscribe to bot events」に「app_home_opened」を設定します。
ユーザがホーム画面に入ってきたときにイベントを処理できるようにするためです。

glitchのLiveAppのURLの場所
image.png

「Event Subscriptions」設定後
image.png

インタラクティブコンポーネントの設定

ボタン押しなどのアクションを起こした際の送信先の設定をしていきます。

  1. 「Features」 > 「Interactive Components」 > ON
  2. 「Request URL」に先ほどフォークさせたソースのLiveAppのURL + /slack/actions

「Interactive Components」設定後
image.png

アプリのインストール

アプリをワークスペースにインストールします。

「Settings」 > 「Install App to Workspace」

トークン関連の設定

glitchでフォークさせたソースの.envファイルに認証用のトークンを設定していきます。

.env
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=

SLACK_SIGNING_SECRETの設定
「Settings」 > 「Basic Information」 > 「Signing Secret」(画面下部)

SLACK_BOT_TOKENの設定
「Features」 > 「OAuth & Permissions」 > 「Bot User OAuth Access Token」

動かしてみよう!

これで準備完了です。Slackを見てみます。

image.png

ホーム画面が表示されました!
ちなみに 「Add a Stickie」を押すとモーダルが表示されます。

image.png

Createを押すとメモとしてHome画面に登録されます!
適当に押したので「ddddddd」と入力してしまいました。もう少しわかりやすい言葉にすればよかったです。
Home画面には、モーダルで設定した文言とColorで設定した色の付箋が表示されます。

image.png

ソースをみる

ポイントになりそうなところをみていきます。
ソース全量についてはソースコードを参照してください。

ホーム画面表示部分

ホーム画面表示部分(/slack/event)のソースをみていきます。リクエストの内容によって処理分けされています。

index.js
app.post('/slack/events', async(req, res) => {
  switch (req.body.type) {

    case 'url_verification': {
      // verify Events API endpoint by returning challenge if present
      res.send({ challenge: req.body.challenge });
      break;
    }

    case 'event_callback': {
      // Verify the signing secret
      if (!signature.isVerified(req)) {
        res.sendStatus(404);
        return;
      } 

      // Request is verified --
      else {

        const {type, user, channel, tab, text, subtype} = req.body.event;

        // Triggered when the App Home is opened by a user
        if(type === 'app_home_opened') {
          // Display App Home
          appHome.displayHome(user);
        }
      }
      break;
    }
    default: { res.sendStatus(404); }
  }
});

url_verification
リクエストの信憑性確認用の処理みたいです。
Slackアプリの管理画面でイベントAPIのエントリーポイント登録時にも参照されていると思います。

event_callback

if (!signature.isVerified(req)) {

環境変数SLACK_SIGNING_SECRETの確認ですかね。きっと。

isVerifiedソース

```
/* ******************************************************************************
* Signing Secret Varification
*
* Signing secrets replace the old verification tokens.
* Slack sends an additional X-Slack-Signature HTTP header on each HTTP request.
* The X-Slack-Signature is just the hash of the raw request payload
* (HMAC SHA256, to be precise), keyed by your app’s Signing Secret.
*
* More info: https://api.slack.com/docs/verifying-requests-from-slack
*
* Tomomi Imura (@girlie_mac)
* ******************************************************************************/

const crypto = require('crypto');
const timingSafeCompare = require('tsscmp');

const isVerified = (req) => {
const signature = req.headers['x-slack-signature'];
const timestamp = req.headers['x-slack-request-timestamp'];
const hmac = crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET);
const [version, hash] = signature.split('=');

// Check if the timestamp is too old
const fiveMinutesAgo = ~~(Date.now() / 1000) - (60 * 5);
if (timestamp < fiveMinutesAgo) return false;

hmac.update(${version}:${timestamp}:${req.rawBody});

// check that the request signature matches expected value
return timingSafeCompare(hmac.digest('hex'), hash);
};

module.exports = { isVerified };
```

f(type === 'app_home_opened') {
     // Display App Home
     appHome.displayHome(user);

app_home_openedはHome画面表示時に送られてくるようです。
appHome.displayHomeでHome画面の情報をDB参照して取得。
表示する要素(ブロック)の情報をJSON形式で生成して、Slackのサーバに送っています。

要素(ブロック)の情報に関してはblock-kit-builderで生成できるので、実際に作る場合は簡単に作れそうです。

appHomeソース(長いので折りたたみ)
/* Display App Home */

const displayHome = async(user, data) => {

  if(data) {     
    // Store in a local DB
    db.push(`/${user}/data[]`, data, true);   
  }

  const args = {
    token: process.env.SLACK_BOT_TOKEN,
    user_id: user,
    view: await updateView(user)
  };

  const result = await axios.post(`${apiUrl}/views.publish`, qs.stringify(args));

  try {
    if(result.data.error) {
      console.log(result.data.error);
    }
  } catch(e) {
    console.log(e);
  }
};

onst updateView = async(user) => {

  // Intro message - 

  let blocks = [ 
    {
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*Welcome!* \nThis is a home for Stickers app. You can add small notes here!"
      },
      accessory: {
        type: "button",
        action_id: "add_note", 
        text: {
          type: "plain_text",
          text: "Add a Stickie",
          emoji: true
        }
      }
    },
    {
      type: "context",
      elements: [
        {
          type: "mrkdwn",
          text: ":wave: Hey, my source code is on <https://glitch.com/edit/#!/apphome-demo-keep|glitch>!"
        }
      ]
    },
    {
      type: "divider"
    }
  ];


  // Append new data blocks after the intro - 

  let newData = [];

  try {
    const rawData = db.getData(`/${user}/data/`);

    newData = rawData.slice().reverse(); // Reverse to make the latest first
    newData = newData.slice(0, 50); // Just display 20. BlockKit display has some limit.

  } catch(error) {
    //console.error(error); 
  };

  if(newData) {
    let noteBlocks = [];

    for (const o of newData) {

      const color = (o.color) ? o.color : 'yellow';

      let note = o.note;
      if (note.length > 3000) {
        note = note.substr(0, 2980) + '... _(truncated)_'
        console.log(note.length);
      }

      noteBlocks = [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: note
          },
          accessory: {
            type: "image",
            image_url: `https://cdn.glitch.com/0d5619da-dfb3-451b-9255-5560cd0da50b%2Fstickie_${color}.png`,
            alt_text: "stickie note"
          }
        },
        {
          "type": "context",
          "elements": [
            {
              "type": "mrkdwn",
              "text": o.timestamp
            }
          ]
        },
        {
          type: "divider"
        }
      ];
      blocks = blocks.concat(noteBlocks);

    }

  }

  // The final view -

  let view = {
    type: 'home',
    title: {
      type: 'plain_text',
      text: 'Keep notes!'
    },
    blocks: blocks
  }

  return JSON.stringify(view);
};

ホーム画面ボタン部分

ホームのメモ作成ボタン押し時 & モーダルで確定を押した時の処理です。

index.js
app.post('/slack/actions', async(req, res) => {
  //console.log(JSON.parse(req.body.payload));

  const { token, trigger_id, user, actions, type } = JSON.parse(req.body.payload);

  // Button with "add_" action_id clicked --
  if(actions && actions[0].action_id.match(/add_/)) {
    // Open a modal window with forms to be submitted by a user
    appHome.openModal(trigger_id);
  } 

  // Modal forms submitted --
  else if(type === 'view_submission') {
    res.send(''); // Make sure to respond to the server to avoid an error

    const ts = new Date();
    const { user, view } = JSON.parse(req.body.payload);

    const data = {
      timestamp: ts.toLocaleString(),
      note: view.state.values.note01.content.value,
      color: view.state.values.note02.color.selected_option.value
    }

    appHome.displayHome(user.id, data);
  }
});
ホームの作成ボタン押し時の判定
  if(actions && actions[0].action_id.match(/add_/)) {
    // Open a modal window with forms to be submitted by a user
    appHome.openModal(trigger_id);
  } 

ホームの作成ボタン押し時に押された要素のaction_idで判定しているようです。
この場合は、「add_」始まりの要素の場合、判定されるようです。

以下、ボタンの要素です。
「add_note」でマッチし、メモ登録用のモーダル表示処理が稼働します。

appHome.js
  {
      type: "section",
      text: {
        type: "mrkdwn",
        text: "*Welcome!* \nThis is a home for Stickers app. You can add small notes here!"
      },
      accessory: {
        type: "button",
        action_id: "add_note", 
        text: {
          type: "plain_text",
          text: "Add a Stickie",
          emoji: true
        }
      }
    },

モーダル表示~登録

ホーム画面のボタン押し時と同じ流れなので省略

感想

Slackで勤怠ツール作りたいというのもありますが、Slackのblock-kit-builderが楽しい!作ってみたい!と思って書き始めましたが、今回全く出番がなかったです。

初めはglitchではなく、firebaseのCloud Functionで作るつもりでDockerで開発環境作ったり、記事を書いていたのですが、
途中で課金しないと外部へリクエスト投げられないじゃん!と気づき、急遽変更しました...悲しい。

今回はチュートリアルの実施である程度Slackアプリわかってきたような気がするので、次回Slack勤怠アプリ作れればと思います!

参考

slack api
Building a home for your app
Tutorial: Developing an Action-able app
template-action-and-dialog

11
10
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10