bokunonikki.net

Slackアプリをapi gatewayとlambdaで作った

Mon Aug 16, 2021
Sat Aug 21, 2021

slackアプリを作る機会があったので、ブログにしてみました。

作ったアプリ

チームのみんなにお寿司ピザを選択してもらい、それがリモートの自宅に届くすごく便利なFoods Botです。

Slackアプリをapi gatewayとlambdaで作った

流れは実に単純で

slack → api gateway → lambda → お寿司屋さんorピザ屋さん

とういうふうになってます。

使ったもの

  • Serverless Framework
  • api gateway
  • lambda
  • Bolt

Serverless Frameworkの具体的な説明やawsの詳しい仕様周りの説明はしません。それぞれチュートリアル程度はやっていないと、この説明では何がなんだかわからないと思います。

前提知識

このslackアプリを作るうえであった方がいい前提知識があります。

まずはslackが提供しているBoltです。

slackは日本人の開発メンバーもいるらしくドキュメントが日本語化されているので、この辺はわかりやすくていいですね。

入門ガイドや基本的な概念程度はまず手を動かしてやってみることをお勧めします。

公式からAWSへのデプロイ方法も紹介されているので、こちも参考になります。AWS Lambda へのデプロイ

あとは、slack api

slackのアプリのデザインをオンライン上で確認できるBlock Kit Builderなんかも使うのであらかじめ見てみることをお勧めします。

api gatewayとlambdaを作る

HelloWorld程度のものは公式から出ているので、こちらを参考にしましょう。

AWS Lambda へのデプロイ

ちょっと説明不足な点といえばlayerくらいですかね。

こちらはAWS Lambdaのlayerをserverless frameworkでつくる(node.js)が参考になると思います。

HelloWorld程度であればこれでいいですけど、実際は色々な処理が入ってきて分割したくなるのが普通なので、僕の場合はこうしてます。

まずはserverless.ymlです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
service: slack-api

frameworkVersion: '2'

plugins:
  localPath: './layer-package/nodejs/node_modules'
  modules:
    - serverless-deployment-bucket

provider:
  name: aws
  runtime: nodejs14.x
  region: ${opt:region, 'us-east-1'}
  stage: ${opt:stage, 'development'}
  lambdaHashingVersion: 20201221
  deploymentBucket:
    name: ${self:service}-architect-bucket-${self:provider.stage}
    serverSideEncryption: AES256
  environment:
    SLACK_SIGNING_SECRET: 123456789
    SLACK_BOT_TOKEN: token-123456789

resources:
  # api/role
  - ${file(./api/slack/role/operation-event.yml)}

package:
  individually: true
  patterns:
    - '!*.md'
    - '!api/**'
    - '!*.json'
    - '!node_modules/**'
    - '!Makefile'
layers:
  layerPackage:
    path: layer-package

functions:
  slackOperationEvent:
    package:
      patterns:
        - api/slack/operation-event.js
    handler: api/slack/operation-event.handler
    events: ${file(./api/slack/slack.yml):slack_operation_event}
    role: !GetAtt OperationEventRole.Arn
    layers: 
      - !Ref LayerPackageLambdaLayer

僕の場合はroleapi gatewaylayerなんかも分割して最初から作ってしまうので、こんな感じになってます。あとはSLACK_SIGNING_SECRETSLACK_BOT_TOKENの環境変数ですかね。これはlambdaでboltを使うときに必要になります。

api gatewayは以下のようにしてます。

slack.yml

1
2
3
4
slack_operation_event:
  - http:
      path: slack
      method: post

short cutを作る

slack apiのshort cutを使ってまずはslackのフォームを作成します。

Creating and handling shortcuts

フォームのレイアウトはBlock Kit Builderで作っていくと簡単にできます。

lambdaは以下のようになります。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// slack/bolt
const { App, AwsLambdaReceiver } = require('@slack/bolt');

