JavaでAWS lambda & API Gateway を使ったSlackBotを作る

# はじめに
slack botを作る時は、大抵の場合はPythonかNode.jsを使うと思いますが、今回はあえてJavaで行ってみようかと思います。
JavaでAWSを経由したサーバーレスのslack botを作る記事は日本語・英語では見つからなかったため、実装の手順をここで記事にしておこうと思います。
Javaに関しては素人なので、こうした方がいいよ！というところがあれば指摘していただけると幸いです
全体のコードはgithubにおいてあります
https://github.com/kamata1729/sampleSlackBotWithJava

# slack botの作成
## Bot作成用のアプリを作る
まずはじめにSlack Botを作っていきましょう！
https://api.slack.com/apps
ここから Create New App を押して新しいアプリを作成します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/3f328d38-417d-47ac-06e3-d48208b99373.png)

そして作ったアプリにBot Userを追加します
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/b23f428e-ebee-15ed-d05b-e0e8a0b81a0d.png)

# slackイベントを受け取るエンドポイントを作成
ここからは、slackからのイベントをAWS受け取るためのエンドポイントを作成していきます。
具体的には、API Gatewayでイベントを受け取り、それをlambda関数に受け渡すようにしていきます。


## IAMのロールの作成
AWS コンソールからIAMを開き、新しいロールを作成します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/75fc9598-c97d-5ff5-6e98-5b3e4c899589.png)

lambda で使用するので「lambda」を選択して先にすすみます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/9e75193c-4368-2f78-5737-11a8530faa34.png)

今回はログをCloudWatchLogに出力できるようにしたいので、書き込み権限のある`AWSLambdaBasicExecutionRole`を選択します
`
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/50e0f227-9049-c3e1-b7a3-a472d82589bb.png)

次のタグの作成に関しては今回は使用しないので無視して大丈夫です。
その次の確認画面でロール名をつけて完了です！
今回は`sampleBotRole`とつけました。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/d02e0232-eedb-7699-0c86-3751721b73f1.png)

## lambda関数の作成
今度はlambdaのコンソールに移動して、関数の作成を選択します。
「一から作成」を選択し、関数名は今回は`sampleBot`, ランタイムに`java 8`を選択します。
また、ロールには先ほど作成した`sampleBotRole`を選択しました
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/be0c733c-fb66-fa5d-333a-878041d54a2d.png)

## API Gatewayの作成
まず、awsコンソールのAPI Gatewayのページから「APIの作成」を選択し、API名(今回は`sampleBotAPI`)をつけて作成します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/dd475851-daee-c4de-2775-0bbbecb49159.png)
作成後の画面から、`アクション->メソッドの作成`を選択し、`POST`メソッドを追加します
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/12a7ec34-251a-41eb-e776-0d000634d735.png)
チェックマークを押してPOSTメソッドを作成し、セットアップ画面で先ほど作ったlambda関数に合うように設定して保存します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/3f5da402-3d95-0c7a-7461-91b6cc428eb4.png)
そのあとの画面で、`アクション->APIのデプロイ`を選択し、ステージ名を入力してデプロイします。
この時に画面上部にエンドポイントを呼び出すURLが表示されるので、これをメモしておいてください。
これでエンドポイントの作成が完了しました！
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/800ae560-bd28-bcb5-f6e2-677083eb1e94.png)

# slack Event APIの認証について
lambda関数を実装する前に、slack Event APIの認証について説明します。
slack Event APIでは、最初にプログラムがEvent API用のものか確認するために、特定の文字列を送り返してやる必要があります。

認証では、最初に以下のようなjsonが送られてきます。

```json
{
    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
    "type": "url_verification"
}
```

これに対して3秒以内に`challenge`の内容を送り返すことができれば認証が完了します。
送り返す時のフォーマットは、以下の三通りなら大丈夫です。詳しくは　https://api.slack.com/events/url_verification を参照してください。

```
HTTP 200 OK
Content-type: text/plain
3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P
```

```
HTTP 200 OK
Content-type: application/x-www-form-urlencoded
challenge=3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P
```

```
HTTP 200 OK
Content-type: application/json
{"challenge":"3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"}
```

# AWS lambda の入出力について

JavaでAWS lambdaの入出力を定義する場合、handlerの実装を実装する必要がありますが、これには三通りの方法があります。
ここでは簡単に書きますが、詳しくは以下を参照してください
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-programming-model-handler-types.html
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-handler-using-predefined-interfaces.html

### 1. 直接handlerメソッドをロード
これは特別なinput/outoutの型を定義せずに、デフォルトで実装されている型を使用するものです。
以下のように実装します

```java

