Slack メッセージ・ショートカット API を使ってディスカバラブルなアプリを作ろう

By Tomomi Imura

Published: 2019-03-05
Updated: 2022-01-31

  1. OAuth 権限設定についての変更がありましたので、チュートリアルのその設定部分を追加しました。
  2. 廃止予定の Dialog の代わりとなる Modals に変更しました。

Slack には、ユーザーがメッセージに対して絵文字リアクションシェアを行う標準機能がありますが、ショートカット 機能を使うとユーザーがメッセージを送信したときにアプリを起動することもできます。

例えば、メッセージから直接プロジェクトマネージメントアプリのタスクを作成したり、バグトラッカーアプリにバグを送ったり、メッセージ内容をヘルプデスクにコピーして送信したりなど、いろいろなことが可能になるのです。この機能をうまく自分のアプリに取り込めば、より多くのユーザーにあなたのアプリを知ってもらうことにもなるでしょう。

というわけで、このチュートリアルでは、この API を使ってアクショナブルなアプリを作る説明をしていきたいと思います。

"ClipIt! for Slack" を作る

これから作るアプリは「ClipIt! for Slack」というアプリです。あなたが架空の ClipIt! (クリップ・イット)というサービスをすでに運営していると仮定し、そのサービスに対応する Slack アプリを作るという設定でいってみましょう。このウェブサービスは、ユーザーがインターネット上で「クリップ」(保存)したテキストをデータベースに保存、同期していきます。

さて、あなたはこのサービスを拡張して Slack のメッセージも保存できるようにしたいと思っています。と、いうことで今からこのショートカット機能を使ってユーザーが Slack 上のメッセージをクリッピングできるようにしてみましょう。

このアプリは下のように動作します。

demo gif

ユーザーインタラクションは次のような感じになります。

  1. まずユーザーがメッセージにマウスポインタを合わせ、表示された … メニューから Clip the message を選択
  2. モーダルが表示されるのでユーザーはその中のフォームに、必要な情報を編集または追加
  3. ユーザーが送信 (Clipit) ボタンを押す
  4. ClipIt! for Slack がそのメッセージを ClipIt データベースに追加し同期(ここは架空の過程)
  5. ClipIt! for Slack が そのユーザーに DM で完了したことを通知

このチュートリアルは、プログラミング言語にかかわらず Slack API について学びたい誰もが理解できるようにと、あえて SDK を使用しないで API を直接 HTTP で呼び出しています。便宜上このチュートリアルでは Node.js を使っていますので、サンプルコードをそのまま使いたい方は、お使いのマシンやサーバーに Node.js がインストールされていることを確認してください。この先も Node で Slack アプリをどんどん書いていきたい、という方は公式の Node SDK (英語) も参考にしてみてください。

🐙🐱 ソースコードは GitHub にありますが、このチュートリアルでは説明しやすいようにさらに簡略化したコードを使っています。そちらのソースは Glitch という、Node アプリをウェブブラウザで書いて実行させることができるウェブ IDE 上に置いてあります。

🎏🍴そのコードをこの リンク から "remix" してください。Glitch の remix とは、GitHub の fork のような機能で、リミックスしたコードは自分のリポジトリとなりますので、好きなように書き換える事ができます。

⚙️ アプリの作成と設定

まずは、開発用に使える Slack ワークスペースにサインインしてください。今からそのワークスペース上で新規のアプリを作成、インストールしていきます。Slack App マネージメント でアプリ名を入力し、開発用ワークスペースを選択してください。

新規アプリを作成

screenshot - create an app

Create App と書かれた緑のボタンをクリックしてください。

次に Basic InformationApp Credentials セクションまでスクロールします。ここにはアプリのシークレットキーがいくつかがありますが、このチュートリアルでは Signing Secret を使います。

screenshot - app credentials

この Signing Secret は隠されている状態ですが、まずそれを表示させ、その値を Node アプリのルートにある .env ファイルに 環境変数 SLACK_SIGNING_SECRET として保存します。このシークレットキーの使い方についてはのちの リクエスト情報の検証 セクションで説明します。

SLACK_SIGNING_SECRET=15770a…

もう少し下にスクロールしていくと Display Information がありますのでそこでアプリのアイコンや詳細など編集することができます。 次に、左のメニューから Interactivity & Shortcuts をクリックして、そのページトップになる Interactivity をオンにしてください。するとフィールドが表示されます。

screenshot - actions

ここでは Request URL を入力します。このリクエスト URL とは、ユーザーがショートカット機能を呼び出した際に Slack の API サーバーから送信されるペイロードデータの受け取り場所となる URL と考えてください。

注:この URL はあなたのアプリが稼働しているサーバーの URL となりますが、このチュートリアルでは Glitch を使っていますので、リクエスト URL は https://my-project.glitch.me/actions のようになります。この my-project 部分は各自異なる文字列になっています。確認してみてください。

Request URL を設定し終わったら、Shortcuts までスクロールし、Create New Shortcut ボタンをクリックし、 On messages を選び、次のように入力します。

