[Slack][Slash Command][AWS][Lambda][ChatOps] Slack Slash Command と AWS Lambda で処理時間のかかるコマンドを作成


EC2 でのコマンド実行を、サーバにログインせずに簡単にできないかを考えていました。

- EC2 のコマンド実行自体は SSM RunCommand で可能
- Lambda で RunCommand を実行できるので、API Gateway や ALB 経由で Web API まではできる
- API をどう叩く？画面用意するのは面倒、curl で叩くのはもっと面倒。。
- コマンド実行なので結果の受け取りまで必要

そんなことを考えていて、Slack の Slash Command というのがよさそうだったので試してみました。

slack api | Enabling interactivity with Slash Commands
https://api.slack.com/interactivity/slash-commands

チャットの UI がコマンド実行との親和性も高いですしね。(ChatOps という用語まであるらしい)



## Slack Slash Command とは

Slack のワークスペースに紐づけてアプリを作成し、その中でコマンドを用意する形になるようです。
まずは、アプリを作成から。(以下の「**Create New App**」から)

slack api | Your Apps
https://api.slack.com/apps

アプリを作成したあと、「**Basic Information ＞ Install your app**」から自分のワークスペースへのインストールを忘れないようにしましょう。
(これでハマりました。。Slack にコマンドをタイプしても候補が現れず。。)

Slash Command は左メニューの「**Slash Commands**」から登録します。

![slack-new-command.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/8bedf14f-f04e-61eb-02ab-6ff4f7224e37.png)

Slash Command は以下のような振る舞いになるようです。

Slack からの最初のリクエストは 3秒以内に返さないといけないようなので、時間がかからない処理なら単純にレスポンスを返すだけで済みます。

今回は、EC2 でコマンド実行して～のように処理時間がかかる前提なので、最初のレスポンスは受信確認のみとし、あとから処理結果を送ります。

最初のリクエスト時に、レスポンス用の一時的な hook URL が提供されるので、ここにメッセージを投げる形になります。
hook URL は 30分間でリクエスト 5つまで受けられるようです。


![slack-command-overview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/3b4ea9d3-ec75-4ce2-1e0d-3430ea7d2560.png)

Slack からのリクエストは form-data 形式の POST。
Slack へのレスポンス、および hook URL へのメッセージ送信は JSON 形式になります。


**リクエスト: Slack → コマンドアプリ**

```http
POST /weather
Content-type: application/x-www-form-urlencoded

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=/weather
&text=94070
&response_url=https://hooks.slack.com/commands/1234/5678  ★メッセージ送信先の hook URL
&trigger_id=13345224609.738474920.8088930838d88f008e0
&api_app_id=A123456
```

**レスポンス: コマンドアプリ → Slack**  ※3秒以内

```http
200 OK
Content-Type: application/json

{
  "text": "OK"
}
```

**メッセージ: コマンドアプリ → Slack**

```http
POST https://hooks.slack.com/commands/1234/5678
Content-Type: application/json

{
  "text": "Message to Slack."
}
```

JSON メッセージは Block Kit という書式で、より高度な表現にすることができます。
以下のように Slack の Markdown 書式も使えます。

```json
{
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "This is unquoted text\n>This is quoted text\n>This is still quoted text\nThis is unquoted text again"
      }
    }
  ]
}
```



## AWS Lambda でコマンドアプリ作成

コマンドアプリの構成は以下のようになります。

![slack-connection.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/2d2caae7-3434-dadd-feee-2754e67e86ec.png)

Lambda 関数は Slack からのリクエストを受けて、即時レスポンスを返すもの(func-gw) と、処理を実施して hook URL へ結果を返す本体(func) の 2つ必要になります。

本体の func は Lambda から呼び出します。


上図では Lambda の API 化は API Gateway にしていますが、ALB を使うこともできます。

それぞれの場合のやること、注意点は以下のとおり。

**API Gateway の場合**

- POST メソッドを追加 (リソースは任意、`/` で OK)
- **結合リクエスト ＞ マッピングテンプレート** で form-data → JSON 変換設定を追加
    - Content-Type が **application/x-www-form-urlencoded** の場合に変換 
- ステージを作成し、API をデプロイ
    - URL をメモって、Slack Slash Command の Request URL へ設定
      例) https://AAAAAAAAAA.execute-api.ap-northeast-1.amazonaws.com/prod ※prod ステージの場合

**ALB の場合**

- ALB は設置コストがかかるので注意 (すでに設置済のものを流用できる場合などに限られそう)
- ヘルスチェック応答のハンドリングが必要
    - ターゲットグループの対象を **Lambda** に設定
    - ターゲットグループにヘルスチェックのパスを設定 (/health など)
    - Lambda でパスがヘルスチェックパスの場合、即時 200 OK を返す

以降は API Gateway の利用で続けます。

マッピングテンプレートの設定は、以下のように Slack からのパラメータをまるっと JSON の文字列にすれば OK です。
Lambda 関数で文字列として受け取り後、文字列を form-data としてパースして利用する形になります。
(あるいは、ここで form-data を JSON 形式にちゃんと変換してもよい)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/18f97d03-98d9-0149-98c3-1ce01503cebb.png)


続いて、Lambda 関数は 2つ作成します。
ここでは func-gw と func とします。

- **func-gw** : API Gateway から呼び出す。Slack からのリクエスト受領確認のため、即時 200 OK を返し、後続の Lambda 関数(func) を非同期でキックする
- **func** : 処理を実施し、処理結果を Slack への hook URL へ POST する

