【チュートリアル】 Lambda と API Gateway で Slack コマンドを作成してみる

Lambda (Python) と API Gateway を使って、Slack のスラッシュコマンドを試す方法(初心者向け)です。
2021.08.17

Guten Tag、ベルリンの伊藤です。

Slack のスラッシュコマンドというと、Slack上でメッセージ入力に例えば /shrug と打つと ¯\_(ツ)_/¯ が入力される、/remind @channel "寝てたら起こして" at 16:00 と打つとリマインダーを設定してくれる、というような機能ですね。

これを使って導入したい事案があり参考記事を調べたんですが、ちょっとハードルが高かったので、まずは動きを確認するだけの超シンプルな構成を試してみました。
初心者の方にもチュートリアルとして参考にしてもらえればと思います。

本格的に導入する場合は Slack 認証を加え、Serverless FrameworkやSAMを使うべきでしょうが、今回はまず仕組みを理解するために、認証なし(Secrets Managerなし) & 手動で API Gateway と Lambda を作成しています。
ご参考までに私のレベルは

  • Lambda は使ったことある
  • API Gateway はちょっとだけなら触ったことある
  • Slack API ハジメテ
  • CloudFormation は触ったことあるけどサーバーレスは知らん(ので今回は使わない)

といったところです。

早速本格的に作りたい!という方は、こういうのがおすすめです↓

【入門】Slack のコマンドを作ってみよう!(非同期実行版)

Slackで「今の電車の運行情報」を自分だけに教えてくれるSlash commandsを作った

Los geht's!

完成イメージ

/コマンド 文字列1 文字列2 と打ったら、文字列が2つでない場合はメッセージを返し、2つの場合はそれらの文字列を返すだけのものです。

全体の流れ

  1. Lambda 作成
  2. API Gateway 作成(非プロキシ統合ver)
  3. Slack App・Slash Command 作成
  4. 動作確認
  5. Lambda コード修正
  6. API Gateway 修正(プロキシ統合ver)
  7. 動作確認

プロキシ統合については手順内で詳しく触れますが、これによって Lambda コードの入出力の書き方も異なります。

既によく理解している方は、非プロキシ統合バージョンの設定を飛ばしてもOKです。

よく分かっていない方は、API Gateway の理解を深めるためにこの通り試すことをオススメします。なぜなら、Slash Command の参考記事を調べてみると両方のパターンがあり、違いを知らないでツギハギで参考にしてると全然動かないということになるからです。(私がなったからです...それで本記事執筆に至る)

今回は、非プロキシ統合バージョンであるSlack API 公式記事にもリンクされていたこちらの Slash Command Tutorial (Medium) を全体的に参考にしました。
ただ、これは Node.js なのと、引数の入出力がいまいち分かりづらかったので、Slackでググる@AWS (Qiita)を合わせて参考にしています。

チュートリアル

1. Lambda 作成

  • まずは Lambda 関数を作成します。テンプレの「hello-world-python」を使いましょう。

  • 適当な関数名をつけます。この下にコード表示されますが、ここでは編集できないので、ひとまず [Create function] をクリック。

  • 作成した関数のコードを次のように編集して、[Deploy] をクリック。

ここでは、2つのパラメータがない場合は2つ指定するようメッセージを返し、ある場合は1つ目と2つ目の値をそれぞれ返します。レスポンスメッセージには改行 \nやSlack上で使うマークダウン書式が適用できます。

def lambda_handler(event, context):
    text = event['text']
    command = event['command'] + ' ' + text
    words = [e for e in text.split(' ')]

    print("Command executed: " + command)

    if len(words) != 2:
        message = f"Please specify *two* parameters.\nYou typed: {command}"
        
    else:
        p1 = words[0]
        p2 = words[1]
        message = f"The first value is {p1}.\nThe second value is {p2}."

    return {
        "response_type": "ephemeral",
        "text": message
    }

よく分からない方は、ほんとに引数を返すだけのこっちから始めてみてください。

def lambda_handler(event, context):
    return {
        "text": event['text']
    }

これで Lambda は使える状態です。

  • 以下のようなテストイベントを作成し、[Test] を実行してみます。実行結果のレスポンスでエラーが起きないことを確認すればOKです。
{
    "command": "/tutorial",
    "text": "myText"
}

 