outputType handler-name(inputType input, Context context) {
   ...
}
```

これの`inputType`, `outputType`にはデフォルトでは文字列型、整数型、ブール型、マップ型、およびリスト型をサポートしているようです。
（詳しくは以下を参照　https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-programming-model-req-resp.html ）

例えば以下のような実装が可能です

```java
package example;

import com.amazonaws.services.lambda.runtime.Context; 

public class Hello {
    public String myHandler(String name, Context context) {
        return String.format("Hello %s.", name);
    }
}
```

### 2. POJOの型を使用
`inputType`, `outputType`に独自の型を指定したい場合には、自分で入出力の形式に合わせた型を定義して使用することもできます。

これは以下のように実装できます。

```java
package example;

import com.amazonaws.services.lambda.runtime.Context; 

public class HelloPojo {

    // Define two classes/POJOs for use with Lambda function.
    public static class RequestClass {
      ...
    }

    public static class ResponseClass {
      ...
    }

    public static ResponseClass myHandler(RequestClass request, Context context) {
        String greetingString = String.format("Hello %s, %s.", request.getFirstName(), request.getLastName());
        return new ResponseClass(greetingString);
    }
}
```

### 3. RequestStreamHandlerを使用
`InputStream`と`OutputStream`を利用して、どんな入出力も可能に可能にする方法もあります。
`OutputStream`にバイト列を書き込むことで返信することができます。
例として、与えられた文字列を大文字にして返すプログラムが挙げられています。

```java
package example;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

import com.amazonaws.services.lambda.runtime.RequestStreamHandler;
import com.amazonaws.services.lambda.runtime.Context; 

public class Hello implements RequestStreamHandler {
    // if input is "test", then return "TEST"
    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context)
            throws IOException {
        int letter;
        while((letter = inputStream.read()) != -1)
        {
            outputStream.write(Character.toUpperCase(letter));
        }
    }
}
```

今回は、ネストしたjsonをPOJOの型で受け取るのは大変なので、RequestStreamHandlerを使用することにします。


# lambda関数をslackの認証に対応させる
以上を踏まえてlambda関数のコーディングをしていきますが、pythonなどとは異なり、Javaの場合はコードをインライン編集することができません。そのため今回は、ローカルのmavenでjavaのプロジェクトを作成し、それをgradleを使ってzipにまとめてアップロードするという形を取っていきます。

## プロジェクトの作成
まずはmavenでプロジェクトを作成します。（プロジェクトを作成するためだけにmavenは使用しているので、他の方法で作成しても一向に構いません）

```
$ mvn archetype:generate
```
途中でいろいろ聞かれると思いますが、`groupId`と　`artifactId`以外はエンターで大丈夫です。
今回は`groupId`は`jp.com.hoge`, `artifactId`は`SampleBot`としました。
実行するとプロジェクト名のフォルダが作成され、`src/main/java/jp/com/hoge/App.java` が生成されます。

## App.javaの編集

App.javaを以下のように編集します。
もし`challenge`の内容を得ることに失敗しても、何かしらの文字列を送り返さないとタイムアウトと見なされ、1分後と５分後にまたeventが送られて来ることになるので、とりあえず"OK"と返信しておくことにしています。

```src/main/java/jp/com/hoge/App.java
package jp.com.hoge;

import java.io.*;
import java.net.URLEncoder;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.lang.StringBuilder;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;

import com.amazonaws.services.lambda.runtime.*;

public class App implements RequestStreamHandler
{
    @Override
    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException {
        // デフォルトのresponse 何も返信しないと何度か同じイベントが送られてくる
        String response = "HTTP 200 OK\nContent-type: text/plain\nOK";
        try{
            BufferedReader rd = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8"))); 
            String jsonText = readAll(rd);
            System.out.println(jsonText); //System.outの出力はCloudWatchLogsに書き込まれる
            JSONObject json = new JSONObject(jsonText);
            if (json.has("type")) {
                String eventType = json.get("type").toString();
                if (eventType.equals("url_verification")) {
                    // challenge の内容をresponseに設定する
                    response = "HTTP 200 OK\nContent-type: text/plain\n" + json.get("challenge").toString();
                }
            }
        } catch(IOException e) {
            e.printStackTrace();
        } finally {
            // responseの内容をoutputStreamに書き込む
            outputStream.write(response.getBytes());
            outputStream.flush();
            outputStream.close();
        }
        return;
    }

