概要
Google Cloud Functions を使って、Slackの投稿に対してメッセージを返信するアプリを作りました。
構成
- 言語: Java11
- Gradle
- slack-api-client
- 環境:
- Google Cloud Functions
やったこと
イベントを受け取るURLを用意する(Functions)
SlackからのイベントはURLで受け取ることができます。
設定はあとで行うので、先にURLとその中身を作っておきます。
イベント受信用URLには、認証リクエスト"type": "url_verification"
のイベントが来ます。
これに対して、HTTP 200で"challenge"
の値を返せば認証OKとなります。
url_verification event | Slack
今回は、Google Cloud Functions上でJavaを使って作りました。
Cloud Functionsコンソールの「関数を作成」からオンライン上で作成しました。
トリガーは「HTTP」を使います。
Slackからの呼び出しはFunctionsに対して認証できないので、「未承認の呼び出しを許可」しておく必要があります。
※あとから承認有無を変更する場合
IAM によるアクセス管理 | Google Cloud Functions に関するドキュメント > 認証されていない関数の呼び出しを許可する
ランタイムを選ぶとサンプルソースが用意されます。
サンプルソースを下記のように変更し、まずは認証が通るようにしました。
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
public class Example implements HttpFunction {
private static final Gson gson = new Gson();
@Override
public void service(HttpRequest request, HttpResponse response) throws Exception {
try {
JsonElement requestParsed = gson.fromJson(request.getReader(), JsonElement.class);
JsonObject requestJson = null;
if (requestParsed != null && requestParsed.isJsonObject()) {
requestJson = requestParsed.getAsJsonObject();
}
if (requestJson != null && requestJson.has("type")) {
String type = requestJson.get("type").getAsString();
if(type.equals("url_verification") && requestJson.has("challenge")) {
String challenge = requestJson.get("challenge").getAsString();
response.getWriter().write(challenge);
return;
}
response.getWriter().write("no challenge");
return;
}
} catch (JsonParseException e) {
response.getWriter().write("JsonParseException");
}
response.getWriter().write("not url_verification");
}
}
JSON解析するために以下を追記。
※mavenの場合
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>
※gradleの場合
dependencies {
implementation('com.google.code.gson:gson:2.8.6')
}
これで準備は完了です。
Slack Apps を設定する(Slack)
Slack API > Your Apps から作成します。
名前は日本語も使えます。
事前に作成先のWorkspaceを準備しておくと良いです。
Add features and functionality
今回は、メッセージ投稿イベントの通知、メッセージ投稿を行いたかったため、以下2つを設定しました。
- Event Subscriptions
- Permissions
- Bots
Event Subscriptions
メッセージを受信するための設定をします。
「On」に切り替え、Request URL
にさきほど準備したURLを設定します。
「Verified」になればOKです。
Subscribe to bot eventsに下記を追加します。
パブリックチャンネルだけを対象にするのであればmessage.groups
はなくてOKです。
-
message.channels
: メッセージ投稿イベント(アプリが追加されたパブリックチャンネルが対象) -
message.groups
: メッセージ投稿イベント(アプリが追加されたプライベートチャンネルが対象)
Permissions
Settings - Basic Information > Add features and functionality > Permissions を開きます。
さきほど行ったSubscribe to bot eventsの設定に必要なPermissionsが自動で追加されています。
これらのPermissionsは、削除ボタンが非活性になっています。
-
channels:history
: message.channelsイベントでパブリックチャンネルのメッセージ等を参照するため -
groups:history
: message.groupsイベントでプライベートチャンネルのメッセージ等を参照するため
注意として、イベントの方を削除しても自動追加されたPermissionsは削除されません。
削除ボタンが活性になるだけです。他で利用していなければ合わせて削除しておいた方がよいです。
また今回は返信を送るので、下記も追加します。
-
chat:write
: メッセージを投稿するため
Bots
Botsの表示名(日本語OK)とユーザー名(日本語NG)を設定します。
Install your app to your workspace(Slack)
設定が済んだら、Workspaceにアプリをインストールします。
インストールできたら、Features - OAuth & Permissions を開き、Bot User OAuth Access Token
を取得します。
この値をあとで使います。
スレッドで返信するように処理を変更する(Functions)
スレッドとは
An overview of managing messages | Slack > Threading messages
- Before a message has any replies, we call it an unthreaded message.
- Once a message has replies, it becomes a parent message.
- Any child messages of that parent message are called threaded replies.
- The whole bundle of parent message and replies is referred to as a thread
- Each of the messages within a thread, whether parent or reply, is a threaded message.
用語の説明を日本語にするとだいたい下記のとおりです(雰囲気翻訳)。
- 返信がないメッセージのことをスレッドかされていないメッセージと呼びます
- メッセージに返信があると、親メッセージと呼びます。
- 親メッセージとそれに連なる子メッセージをまとめてスレッド化された返信と呼びます。
- 親メッセージと返信全体をスレッドと呼びます。
- スレッド内の各メッセージは、親メッセージも子メッセージもスレッド化されたメッセージです。
Sending messages | Slack > Replying to your message
スレッド返信するには、以下3つの値が必要です。
- token: SlackAppsの認証トークン
- channel: 返信したいメッセージの値
- ts: 返信したい受信メッセージの値
メッセージを解析してスレッド返信する
今回はBoltではなく slack-api-client を使います。
まず、API クライアントのセットアップ | Slack SDK for Java に従い、セットアップします。
※mavenの場合
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
<version>1.2.1</version>
</dependency>
※gradleの場合
dependencies {
implementation("com.slack.api:slack-api-client:1.2.1")
}
さきほどのJavaを変更し、受け取った2つの情報をもとにスレッド返信する処理を追加しています。
OAUTH_TOKENS
には、さきほど 取得したものを設定します。
※Functions ではSystem.out.println
するとログになるため、簡易的にSystem.out.println
で記述
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.slack.api.Slack;
import com.slack.api.methods.MethodsClient;
import com.slack.api.methods.SlackApiException;
import com.slack.api.methods.response.chat.ChatPostMessageResponse;
import java.io.IOException;
public class Example implements HttpFunction {
private static final Gson gson = new Gson();
@Override
public void service(HttpRequest request, HttpResponse response) throws Exception {
try {
JsonElement requestParsed = gson.fromJson(request.getReader(), JsonElement.class);
JsonObject requestJson = null;
if (requestParsed != null && requestParsed.isJsonObject()) {
requestJson = requestParsed.getAsJsonObject();
}
if (requestJson != null && requestJson.has("type")) {
String type = requestJson.get("type").getAsString();
// url_verification
if (type.equals("url_verification") && requestJson.has("challenge")) {
String challenge = requestJson.get("challenge").getAsString();
response.getWriter().write(challenge);
return;
}
// メッセージ投稿されたらスレッド返信する
if (type.equals("event_callback") && requestJson.has("event")) {
JsonObject event = requestJson.getAsJsonObject("event");
if(event.has("thread_ts")){
System.out.println("type is event_callback, but this message is threaded replies");
return;
}
if (event.get("type").getAsString().equals("message")) {
String channel = event.get("channel").getAsString();
String ts = event.get("ts").getAsString();
String message = "threaded reply :tada:";
String result = reply(message, channel, ts);
System.out.println(String.format("message: %s", requestParsed));
System.out.println(String.format("replied: %s", result));
}
System.out.println("type is event_callback, but event type is not message");
return;
}
}
} catch (JsonParseException e) {
System.out.println("JsonParseException");
}
System.out.println("type is not url_verification or event_callback");
}
private String reply(String message, String channel, String ts)
throws IOException, SlackApiException {
MethodsClient client = Slack.getInstance().methods();
ChatPostMessageResponse result =
client.chatPostMessage(
request ->
request.token("OAUTH_TOKENS").channel(channel).threadTs(ts).text(message));
return result.toString();
}
}
試してみる
以下はメッセージ投稿した結果ChatPostMessageResponseです。
失敗時
ChatPostMessageResponse(ok=false, warning=null, error=invalid_auth, needed=null, provided=null, deprecatedArgument=null, responseMetadata=null, channel=null, ts=null, message=null)
成功時
長くて見づらいのでカンマで改行しています。
OK=true
で返ってきていれば投稿できています。
11月 01, 2020 5:31:42 午後 slack.MessagingFunctions replyMessage
情報: result: ChatPostMessageResponse(
ok=true,
warning=null,
error=null,
needed=null,
provided=null,
deprecatedArgument=null,
responseMetadata=null,
channel=XXXXXXXXX,
ts=1604219501.000100,
message=Message(
type=message,
subtype=null,
team=XXXXXXXXX,
channel=null,
user=XXXXXXXXX,
username=null,
text=threaded reply :tada:,
blocks=null,
attachments=null,
ts=1604219501.000100,
threadTs=1603605622.000200,
intro=false,
starred=false,
wibblr=false,
pinnedTo=null,
reactions=null,
botId=XXXXXXXXX,
botLink=null,
displayAsBot=false,
botProfile=BotProfile(
id=XXXXXXXXX,
deleted=false,
name=XXXXXXXXX,
updated=1603372962,
appId=XXXXXXXXX,
icons=BotProfile.Icons(image36=https://avatars.slack-edge.com/2020-10-22/XXXXXXXXX.png, image48=https://avatars.slack-edge.com/2020-10-22/XXXXXXXXX.png, image72=https://avatars.slack-edge.com/2020-10-22/XXXXXXXXX.png),
teamId=XXXXXXXXX
),
icons=null,
file=null,
files=null,
upload=false,
parentUserId=XXXXXXXXX,
inviter=null,
clientMsgId=null,
comment=null,
topic=null,
purpose=null,
edited=null,
unfurlLinks=false,
unfurlMedia=false,
threadBroadcast=false,
replies=null,
replyCount=null,
replyUsers=null,
replyUsersCount=null,
latestReply=null,
subscribed=false,
xFiles=null,
hidden=false,
lastRead=null,
root=null,
itemType=null,
item=null
)
)
追加で対応したこと
返信が複数回されてしまう
これは Slack Event Subscriptions の再送仕様に引っかかっているためです。
FunctionsをJavaで書いたために起動時間が遅いのが原因です。
もう少し処理が速い言語を使えば解消するかもしれません。
下記の記事を参考にして対応を入れました。ありがとうございます。
https://qiita.com/07JP27/items/0cd25a70c668ddb2f616
https://dev.classmethod.jp/articles/slack-resend-matome/
公式ドキュメント
Using the Slack Events API | Slack > Responding to Events
ボットの投稿にも反応してしまう
投稿ユーザーがボットかどうかをチェックして処理するよう対応を入れました。
公式ドキュメント
users.info method | Slack
日本語メッセージが文字化けして投稿される
Functions はどうやらビルドがUTF-8ではないようなので、UTF-8を明示する必要があります。
※mavenの場合
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
※gradleの場合
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
Tokenをソースに書きたくない
Functions では、環境変数を設定できます。
ソースから読み込めるのでそこに入れることもできます。
private static final String OAUTH_TOKENS = System.getenv("SLACK_OAUTH_TOKENS");
シークレット管理 | Google Cloud を使うとより良いかもしれませんが、よく分かっていないのでまた調べてみるつもりです。
参考
- 使うAPIを選ぶ
https://api.slack.com/lang/ja-jp/which-api - Event API の使い方
https://slack.dev/java-slack-sdk/guides/ja/events-api
感想
Boltを使ったらもう少し簡単だったかもしれません。