■ 解説

Slash Command で /tutorial aaa bbb と実行した場合、command に「/tutorial」、text に「aaa bbb」が入っています。これらがJSON形式でLambdaの event に渡されます。(具体的な渡し方は、次の API Gateway のマッピングテンプレートで定義)

レスポンスは、平坦な文字列、またはJSON形式で text にメッセージを返しますが、加えて response_type で ephemeral (デフォルト)を指定するとSlackでコマンドを実行した本人のみが結果を見ることができ、in_channelを指定するとSlackチャネル内の全員が見ることができます。

いずれも公式ドキュメント(Slash Commands | Slack api)に記載されています。

2. API Gateway 作成

  • API Gateway コンソールを開き、REST API を構築後、 POST メソッドを作成して、先ほどの Lambda 関数を選択します。(Lambda の画面から [Add trigger] で REST API (Open) を作成でも大丈夫)
  • 統合リクエストのマッピングテンプレートを設定したら、API をデプロイします。

これらは手順・キャプチャとも詳しくは下記の「API Gatewayの作成」をご参考ください。

【小ネタ】SlackのスラッシュコマンドからLambdaを実行させる方法

ただし、マッピングテンプレートでは、Content-Type に application/x-www-form-urlencoded を追加したあと、「メソッドリクエストのパススルー」を選択するのでなく、下記を貼り付けてください

これは冒頭2つの参考記事を始め広く使用されている、こちらの AWS フォーラムの寄稿のマッピングテンプレートです。

## convert HTML POST data or HTTP GET query string to JSON

## get the raw post data from the AWS built-in variable and give it a nicer name
#if ($context.httpMethod == "POST")
 #set($rawAPIData = $input.path("$"))
#elseif ($context.httpMethod == "GET")
 #set($rawAPIData = $input.params().querystring)
 #set($rawAPIData = $rawAPIData.toString())
 #set($rawAPIDataLength = $rawAPIData.length() - 1)
 #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))
 #set($rawAPIData = $rawAPIData.replace(", ", "&"))
#else
 #set($rawAPIData = "")
#end

## first we get the number of "&" in the string, this tells us if there is more than one key value pair
#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())

## if there are no "&" at all then we have only one key value pair.
## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.
## the "empty" kv pair to the right of the ampersand will be ignored anyway.
#if ($countAmpersands == 0)
 #set($rawPostData = $rawAPIData + "&")
#end

## now we tokenise using the ampersand(s)
#set($tokenisedAmpersand = $rawAPIData.split("&"))

## we set up a variable to hold the valid key value pairs
#set($tokenisedEquals = [])

## now we set up a loop to find the valid key value pairs, which must contain only one "="
#foreach( $kvPair in $tokenisedAmpersand )
 #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())
 #if ($countEquals == 1)
  #set($kvTokenised = $kvPair.split("="))
  #if ( ( $kvTokenised[0].length() > 0 ) && ( $kvTokenised[1].length() > 0 ) )
   ## we found a valid key value pair. add it to the list.
   #set($devNull = $tokenisedEquals.add($kvPair))
  #end
 #end
#end

## next we set up our loop inside the output structure "{" and "}"
{
#foreach( $kvPair in $tokenisedEquals )
  ## finally we output the JSON for this pair and append a comma if this isn't the last pair
  #set($kvTokenised = $kvPair.split("="))
 "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised.size() > 1 && $kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end
#end
}
  • API のデプロイが完了したら、呼び出しURLを使ってコマンドラインで curl でテストしてみます。ちゃんとレスポンスが返ってきたらOKです。
% curl \
 -H "Content-Type: application/x-www-form-urlencoded" \
 -X POST \
 -d 'command=/tutorial&text=aaa bbb' \
 https://xxxxxxxx.execute-api.eu-west-1.amazonaws.com/tutorial
# {"response_type": "ephemeral", "text": "The first value is aaa.\nThe second value is bbb."}

 

■ 解説

Slash Command は、Content-Type を application/x-www-form-urlencoded としてPOSTでデータを送ります。この Content-type についてはこちらの解説 (経営管理deプログラミング)がざっくり分かりやすかったです。