    /* get String from BufferedReader */
    private static String readAll(Reader rd) throws IOException {
        StringBuilder sb = new StringBuilder();
        int cp;
        while ((cp = rd.read()) != -1) {
            sb.append((char) cp);
        }
        return sb.toString();
    }
}
```

## build.gradleの編集

また、プロジェクトフォルダ直下に`build.gradle`を配置します。
他に使いたいライブラリがある場合は`dependencies`の中に書き込むか、libフォルダを作ってその中に.jarファイルを入れてあげれば良いようにしてあります。

```build.gradle
apply plugin: 'java'

repositories {
    mavenCentral()
}

dependencies {
    compile (
        'com.amazonaws:aws-lambda-java-core:1.1.0',
        'com.amazonaws:aws-lambda-java-events:1.1.0',
        'org.json:json:20180813'
    )
    testCompile 'junit:junit:4.+'
}

task buildZip(type: Zip) {
    from compileJava
    from processResources              
    into('lib') {
        from configurations.compileClasspath
    }           
}

build.dependsOn buildZip
```

## gradleでビルド

ここまで来たら、プロジェクトフォルダ直下で以下を実行してビルドします。

```
$ gradle build
```

ビルドに成功すると`build/distribution/SampleBot.zip`が生成されているはずです。

## lambda関数のテスト実行
AWS lambdaのコンソールを開き、先ほど作成した`SampleBot.zip`をアップロードします。
ハンドラには`jp.com.hoge.App`を入力し、忘れずに保存します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/55a6bf47-bdd0-d118-74b9-7ab3e7ca4053.png)

### テストイベントの実行
まずはlambda関数をテストしてみましょう。
画面右上の「テストイベントの選択...」と出ているボックスの下矢印から「テストイベントの作成」を選択し、下図のように作成します。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/c0c51a4a-3372-9422-32e4-59528c108fe5.png)

画面右上の「テスト」ボタンを押してテストを実行します。
成功すると実行結果が表示され、「詳細」を押すと下のように出ます。
ちゃんとアウトプットできていることがわかります
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/49065a27-aad5-b58e-c6f0-9150f44c557b.png)

CloudWatchを見ると、ログとして出力したinputStreamの内容が出ています。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/13463b6f-4648-633e-ec63-2806456a51b7.png)

# Slack Event APIの購読
ここまで来て、ようやくSlack Event APIの購読ができます！
botを作成した時のページの左にある、「Event Subscriptions」を選択します
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/1ab83690-9127-8576-1736-54b1323a2f2b.png)
Enable Eventsをonにして、Request URLの欄に、APIのデプロイ時にメモしたURLを入力します。
responseが確認されると下のようになり、eventsを購読することができるようになります！
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/d72e0a98-2fb9-496b-32b9-6b5e05ff43cc.png)

今回は、`app_mention`というイベントを受け取ることにしましょう。
これはbotが@をつけてメンションされた時に反応するイベントです。
追加したら「Save changes」を忘れすに押してください
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/88bb1e7c-1dd1-4b0b-c6c7-bdcf76b891bd.png)

## botのインストール

そして、botをworkspaceにインストールします
「OAuth & Permissions」を選んで、下にスクロールするとScopesというメニューがあるので、そこで`Send messages as SampleBot`というものを選択して「Save Changes」してください。
これでSampleBotとしてメッセージを送ることが可能になります。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/19176815-b023-0f98-ed69-d50c80649ecd.png)

その後「Install App to Workspace」でインストールします。
`OAuth Access Token`と`Bot User OAuth Access Token`が表示されるのでこれもメモしておきましょう。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/65a9f9c6-8dc1-23c0-314e-4f227edc7e54.png)

# オウム返しBotを作る
ここからは、自分宛の発言に対してオウム返しで返信するBotを例として作成していきます。
githubには全体のソースコードを載せてあります
https://github.com/kamata1729/sampleSlackBotWithJava

`app_mention`イベントは以下のように送られてきます。

```json
{
    "token": "ZZZZZZWSxiZZZ2yIvs3peJ",
    "team_id": "T061EG9R6",
    "api_app_id": "A0MDYCDME",
    "event": {
        "type": "app_mention",
        "user": "U061F7AUR",
        "text": "What is the hour of the pearl, <@U0LAN0Z89>?",
        "ts": "1515449522.000016",
        "channel": "C0LAN2Q65",
        "event_ts": "1515449522000016"
    },
    "type": "event_callback",
    "event_id": "Ev0LAN670R",
    "event_time": 1515449522000016,
    "authed_users": [
        "U0LAN0Z89"
    ]
}
```

これに対して、同じチャンネルに、メンションを相手のユーザー名に変えて、`"What is the hour of the pearl, <@U061F7AUR>?"`とポストするBotを作成します。

### Botのuser idの取得
textの中のuser idをreplaceする必要があるので、botのuser idを入手します。
まず以下からwaorkspaceのtokenを入手します。
https://api.slack.com/custom-integrations/legacy-tokens
そのtokenをつかって、以下のurlにアクセスし、samplebotのidを取得し、メモしておきます。大文字のUから始まる文字列です。
https://slack.com/api/users.list?token=取得したtoken

### 環境変数の登録
AWS lambdaのページで環境変数として、メモしておいた、

* `SLACK_BOT_USER_ACCESS_TOKEN`: xoxp-から始まるtoken
* `SLACK_APP_AUTH_TOKEN`: xoxb-から始まるtoken
* `USER_ID`: samplebotのuser id  
 

の３つを登録します。これで`System.getenv`メソッドから取り出せるようになります。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/262908/abeff03e-e2fd-63a7-c45f-94920ca05eb6.png)


### chat.postMessage APIを利用してメッセージを投稿する
chat.postMessage APIにJSONを投げることでメッセージを投稿することができます。
その時のjsonはこのような形で送ります。

```json
{
    "token": SLACK_APP_AUTH_TOKEN,
    "channel": channel,
    "text": message,
    "username": "sampleBot"
}
```

また、その際にRequest Propertyとして、
`"Content-Type": "application/json; charset=UTF-8"`と、
`"Authorization": "Bearer " + SLACK_BOT_USER_ACCESS_TOKEN`
を設定する必要があります。

以上を踏まえて以下のようにApp.javaを編集しました。

```App.java
package jp.com.hoge;