screenshot - actions

Create ボタンを押し、次に場面で Save Changes をクリックしてください。

次は OAuth & Permissions へ行き Install App to Workspace をクリックして一旦このアプリをインストールします。インストール画面が表示されますのでそのまま続行し、ワークスペースにインストールしてください。し終わってから OAuth & Permission ページに戻ると access tokens が表示されていますのでそれを取得します。トークンは他のキー同様、 .env ファイルに保存します。

SLACK_ACCESS_TOKEN=xoxb-214…

次に、同じページ内でパーミションスコープを有効にする必要があります。下にスクロールして Scopes セクションまで行き、必要な bot スコープを追加します。ここでは次の二つの権限設定が必要です:

  • commands メッセージ・ショートカットに必要な権限
  • chat:write メッセージを送信するのに必要な権限

screenshot - scopes

さて、アプリの設定がようやく終わりました!次は早速アプリのコーディングです。

☕️ アプリの構築

冒頭でも述べたように、このチュートリアルでは Slack API そのものの使い方を説明していますので、Node.js の Express.js モジュールなどを使用して直接 API を呼んでいます。

さて、まず依存するモジュールをインストールしていきましょう。 POST リクエスト実行のための Express とミドルウェアの body-parser、そして HTTP リクエストクライアントの axiosと、クエリストリングのパーサーである、qs をインストールします。

$ npm install express body-parser axios qs dotenv --save

次はコード部分をみていきましょう。少しづつコード・スニペットで説明していきますので後からどんどんこのコードに追加・編集していきます。 まず、 index.js を作成し、Express アプリのインスタンス化し適当なポートナンバーでサーバを接続します。


/* スニペット 1 */

require('dotenv').config(); // .env ファイルから環境変数を取得するため

const express = require('express');
const bodyParser = require('body-parser');
const axios = require('axios');
const qs = require('qs');
const app = express();

// この次の二行は後で変更される
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

const server = app.listen(5000); // port

次に進む前に、まず今から構築していく Slack アプリがどう作動するかの説明を示した図をみてみましょう。

app flow diagram

各フローは、ユーザーがメッセージメニューからショートカットを起動した時に開始されます。ここで message_action イベントがトリガーされ、 Slack 側がそのイベントのペイロードを、指定されたエンドポイント(前のステップで設定した、Request URL)に送信します。

app flow diagram

受け取りのエンドポイント側では、下のように書くことができます。この時、先に設定したパスである /actions を使っています。

/* スニペット 2 */

app.post('/actions', (req, res) => {
  const payload = JSON.parse(req.body.payload);
  const { type, user, view } = payload; 

  // リクエストの検証、詳細は下記で説明します。
  if (!signature.isVerified(req)) {
    res.sendStatus(404);
    return;
  }

  if(type === 'message_action') {
    // モーダルを表示する
  } else if (type === 'view_submission') {
    // モーダルが送信された場合
  }
});

if(type === 'message_action') で、イベントタイプが message_action か確認しています。これはユーザーがショートカットを実行した際に送られるイベントタイプです。true である場合にはモーダルを開きます。

コード・スニペット 2 の // open a modal here とコメントのある部分に次のコード (スニペット 2.1) を追加します。ここではモーダルの内容の定義をし、views.open メソッドで Slack クライアント上でモーダルボックスを表示しています。

/* スニペット 2.1 */

const viewData = {
  token: process.env.SLACK_ACCESS_TOKEN,
  trigger_id: payload.trigger_id,
  view: JSON.stringify({
    type: 'modal',
    title: {
      type: 'plain_text',
      text: 'Save it to ClipIt!'
    },
    callback_id: 'clipit',
    submit: {
      type: 'plain_text',
      text: 'ClipIt'
    },
    blocks: [ // Block Kit
      {
        block_id: 'message',
        type: 'input',
        element: {
          action_id: 'message_id',
          type: 'plain_text_input',
          multiline: true,
          initial_value: payload.message.text
        },
        label: {
          type: 'plain_text',
          text: 'Message Text'
        }
      },
      {
        block_id: 'importance',
        type: 'input',
        element: {
          action_id: 'importance_id',
          type: 'static_select',
          placeholder: {
            type: 'plain_text',
            text: 'Select importance',
            emoji: true
          },
          options: [
            {
              text: {
                type: 'plain_text',
                text: 'High 💎💎✨',
                emoji: true
              },
              value: 'high'
            },
            {
              text: {
                type: 'plain_text',
                text: 'Medium 💎',
                emoji: true
              },
              value: 'medium'
            },
            {
              text: {
                type: 'plain_text',
                text: 'Low ⚪️',
                emoji: true
              },
              value: 'low'
            }
          ]
        },
        label: {
          type: 'plain_text',
          text: 'Importance'
        }
      }
    ]
  });
};

axios.post('https://slack.com/api/views.open', qs.stringify(viewData))
  .then((result) => {
    res.sendStatus(200);
  });