マッピングテンプレートというのは、API Gateway がリクエストのデータを Lambda に渡す JSON にどうマップするかというものです。

上記マッピングテンプレートのうち $input.path("$") の部分でデータを渡しています。今回の Slash Command の場合は、このContent-Typeのリクエストデータ ...&command=/tutorial&text=aaa bbb&...{ ..., "command": "/tutorial", "text": "aaa bbb", ...} のようにマップされて Lambda の event に渡されます。
※リクエストデータの全容は、Slash Commandsドキュメント参照。

ちなみに、参考先 DevIO ブログで使用している「メソッドリクエストのパススルー」ではJSONの "body-json" 下にマップしているため、今回はよりシンプルにマップされて分かりやすいこちらのマッピングテンプレートを使用しました。

3. Slack App・Slash Command 作成

ようやく Slack です!

  • ここから [Create New App] をクリックし、任意の App Name を指定、対象のワークスペースを選択して、新しいAppを作成します。

  • 左メニュー「Slash Commands」から、[Create New Command] をクリックし、コマンド、リクエストURL(API Gatewayの呼び出しURL)、説明、ヒントを適宜指定して保存します。

  • 左メニュー「Install App」から、[Install App to Workspace] をクリックし、権限リクエストを許可します。

4. 動作確認①

早速 Slack のメッセージ欄でスラッシュ+コマンド+任意の文字列を打って送ってみましょう。以下のように結果が返ってくるはず。

特に制限を設けていないのでSlack内のどこでもOKですが、今回は自分のDM内で試してます。

ここでエラーとなる場合は後述の「トラブルシューティング」参照。

5. Lambda コード修正

次に、 API Gatewayでプロキシ統合を使用します。こうすると、前項のようなマッピングテンプレートの設定はなく、リクエストデータをそのままLambdaに渡すことになります。これに応じ、Lambda側でも渡されたデータの処理と適切なレスポンスをするようコードを修正する必要があります。

  • Lambda に戻り、関数のコードを次のように編集して、[Deploy] をクリック。

今回はメッセージに装飾を加えていますが、不要であれば "attachments":[...]の部分を前項と同じく"text": messageに置き換えてください。

import json
from urllib.parse import parse_qs

def lambda_handler(event, context):
    params = parse_qs(event['body'])
    text = params['text'][0]
    command = params['command'][0] + " " + text
    words = [e for e in text.split(' ')]

    if len(words) != 2:
        message = f"Please specify *two* parameters."
        color = "#ff0000"
        
    else:
        p1 = words[0]
        p2 = words[1]
        message = f"The first value is {p1}.\nThe second value is {p2}."
        color = "#32cd32"
    
    payload = {
        "response_type": "ephemeral",
        "attachments":[
            {
                "color": color,
                "pretext": command,
                "text": message
            }
        ]
    }
    
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/json"
        },
        "body": json.dumps(payload)
    }

こちらも引数を返すだけのコードでも動きます。

from urllib.parse import parse_qs

def lambda_handler(event, context):
    params = parse_qs(event['body'])

    return {
        "body": params['text'][0]
    }
  • 以下のようなテストイベントを作成し、[Test] を実行してみます。実行結果のレスポンスでエラーが起きないことを確認すればOKです。

(リクエストデータはSlash Commandsドキュメントに記載のもの)

{
  "body": "token=gIkuvaNzQIHg97ATvDxqgjtO&team_id=T0001&team_domain=example&enterprise_id=E0001&enterprise_name=Globular%20Construct%20Inc&channel_id=C2147483705&channel_name=test&user_id=U2147483697&user_name=Steve&command=/tutorial&myText&response_url=https://hooks.slack.com/commands/1234/5678&trigger_id=13345224609.738474920.8088930838d88f008e0&api_app_id=A123456"
}

 

■ 解説

プロキシ統合の場合、Lambda 側では event['body'] にリクエストデータがそのまま入るので、application/x-www-form-urlencoded タイプのデータを解析・分割してくれるurllib.parse.parse_qsを使用して、コマンドやパラメータを取得します。

