Edited at

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 を押して新しいアプリを作成します。

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


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

ここからは、slackからのイベントをAWS受け取るためのエンドポイントを作成していきます。

具体的には、API Gatewayでイベントを受け取り、それをlambda関数に受け渡すようにしていきます。


IAMのロールの作成

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

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

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

`

次のタグの作成に関しては今回は使用しないので無視して大丈夫です。

その次の確認画面でロール名をつけて完了です!

今回はsampleBotRoleとつけました。


lambda関数の作成

今度はlambdaのコンソールに移動して、関数の作成を選択します。

「一から作成」を選択し、関数名は今回はsampleBot, ランタイムにjava 8を選択します。

また、ロールには先ほど作成したsampleBotRoleを選択しました


API Gatewayの作成

まず、awsコンソールのAPI Gatewayのページから「APIの作成」を選択し、API名(今回はsampleBotAPI)をつけて作成します。



作成後の画面から、アクション->メソッドの作成を選択し、POSTメソッドを追加します



チェックマークを押してPOSTメソッドを作成し、セットアップ画面で先ほど作ったlambda関数に合うように設定して保存します。



そのあとの画面で、アクション->APIのデプロイを選択し、ステージ名を入力してデプロイします。

この時に画面上部にエンドポイントを呼び出すURLが表示されるので、これをメモしておいてください。

これでエンドポイントの作成が完了しました!


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を入力し、忘れずに保存します。


テストイベントの実行

まずはlambda関数をテストしてみましょう。

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

画面右上の「テスト」ボタンを押してテストを実行します。

成功すると実行結果が表示され、「詳細」を押すと下のように出ます。

ちゃんとアウトプットできていることがわかります

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


Slack Event APIの購読

ここまで来て、ようやくSlack Event APIの購読ができます!

botを作成した時のページの左にある、「Event Subscriptions」を選択します



Enable Eventsをonにして、Request URLの欄に、APIのデプロイ時にメモしたURLを入力します。

responseが確認されると下のようになり、eventsを購読することができるようになります!

今回は、app_mentionというイベントを受け取ることにしましょう。

これはbotが@をつけてメンションされた時に反応するイベントです。

追加したら「Save changes」を忘れすに押してください


botのインストール

そして、botをworkspaceにインストールします

「OAuth & Permissions」を選んで、下にスクロールするとScopesというメニューがあるので、そこでSend messages as SampleBotというものを選択して「Save Changes」してください。

これでSampleBotとしてメッセージを送ることが可能になります。

その後「Install App to Workspace」でインストールします。

OAuth Access TokenBot User OAuth Access Tokenが表示されるのでこれもメモしておきましょう。


オウム返し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メソッドから取り出せるようになります。


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関数の起動の時間を含めると思いの外すぐにタイムアウトしてしまうことです!


成功例

タイムアウトせず想定通りの動きをしています。


失敗例

すこし時間をおいてから動かした結果です。

lambda関数を改めて起動する必要があるためか、タイムアウト時間を過ぎてしまいました。

それをslackがエラーと判定して何度かイベントを送り直してしまったため、lambda関数が複数回呼ばれ、複数回投稿してしまうという現象が発生しています。


解決方法

ここまででもだいぶ長くなってしまったので、これの解決方法については次の記事で説明します。

そちらもご覧ください

[AWS lambda] Slack Botがタイムアウトで何度もレスポンスするのを防ぐ