最後の5行は、axios モジュールを使って POST リクエストを Slack に送信しています。views.open メソッドでモーダル表示に成功したら即座に HTTP status 200 を送り返す必要があります。

app

同様に、このダイアログがユーザーによって送信された際も先ほどと同じエンドポイントが呼び出されます。少し上の code スニペット 2// dialog is submitted というコメント部分に次のコード (snipet 2.2) を追加します。

/* スニペット 2.2 */

} else if(type === 'view_submission') {
  res.send(''); // エラーを発生しないよう、3 秒以内に Slack へ応答する必要があります。

  // データベースに保存する
  db.set(user.id, payload); // 疑似コードです!

  // 確認のため、ユーザへメッセージを送信する
  let values = view.state.values;

  let blocks = [
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: 'Message clipped!\n\n'
      }
    },
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `*Message*\n${values.message.message_id.value}`
      }
    },
    {
      type: 'section',
      fields: [
        {
          type: 'mrkdwn',
          text: `*Importance:*\n${values.importance.importance_id.selected_option.text.text}`
        },
        {
          type: 'mrkdwn',
          text: `*Link:*\nhttp://example.com/${user.id}/clip`
        }
      ]
    }
  ];

  let message = {
    token: process.env.SLACK_ACCESS_TOKEN,
    channel: userId,
    blocks: JSON.stringify(blocks)
  };

  axios.post(`${apiUrl}/chat.postMessage`, qs.stringify(message));
} 

この時も、モーダルが無事に返信されました、とサーバーに伝える必要があるので、ここでは空の HTTP 200 リスポンスをまず送り返します。

次に、クリップされたメッセージをデータベースに保存するという仮定で進めます。(コードの db.set 部分はデータベース部分は擬似コードで省略してありますのでこのまま使うとエラーになります。)保存を同期がおわった時点で chat.postMessage メソッドを使ってユーザーに確認メッセージを送りましょう。

この最後の確認メッセージの過程は、アプリのユーザー・エクスペリエンスのためには非常に重要ですので、この先新しいアプリを作っていく際にもぜひ、ユーザー視点に立って使いやすさについて考えてみましょう。

さて、では一旦このコードを実行してみましょう。これでメッセージメニューに、このアプリのショートカットが追加されているはずなので、クリックしてみてください。架空のデータベースパート以外はきちんと動作していることを確認してください。

では最後に、アプリのセキュリティ面を改善しましょう。

🔐 リクエスト情報の検証

ここまでのコードでも動作はしますが脆弱性があります。エンドポイントで受け取ったリクエストが本当に Slack から来たものなのかがわからないからです。それをリクエスト毎に確認をする必要があるので、今から signing secrets (英語) を使って検証してみましょう。

この Signing secrets (署名の検証のためのシークレットキー)は、今まで Slack API で使われてきた verification tokens に代わるもので、セキュリティ面をさらに強化するために、各リクエストごとに HTTP ヘッダーに x-slack-signature を追加しています。

ここでの x-slack-signature は、 HMAC SHA256 でハッシュ化されたリクエスト・ペイロードで、 Signing Secret を使ってキー化します。

この例ではすでに Express と body-parser を使っていますので、body-parser の verify ファンクションオプションを使ってこのペイロードを取得してみましょう。

始めのコード・スニペット 1 に戻りましょう。// この次の二行は後で変更される というコメント部の body-parser ミドルウェアの設定部分(12–13行目)を次のコードに置き換えてください。

/* スニペット 3 */

const rawBodyBuffer = (req, res, buf, encoding) => {
 if (buf && buf.length) {
   req.rawBody = buf.toString(encoding || 'utf8');
 }
};

app.use(bodyParser.urlencoded({verify: rawBodyBuffer, extended: true }));
app.use(bodyParser.json({ verify: rawBodyBuffer }));

実際の暗号化に関する関数は verifySignature.js のほうですでに用意しましたので、あとはこのファンクションを index.js の冒頭部に追加するだけです。

const signature = require('./verifySignature'); 

そして、スニペット 2 の、イベント・エンドポイントでこのサインイング・シークレットを使って生成した署名を比較することによってリクエストの検証をします。

if(!signature.isVerified(req)) { // リクエストが Slack から送信されない場合
   res.sendStatus(404); // 潜在的な攻撃者に 404 Not Found エラーを表示した方がおすすめです。
   return;
}

この例では、イベント発生時のエンドポイントでのみ検証をしていますが Slack アプリでペイロードを受け取るときは、必ず同様の検証を行う必要があります。詳しくは Verifying requests from Slack (英語) をお読みください。

さて、Node コードをもう一度実行してみましょう。おめでとうござまいます!アプリの完成です!


ご質問やコメントがありましたら、開発者サポート (@SlackAPI または support@slack.com) までご連絡ください。

原文 Tutorial: Developing an Action-able App by Tomomi Imura (Slack)


おすすめの関連記事 🦄