レスポンスも JSONの "body" にメッセージやレスポンスタイプを入れて返却する必要があります。

レスポンスのステータスコードやヘッダの指定は任意です。
なお、エラーのメッセージを返す場合でも、スラッシュコマンドがメッセージを返すという動作としては正常なので、ステータスコードには200を使います。

非/プロキシ統合に関して、さらに詳しく知りたい方は下記ブログが参考になります。

[初心者向け] Lambda 非プロキシ統合で API Gateway API をビルドする をプロキシ統合にして比較してみる

■ Slack レスポンスの装飾

前項でも使用したマークダウン書式だけでなく、色付き引用のような形で示すには attachments を使用します。

詳しくはドキュメントをご参考ください:

なお、今回は attachments 内でシンプルに color, pretext, text を使用していますが、これは古い記述指定なので、今後は blocks を使用することが推奨されているようです。Block Kit Builderを使って blocks のコードを試すことができます。

6. API Gateway 修正

  • API Gatewayに戻り、POSTメソッドの統合リクエストで「Lambda プロキシ統合の使用」にチェックを入れます。

変更後、画面下部のマッピングテンプレートなどのオプションは表示されなくなります。

  • 再び「アクション」→「APIのデプロイ」を選択し、先ほどと同じステージを選択して、[デプロイ] をクリック。
  • 再び先程の curl コマンドでテストしてみます。同様にレスポンスが返ってきたらOKです。

同じステージなので、エンドポイントURLは変わりません。Slack側の設定も更新不要。

7. 動作確認②

Slackに戻り、再びコマンドを試してみます。以下のように装飾も反映されるはずです。


トラブルシューティング

API Gateway で Missing Authentication Token のエラーが出る

これは API Gateway へアクセス時に問題がある場合に出力される一般的なエラーで、今回の私のケースですと、

  • Lambdaのコードに不備があった場合(シンタックスエラー含む)
  • 誤ったマッピングテンプレートの指定

でもこのエラーが出ていました。

メッセージが出力された時、「認証の問題」だと思い込み Lambda を実行する IAM の権限が足りないのか?それとも Slack のトークンかセキュリティ認証がいるのか?と疑って調べまくったのですが、今回認証は関係ありませんでした。

なお、Slackのトークン・セキュリティ認証に関しては、本格的に導入する場合はセキュリティ認証の利用が推奨されています。(トークンは廃止機能)

また、API Gateway が Lambda を呼び出す IAM の権限に関しては、今回のようにコンソールから指定する場合は自動的にLambda 側のリソースベースポリシーで権限が付与されるため、別途 IAM を設定する必要はありません。
(API Gateway側でメソッドの統合リクエストに「実行ロール」の設定もありますが、これも設定不要)

API Gateway コンソールを使用して API を Lambda 関数と統合すると、コンソールがユーザーに代わって (ユーザーの合意を得て) Lambda 関数でリソースベースのアクセス許可を設定するため、この IAM ロールを明示的に設定することは要求されません。

参考:IAM アクセス許可により API へのアクセスを制御する - Amazon API Gateway

Slack でエラーが出る

  • failed with the error "dispatch_failed"

これも一般的なエラーのようで、この内容からは切り分け不能でした。

まずは Lambda、API Gateway それぞれでテストや curl を実行して、AWS 側に問題ないことを確認し、それでも問題なければSlash Commandの設定を見直しましょう。

私の場合、Slash Command のリクエストURLに設定するAPI Gatewayの呼び出しURLが間違っていたケースがあり、このエラーにぶち当たりました。
(別の参考記事を元にAPI Gatewayを作り直した中で、POSTメソッドがルート直下でなくリソースの配下に作成されているのに、ステージの呼び出しURLそのまま使ってました。この場合、正しくは https://.../[ステージ名]/[リソース名]

  • failed with the error "invalid_blocks"

これはLambdaで返却する JSON レスポンス内のメッセージ装飾に問題があるようです。

blocks による装飾(詳しくはSlackレスポンスの装飾)で誤った記述をしていた時にこのエラーが出ました。


以上、すぐ忘れてしまう自分のために調べたこと全てを放出しました。

これで初心者の皆さんにもお役に立てると嬉しいです!