// Initialize your custom receiver
const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET
});

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
  // The `processBeforeResponse` option is required for all FaaS environments.
  // It allows Bolt methods (e.g. `app.message`) to handle a Slack request
  // before the Bolt framework responds to the request (e.g. `ack()`). This is
  // important because FaaS immediately terminate handlers after the response.
  processBeforeResponse: true
});
// foodsのショートカット
app.shortcut('foods', async ({ ack, body, client }) => {
  // コマンドのリクエストを確認
  await ack();

  try {
    const result = await client.views.open({
      // 適切な trigger_id を受け取ってから 3 秒以内に渡す
      trigger_id: body.trigger_id,
      // view の値をペイロードに含む
      view: {
        type: 'modal',
        // callback_id が view を特定するための識別子
        callback_id: 'operation_view',
        title: {
          type: 'plain_text',
          text: 'foods Bot'
        },
        blocks: [
          {
            type: 'header',
            text: {
              type: 'plain_text',
              text: 'Foods'
            }
          },
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: '好きな食べ物を選んでください。'
            }
          },
          {
            type: 'divider'
          },
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: '食べ物'
            },
            accessory: {
              type: 'static_select',
              placeholder: {
                type: 'plain_text',
                text: 'Select an item',
                emoji: false
              },
              options: [
                {
                  text: {
                    type: 'plain_text',
                    text: 'お寿司'
                  },
                  value: 'sushi'
                },
                {
                  text: {
                    type: 'plain_text',
                    text: 'ピザ'
                  },
                  value: 'pizza'
                }
              ],
              action_id: 'foods'
            }
          },
          {
            dispatch_action: true,
            type: 'input',
            element: {
              type: 'plain_text_input',
              action_id: 'email'
            },
            label: {
              type: 'plain_text',
              text: 'メールアドレス',
              emoji: false
            }
          }
        ],
        submit: {
          type: 'plain_text',
          text: 'Submit'
        }
      }
    });
    console.log(result);
  } catch (error) {
    console.error(error);
  }
});

// Handle the Lambda function event
module.exports.handler = async (event, context, callback) => {
  const handler = await app.start();
  return handler(event, context, callback);
};

これでユーザーがショートカットを選択するとモーダルが出てきて、お寿司とピザの選択そしてEmailを入力するフォームが出来上がりました。

アクションのリスニング

slackのack()による応答は3秒以内に行う必要があります。今回の場合、セレクトボックスでお寿司かピザを選んだ瞬間にack()で応答する必要があります。

正直、なくても動くのですが、CloudWatchにエラーとしてログが残ります。

ERROR [ERROR] An incoming event was not acknowledged within 3 seconds. Ensure that the ack() argument is called

あと、slack側でも注意されるので、ちゃんと対応した方がいいでしょう。

Slackアプリをapi gatewayとlambdaで作った

参考アクションへの応答

今回はaction_idfoodsなので、これに対応します。

lambda

1
2
3
app.action('foods', async ({ ack }) => {
  await ack();
});

これでエラーは出なくなりました。

イベントの確認

モーダルから送られて情報を確認するためにapp.viewを使います。

参考イベントの確認

こちらを参考に作ったのが、こちら。社内で使う程度なのでチェックも最低限でいいと思ってあまり凝ったことはやりませんでした。

view.state.valuesに値が入っているのですが、ユニークidの配下に値が入っていたのでObject.keys() メソッドを使いました。

あとは実行したい処理をひたすら書くだけです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
app.view('operation_view', async ({ ack, body, view, client }) => {
  // モーダルでのデータ送信イベントを確認
  console.log('operation_view');
  await ack();
  console.log(`view ${view}`);

  // 入力値を使ってやりたいことをここで実装

  console.log(`view.state.values: ${JSON.stringify(view.state.values)}`);
  const stateValues = view.state.values;
  const objectKeys = Object.keys(stateValues);

  // slackから送られたデータをJsonに整形する
  async function setJson () {
    const formatJson = {};
    return new Promise((resolve) => {
      for (const element of objectKeys) {
        if (stateValues[element].foods) {
          formatJson.foods = stateValues[element].foods.selected_option.value;
        }
        if (stateValues[element].email) {
          formatJson.email = stateValues[element].email.value;
        }
      }
      resolve(formatJson);
    });
  }

  // emailの有効性チェック
  async function emailValidates () {
    const setJsonResults = await setJson();
    return new Promise((resolve) => {
      if (setJsonResults.email) {
        // eslint-disable-next-line no-useless-escape
        const isEmail = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
        if (isEmail.test(setJsonResults.email)) {
          setJsonResults.status = 'success';
          resolve(setJsonResults);
        } else {
          setJsonResults.status = 'err';
          setJsonResults.message = '入力されたemailが有効ではありません。';
          resolve(setJsonResults);
        }
      } else {
        setJsonResults.status = 'err';
        setJsonResults.message = '入力されたemailが有効ではありません。';
        resolve(setJsonResults);
      }
    });
  }

  // 実行したい様々な処理

  // レスポンスを返す
  async function sendMessageToUser () {
    return result.message;
  }

  const msg = await sendMessageToUser();
  console.log(msg);

  const val = view.state.values;
  const user = body.user.id;

  const results = (user.input, val);

  if (results) {
    msg;
  }

  // ユーザーにメッセージを送信
  try {
    await client.chat.postMessage({
      channel: user,
      text: msg
    });
  } catch (error) {
    console.error(error);
  }
});

これで完成です。

slack apiはなかなか便利ですね。今回は社内むけの簡単なものしか作りませんでしたがawsと連携できるので新しいサービスにも応用が効きそうです。

ちなみに、自宅にお寿司やピザが届く制度はありません。ちょっと変えて書きました。hahaha。