import java.io.*;
import java.net.URLEncoder;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.Charset;
import java.lang.StringBuilder;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONArray;

import com.amazonaws.services.lambda.runtime.*;

public class App implements RequestStreamHandler
{
    public static String SLACK_BOT_USER_ACCESS_TOKEN = "";
    public static String SLACK_APP_AUTH_TOKEN = "";
    public static String USER_ID = "";

    @Override
    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException {
        // 環境変数の読み込み
        App.SLACK_BOT_USER_ACCESS_TOKEN = System.getenv("SLACK_BOT_USER_ACCESS_TOKEN");
        App.SLACK_APP_AUTH_TOKEN = System.getenv("SLACK_APP_AUTH_TOKEN");
        App.USER_ID = System.getenv("USER_ID");

        // デフォルトのresponse 何も返信しないと何度か同じイベントが送られてくる
        String response = "HTTP 200 OK\nContent-type: text/plain\nOK";
        try{
            BufferedReader rd = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8"))); 
            String jsonText = readAll(rd);
            System.out.println(jsonText); //System.outの出力はCloudWatchLogsに書き込まれる
            JSONObject json = new JSONObject(jsonText);

            // Event APIのテストの時
            if (json.has("type")) {
                String eventType = json.get("type").toString();
                if (eventType.equals("url_verification")) {
                    // challenge の内容をresponseに設定する
                    response = "HTTP 200 OK\nContent-type: text/plain\n" + json.get("challenge").toString();
                }
            }
            
            // app_mentionイベントの時
            if (json.has("event")) {
                JSONObject eventObject = json.getJSONObject("event");
                if(eventObject.has("type")) {
                    String eventType = eventObject.get("type").toString();
                    if (eventType.equals("app_mention")){
                        String user = eventObject.get("user").toString();
                        if (user.equals(App.USER_ID)) { return; } // 発言がbot user自身の場合は無視する
                        String channel = eventObject.get("channel").toString();
                        String text = eventObject.get("text").toString();
                        String responseText = text.replace(App.USER_ID, user);
                        System.out.println(responseText);
                        System.out.println(postMessage(responseText, channel));
                    }
                }
            }       
        } catch(IOException e) {
            e.printStackTrace();
        } finally {
            // responseの内容をoutputStreamに書き込む
            outputStream.write(response.getBytes());
            outputStream.flush();
            outputStream.close();
        }
        return;
    }

