4
2

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 3 years have passed since last update.

SlackAdvent Calendar 2020

Day 15

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

Last updated at Posted at 2020-12-14

概要

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に対して認証できないので、「未承認の呼び出しを許可」しておく必要があります。
image.png
※あとから承認有無を変更する場合
IAM によるアクセス管理 | Google Cloud Functions に関するドキュメント > 認証されていない関数の呼び出しを許可する

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

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 から作成します。
名前は日本語も使えます。
事前に作成先のWorkspaceを準備しておくと良いです。

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

  • Event Subscriptions
  • Permissions
  • Bots

image.png

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

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

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

  • message.channels : メッセージ投稿イベント(アプリが追加されたパブリックチャンネルが対象)
  • message.groups : メッセージ投稿イベント(アプリが追加されたプライベートチャンネルが対象)
    image.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

Bots

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

image.png

Install your app to your workspace(Slack)

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

image.png

スレッドで返信するように処理を変更する(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の場合

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 には、さきほど 取得したものを設定します。

※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

  2. そのチャンネルで発言する

  3. Functionsのログに、2の発言データがJSON形式で記録されている

  4. 2の発言にスレッド返信される
    image.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

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

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

公式ドキュメント
users.info method | Slack

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

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 を使うとより良いかもしれませんが、よく分かっていないのでまた調べてみるつもりです。

参考

感想

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

4
2
2

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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?