はじめに
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 を押して新しいアプリを作成します。
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を使用
InputStream
とOutputStream
を利用して、どんな入出力も可能に可能にする方法もあります。
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
以外はエンターで大丈夫です。
今回はgroupId
はjp.com.hoge
, artifactId
はSampleBot
としました。
実行するとプロジェクト名のフォルダが作成され、src/main/java/jp/com/hoge/App.java
が生成されます。
App.javaの編集
App.javaを以下のように編集します。
もしchallenge
の内容を得ることに失敗しても、何かしらの文字列を送り返さないとタイムアウトと見なされ、1分後と5分後にまたeventが送られて来ることになるので、とりあえず"OK"と返信しておくことにしています。
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ファイルを入れてあげれば良いようにしてあります。
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 Token
とBot 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を編集しました。
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がタイムアウトで何度もレスポンスするのを防ぐ