    /* get String from BufferedReader */
    private static String readAll(Reader rd) throws IOException {
        StringBuilder sb = new StringBuilder();
        int cp;
        while ((cp = rd.read()) != -1) {
            sb.append((char) cp);
        }
        return sb.toString();
    }

    /* post message to selected channel */
    public static String postMessage(String message, String channel) {
        String strUrl = "https://slack.com/api/chat.postMessage";
        String ret = "";
        URL url;

        HttpURLConnection urlConnection = null;
        try {
            url = new URL(strUrl);
            urlConnection = (HttpURLConnection) url.openConnection();
        } catch(IOException e) {
            e.printStackTrace();
            return "IOException";
        }
        
        urlConnection.setDoOutput(true);
        urlConnection.setConnectTimeout(100000);
        urlConnection.setReadTimeout(100000);
        urlConnection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
        String auth = "Bearer " + App.SLACK_BOT_USER_ACCESS_TOKEN;
        urlConnection.setRequestProperty("Authorization", auth);

        try {
            urlConnection.setRequestMethod("POST");
        } catch(ProtocolException e) {
            e.printStackTrace();
            return "ProtocolException";
        }

        try {
            urlConnection.connect();
        } catch(IOException e) {
            e.printStackTrace();
            return "IOException";
        }

        HashMap<String, Object> jsonMap = new HashMap<>();
        jsonMap.put("token", App.SLACK_APP_AUTH_TOKEN);
        jsonMap.put("channel", channel);
        jsonMap.put("text", message);
        jsonMap.put("username", " sampleBot");

        OutputStream outputStream = null;
        try {
            outputStream = urlConnection.getOutputStream();
        } catch(IOException e) {
            e.printStackTrace();
            return "IOException";
        }

        if (jsonMap.size() > 0) {
            JSONObject responseJsonObject = new JSONObject(jsonMap);
            String jsonText = responseJsonObject.toString();
            PrintStream ps = new PrintStream(outputStream);
            ps.print(jsonText);
            ps.close();
        }

        try {
            if (outputStream != null) {
                outputStream.close();
            }
            int responseCode = urlConnection.getResponseCode();
            BufferedReader rd = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), "UTF-8"));
            ret = readAll(rd);
        } catch(IOException e) {
            e.printStackTrace();
            return "IOException";
        
        } finally {
            if (urlConnection != null) {
                urlConnection.disconnect();
            }         
        }
        return ret;
    }
}
```

これを今までと同様に`gradle build`してアップロードすれば完成です！

# 動作の様子
これまでで実際に動作するものはできました。
しかしまだ落とし穴があります。
それは、slackのタイムアウト時間が3秒しかないため、lambda関数の起動の時間を含めると思いの外すぐにタイムアウトしてしまうことです！

## 成功例
タイムアウトせず想定通りの動きをしています。
![samplebot.gif](https://qiita-image-store.s3.amazonaws.com/0/262908/f1366629-a325-28af-a91c-46961ea1ad73.gif)

## 失敗例
すこし時間をおいてから動かした結果です。
lambda関数を改めて起動する必要があるためか、タイムアウト時間を過ぎてしまいました。
それを**slackがエラーと判定して何度かイベントを送り直してしまった**ため、lambda関数が複数回呼ばれ、複数回投稿してしまうという現象が発生しています。
![samplebot2.gif](https://qiita-image-store.s3.amazonaws.com/0/262908/f003b5c5-9338-1231-96b6-a973390791a7.gif)

## 解決方法
ここまででもだいぶ長くなってしまったので、これの解決方法については次の記事で説明します。
そちらもご覧ください
[[AWS lambda] Slack Botがタイムアウトで何度もレスポンスするのを防ぐ](https://qiita.com/kamata1729/items/fc6c31a3039599b48305)