また、Lambda 関数にはそれぞれ以下の権限設定が必要になります。

- **実行ロール** : Lambda 自身の権限を設定 (Lambda 関数をキックできる など)
- **関数ポリシー** : どこからの呼び出しを許可するかを設定 (API Gateway から呼び出せる など)

それぞれ、以下のような設定になります。

**func-gw / 実行ロール**

- Lambda 関数をキックできる

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",  ※デフォルトで設定済
            "Action": [
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:XXXXXXXXXXXX:*"
            ]
        },
        {
            "Effect": "Allow",  ※デフォルトで設定済
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:XXXXXXXXXXXX:log-group:/aws/lambda/func-gw:*"
        },
        {
            "Effect": "Allow",  ★これを追加
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": [
                "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:func"
            ]
        }
    ]
}
```

**func-gw / 関数ポリシー**

- API Gateway から呼び出せる  ※API Gateway に割り当てるときに自動生成される

```json
{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "apigateway.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:func-gw",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:execute-api:ap-northeast-1:XXXXXXXXXXXX:AAAAAAAAAA/*/POST/"
        }
      }
    }
  ]
}
```

**func / 実行ロール**

- コマンドの処理で必要な権限を追加 (SSM SendCommand など)
- ここでは省略

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",  ※デフォルトで設定済
            "Action": [
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:XXXXXXXXXXXX:*"
            ]
        },
        {
            "Effect": "Allow",  ※デフォルトで設定済
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:XXXXXXXXXXXX:log-group:/aws/lambda/func:*"
        }
    ]
}
```

**func / 関数ポリシー**

- Lambda 関数から呼び出せる

```json
{
  "Version": "2012-10-17",
  "Id": "default",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:func",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:*"
        }
      }
    }
  ]
}
```

ここまで設定したら通り道は一通りできたので、あとは実際の Lambda 関数を実装するだけです。


## AWS Lambda の実装

Lambda は任意の言語で実装します。
今回は Node.js 18.x を使ってみました。

> ※Node.js は普段使いしてません。。
※Node.js 18.x の AWS SDK は **AWS SDK for JavaScript v3** のようで、ググったサンプルと違っていて苦戦。。
　https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/index.html
※標準機能のみ利用、AWS Lambda のコンソール上でできるレベルで。。


**func-gw の実装例**

```js
'use strict';

import * as querystring from 'querystring';
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";

export const handler = async(event) => {
    // Slack リクエストの本文をパース
    let query = querystring.parse(event.body);

    // 即時応答としてコマンドラインを返すようにする
    let commandline = '*command:* ' + query.command + ' ' + query.text;
    // 呼び出す Lambda 関数
    let lambdaTarget = 'arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:func';

    ////////

    const client = new LambdaClient({ region: 'ap-northeast-1' });

    const command = new InvokeCommand({
        FunctionName: lambdaTarget,
        InvocationType: 'Event',    // 非同期で Lambda 関数を呼び出し
        Payload: JSON.stringify(query)
    });

    // Lambda 関数をキックしてから...
    await client.send(command);
    // 即時応答を返す
    return {
        type: 'mrkdwn',
        text: commandline
    };
};
```

**func の実装例**

```js
'use strict';

import * as https from 'https';

export const handler = async(event, context) => {
    // Slack の hook URL へ送信するためのクライアント
    const slackClient = {
        _request_url: event.response_url,
        post: async function(body) {
            await sendRequest(this._request_url, body)
                .then((data) => {
                    console.log('POST OK', data);
                })
                .catch((e) => {
                    console.error('POST ERROR', e);
                })
                .finally(() => {
                    console.log('finish');
                });
        }
    };

    ////////

    // ここで本体の処理を行う
    // event.text が Slack 上でコマンドに指定した引数
    let output = '処理完了...';  // 処理結果

    // 処理結果を Block Kit 形式に整えて...
    let response = {
        blocks: [
            {
                type: 'section',
                text: {
                    type: 'mrkdwn',
                    text: '```' + output + '```'
                }
            }
        ]
    };
    // Slack へ送信
    await slackClient.post(response);
};

// 標準の https を使って POST 処理を Promise 化...
async function sendRequest(url, content) {
    return new Promise((resolve, reject) => {
        let content_s = JSON.stringify(content);
        let content_len = Buffer.byteLength(content_s, 'utf8');
        const req = https.request(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Content-Length': '' + content_len
            }
        }, (res) => {
            let body = '';
            res.on('data', (chunk) => body += chunk.toString());
            res.on('error', reject);
            res.on('end', () => {
                if (res.statusCode >= 200 && res.statusCode <= 299) {
                    resolve({ statusCode: res.statusCode, statusMessage: res.statusMessage, body: body });
                } else {
                    reject({ statusCode: res.statusCode, statusMessage: res.statusMessage, body: body });
                }
            });
        });
        req.on('error', reject);
        req.write(content_s, 'utf8');
        req.end();
    });
}
```

Slack Slash Command が一通り流れるよう最低限の実装のみです。
実際に使う場合は以下のような対応が必要になります。

- 呼び出す Lambda のターゲットを環境変数にして設定を外部化
- Slack からのリクエストを検証
    - Slack リクエストの token を検証
    - トークンを環境変数にして設定を外部化


※SSM RunCommand で EC2 のコマンドを実行するなどの処理本体は、長くなるので省略 (続編は未定。。)


// EOF