4
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-03-11

はじめに

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

そして作ったアプリにBot Userを追加します
image.png

slackイベントを受け取るエンドポイントを作成

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

IAMのロールの作成

AWS コンソールからIAMを開き、新しいロールを作成します。
image.png

lambda で使用するので「lambda」を選択して先にすすみます。
image.png

今回はログをCloudWatchLogに出力できるようにしたいので、書き込み権限のあるAWSLambdaBasicExecutionRoleを選択します
`
image.png

次のタグの作成に関しては今回は使用しないので無視して大丈夫です。
その次の確認画面でロール名をつけて完了です!
今回はsampleBotRoleとつけました。
image.png

lambda関数の作成

今度はlambdaのコンソールに移動して、関数の作成を選択します。
「一から作成」を選択し、関数名は今回はsampleBot, ランタイムにjava 8を選択します。
また、ロールには先ほど作成したsampleBotRoleを選択しました
image.png

API Gatewayの作成

まず、awsコンソールのAPI Gatewayのページから「APIの作成」を選択し、API名(今回はsampleBotAPI)をつけて作成します。
image.png
作成後の画面から、アクション->メソッドの作成を選択し、POSTメソッドを追加します
image.png
チェックマークを押してPOSTメソッドを作成し、セットアップ画面で先ほど作ったlambda関数に合うように設定して保存します。
image.png
そのあとの画面で、アクション->APIのデプロイを選択し、ステージ名を入力してデプロイします。
この時に画面上部にエンドポイントを呼び出すURLが表示されるので、これをメモしておいてください。
これでエンドポイントの作成が完了しました!
image.png

slack Event APIの認証について

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

認証では、最初に以下のような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の型を定義せずに、デフォルトで実装されている型を使用するものです。
以下のように実装します


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

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

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に独自の型を指定したい場合には、自分で入出力の形式に合わせた型を定義して使用することもできます。

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

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を使用

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

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以外はエンターで大丈夫です。
今回はgroupIdjp.com.hoge, artifactIdSampleBotとしました。
実行するとプロジェクト名のフォルダが作成され、src/main/java/jp/com/hoge/App.java が生成されます。

App.javaの編集

App.javaを以下のように編集します。
もしchallengeの内容を得ることに失敗しても、何かしらの文字列を送り返さないとタイムアウトと見なされ、1分後と5分後にまた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

テストイベントの実行

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

image.png

画面右上の「テスト」ボタンを押してテストを実行します。
成功すると実行結果が表示され、「詳細」を押すと下のように出ます。
ちゃんとアウトプットできていることがわかります
image.png

CloudWatchを見ると、ログとして出力したinputStreamの内容が出ています。
image.png

Slack Event APIの購読

ここまで来て、ようやくSlack Event APIの購読ができます!
botを作成した時のページの左にある、「Event Subscriptions」を選択します
image.png
Enable Eventsをonにして、Request URLの欄に、APIのデプロイ時にメモしたURLを入力します。
responseが確認されると下のようになり、eventsを購読することができるようになります!
image.png

今回は、app_mentionというイベントを受け取ることにしましょう。
これはbotが@をつけてメンションされた時に反応するイベントです。
追加したら「Save changes」を忘れすに押してください
image.png

botのインストール

そして、botをworkspaceにインストールします
「OAuth & Permissions」を選んで、下にスクロールするとScopesというメニューがあるので、そこでSend messages as SampleBotというものを選択して「Save Changes」してください。
これでSampleBotとしてメッセージを送ることが可能になります。
image.png

その後「Install App to Workspace」でインストールします。
OAuth Access TokenBot User OAuth Access Tokenが表示されるのでこれもメモしておきましょう。
image.png

オウム返しBotを作る

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

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

{
    "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

の3つを登録します。これでSystem.getenvメソッドから取り出せるようになります。
image.png

chat.postMessage APIを利用してメッセージを投稿する

chat.postMessage APIに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

失敗例

すこし時間をおいてから動かした結果です。
lambda関数を改めて起動する必要があるためか、タイムアウト時間を過ぎてしまいました。
それをslackがエラーと判定して何度かイベントを送り直してしまったため、lambda関数が複数回呼ばれ、複数回投稿してしまうという現象が発生しています。
samplebot2.gif

解決方法

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

4
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?