GCP: Google Cloud Functions で Slack アプリを試してみた

# 概要
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](https://api.slack.com/events/url_verification)

今回は、Google Cloud Functions上でJavaを使って作りました。
Cloud Functionsコンソールの「関数を作成」からオンライン上で作成しました。
トリガーは「HTTP」を使います。

Slackからの呼び出しはFunctionsに対して認証できないので、「未承認の呼び出しを許可」しておく必要があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/32bcc12e-96c9-7cef-0c8c-5aac5360dfa8.png)
※あとから承認有無を変更する場合
[IAM によるアクセス管理  |  Google Cloud Functions に関するドキュメント > 認証されていない関数の呼び出しを許可する](https://cloud.google.com/functions/docs/securing/managing-access-iam#allowing_unauthenticated_function_invocation)

ランタイムを選ぶとサンプルソースが用意されます。
サンプルソースを下記のように変更し、まずは認証が通るようにしました。

```Example.java
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の場合

```pom.xml
    <dependency>
        <groupId>com.google.code.gson</groupId>
        <artifactId>gson</artifactId>
        <version>2.8.6</version>
    </dependency>
```

※gradleの場合

```build.gradle
dependencies {
	implementation('com.google.code.gson:gson:2.8.6')
}
```


これで準備は完了です。

## Slack Apps を設定する(Slack)
[Slack API > Your Apps](https://api.slack.com/apps) から作成します。
名前は日本語も使えます。
事前に作成先のWorkspaceを準備しておくと良いです。

**Add features and functionality**
今回は、メッセージ投稿イベントの通知、メッセージ投稿を行いたかったため、以下2つを設定しました。

- Event Subscriptions
- Permissions
- Bots

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/e727fe05-5231-5e88-1865-cebfd104cb76.png)

**Event Subscriptions**
メッセージを受信するための設定をします。

「On」に切り替え、`Request URL`にさきほど準備したURLを設定します。
「Verified」になればOKです。

Subscribe to bot eventsに下記を追加します。
パブリックチャンネルだけを対象にするのであれば`message.groups`はなくてOKです。

- `message.channels` : メッセージ投稿イベント(アプリが追加されたパブリックチャンネルが対象)
- `message.groups` : メッセージ投稿イベント(アプリが追加された**プライベート**チャンネルが対象)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/10990aa9-8856-e787-b69a-3d3c76bf7dd5.png)

**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`: メッセージを投稿するため

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/ed907a8b-f4d6-3059-a1a2-bcfb354f1f13.png)

**Bots**

Botsの表示名(日本語OK)とユーザー名(日本語NG)を設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/1796d6e0-d7cb-d9e2-7c71-7f444d2101c6.png)

## Install your app to your workspace(Slack)
設定が済んだら、Workspaceにアプリをインストールします。
インストールできたら、Features - OAuth & Permissions を開き、`Bot User OAuth Access Token`を取得します。
この値をあとで使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/9c508fe9-a76c-8763-e1f0-e7cf06614228.png)


## スレッドで返信するように処理を変更する(Functions)
### スレッドとは
>[An overview of managing messages | Slack > Threading messages](https://api.slack.com/messaging/managing#threading)
- 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](https://api.slack.com/messaging/sending#threading)

スレッド返信するには、以下3つの値が必要です。

- token: SlackAppsの認証トークン
- channel: 返信したいメッセージの値
- ts: 返信したい受信メッセージの値

### メッセージを解析してスレッド返信する

今回はBoltではなく slack-api-client を使います。
まず、[API クライアントのセットアップ | Slack SDK for Java](https://slack.dev/java-slack-sdk/guides/ja/web-api-client-setup) に従い、セットアップします。

※mavenの場合

```pom.xml
    <dependency>
        <groupId>com.slack.api</groupId>
        <artifactId>slack-api-client</artifactId>
        <version>1.2.1</version>
    </dependency>
```

※gradleの場合

```build.gradle
dependencies {
	implementation("com.slack.api:slack-api-client:1.2.1")
}
```

さきほどのJavaを変更し、受け取った2つの情報をもとにスレッド返信する処理を追加しています。
`OAUTH_TOKENS` には、[さきほど](#install-your-app-to-your-workspaceslack) 取得したものを設定します。

※Functions では`System.out.println`するとログになるため、簡易的に`System.out.println`で記述

```Example.java
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();
  }
}

```

## 試してみる
1. チャンネルにアプリを追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/39a72850-ab34-4b6c-7174-875c529c34c7.png)

2. そのチャンネルで発言する
3. Functionsのログに、2の発言データがJSON形式で記録されている
4. 2の発言にスレッド返信される
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/407476ea-9913-1a43-154d-db61f0029bf9.png)

以下はメッセージ投稿した結果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](https://api.slack.com/events-api#the-events-api__responding-to-events)

## ボットの投稿にも反応してしまう

投稿ユーザーがボットかどうかをチェックして処理するよう対応を入れました。

公式ドキュメント
[users.info method | Slack](https://api.slack.com/methods/users.info)

## 日本語メッセージが文字化けして投稿される

Functions はどうやらビルドがUTF-8ではないようなので、UTF-8を明示する必要があります。

※mavenの場合

```pom.xml
  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
```

※gradleの場合

```build.gradle
tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}
```

## Tokenをソースに書きたくない

Functions では、環境変数を設定できます。
ソースから読み込めるのでそこに入れることもできます。

```Example.java
private static final String OAUTH_TOKENS = System.getenv("SLACK_OAUTH_TOKENS");
```

[シークレット管理  |  Google Cloud](https://cloud.google.com/solutions/secrets-management/?hl=ja) を使うとより良いかもしれませんが、よく分かっていないのでまた調べてみるつもりです。

# 参考
- 使うAPIを選ぶ  
https://api.slack.com/lang/ja-jp/which-api
- Event API の使い方  
https://slack.dev/java-slack-sdk/guides/ja/events-api

# 感想
Boltを使ったらもう少し簡単だったかもしれません。