Slack チュートリアル - スラッシュ コマンド(第 2 世代)

コレクションでコンテンツを整理 必要に応じて、コンテンツの保存と分類を行います。

このチュートリアルでは Cloud Functions を使用して、Google Knowledge Graph API を検索する Slack のスラッシュ コマンドの実装方法について説明します。

目標

  • Slack で Slash コマンドを作成する。
  • HTTP Cloud Functions の関数を書き込み、デプロイする。
  • Slash コマンドを使って Google Knowledge Graph API を検索する。

費用

このチュートリアルでは、課金対象である次の Google Cloud コンポーネントを使用します。

  • Cloud Functions
  • Cloud Build
  • Artifact Registry
  • Cloud Logging

詳細については、Cloud Functions の料金をご覧ください。

料金計算ツールを使うと、予想使用量に基づいて費用の見積もりを生成できます。 新しい Google Cloud ユーザーは無料トライアルをご利用いただける場合があります。

始める前に

  1. Google Cloud アカウントにログインします。Google Cloud を初めて使用する場合は、アカウントを作成して、実際のシナリオでの Google プロダクトのパフォーマンスを評価してください。新規のお客様には、ワークロードの実行、テスト、デプロイができる無料クレジット $300 分を差し上げます。
  2. Google Cloud Console の [プロジェクト セレクタ] ページで、Google Cloud プロジェクトを選択または作成します。

    プロジェクト セレクタに移動

  3. Cloud プロジェクトに対して課金が有効になっていることを確認します。詳しくは、プロジェクトで課金が有効になっているかどうかを確認する方法をご覧ください。

  4. Cloud Functions, Cloud Build, Artifact Registry, and Logging API を有効にします。

    API を有効にする

  5. Google Cloud CLI をインストールして初期化します。
  6. Google Cloud Console の [プロジェクト セレクタ] ページで、Google Cloud プロジェクトを選択または作成します。

    プロジェクト セレクタに移動

  7. Cloud プロジェクトに対して課金が有効になっていることを確認します。詳しくは、プロジェクトで課金が有効になっているかどうかを確認する方法をご覧ください。

  8. Cloud Functions, Cloud Build, Artifact Registry, and Logging API を有効にします。

    API を有効にする

  9. Google Cloud CLI をインストールして初期化します。
  10. gcloud CLI がすでにインストールされている場合は、次のコマンドを実行して更新します。

    gcloud components update
  11. 開発環境を準備します。

データの流れ

Slack のスラッシュ コマンドのチュートリアル アプリケーションでのデータの流れでは、次の手順が行われます。

  1. ユーザーが Slack チャネルで /kg <search_query> スラッシュ コマンドを実行します。
  2. Slack がコマンドのペイロードを Cloud 関数のトリガー エンドポイントに送信します。
  3. Cloud 関数がユーザーの検索クエリとともにリクエストを Knowledge Graph API に送信します。
  4. Knowledge Graph API は一致した結果で応答します。
  5. Cloud Function はフォーマット化したレスポンス内容を Slack メッセージに保存します。
  6. Cloud 関数はメッセージを Slack に送信します。
  7. ユーザーは Slack チャンネルでフォーマット化されたレスポンスを確認します。

次はこの手順を可視化した図です。

認証情報の取得

関数をデプロイするには、Google Cloud コンソールで提供される API キーと Slack 署名シークレットが必要です。

Knowledge Graph API キーの取得

Google Cloud コンソールの [認証情報] ページで、[認証情報を作成] ボタンをクリックして [API キー] を選択します。このキーは deploy コマンドの一部として使用するので、覚えておいてください。このキーにより、関数から Knowledge Graph API にアクセスできます。

Slack の署名シークレットの取得

関数をデプロイするには、Slack 署名シークレットも必要です。Slack の署名シークレットを取得するには、Slack のスラッシュ コマンドをホストする Slack アプリを作成します。このアプリは、統合をインストールする権限のある Slack チームに関連付ける必要があります。

  1. Slack の [Your Apps] ページに移動して、[Create New App] をクリックします。

  2. [From Scratch] を選択します。

  3. アプリの名前を指定し、統合をインストールする権限のある Slack ワークスペースを選択します。

  4. [Create App] をクリックします。

    アプリが作成され、表示が [Basic Information] に変わります。

  5. [Basic Information] ページで、Slack の署名シークレットをコピーして保存します。

  6. 変更を保存します。

次に、ソースコードを取得し、関数をデプロイする必要があります。関数をデプロイしたら、アプリケーションの構成の説明に沿って、デプロイされた関数と統合するように Slack アプリを構成します。

関数の作成

  1. ローカルマシンにサンプルアプリのリポジトリのクローンを作成します。

    Node.js

    git clone https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git

    また、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    Python

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    Go

    git clone https://github.com/GoogleCloudPlatform/golang-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    Java

    git clone https://github.com/GoogleCloudPlatform/java-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    C#

    git clone https://github.com/GoogleCloudPlatform/dotnet-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    Ruby

    git clone https://github.com/GoogleCloudPlatform/ruby-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

    PHP

    git clone https://github.com/GoogleCloudPlatform/php-docs-samples.git

    または、zip 形式のサンプルをダウンロードし、ファイルを抽出してもかまいません。

  2. Cloud Functions のサンプルコードが含まれているディレクトリに移動します。

    Node.js

    cd nodejs-docs-samples/functions/slack/

    Python

    cd python-docs-samples/functions/slack/

    Go

    cd golang-samples/functions/functionsv2/slack/

    Java

    cd java-docs-samples/functions/slack/

    C#

    cd dotnet-docs-samples/functions/slack/SlackKnowledgeGraphSearch/

    Ruby

    cd ruby-docs-samples/functions/slack/

    PHP

    cd php-docs-samples/functions/slack_slash_command/

関数のデプロイ

ユーザー(または Slack)が関数のエンドポイントに HTTP POST リクエストを送信したときに実行される関数をデプロイするには、サンプルコード(または Java の pom.xml ファイル)を含むディレクトリで次のコマンドを実行します。

YOUR_SLACK_SIGNING_SECRET は、アプリの構成の [Basic information] ページで、Slack から提供された署名シークレットに置き換えます。YOUR_KG_API_KEY は、前に作成した Knowledge Graph API Key で置き換えます。

Node.js

gcloud functions deploy nodejs-slack-function \
--gen2 \
--runtime=nodejs16 \
--region=REGION \
--source=. \
--entry-point=kgSearch \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する Node.js のバージョンを指定するには、--runtime フラグに次の値を使用します。
  • nodejs16(推奨)
  • nodejs18(プレビュー版)
  • nodejs14
  • nodejs12
  • nodejs10

Python

gcloud functions deploy python-slack-function \
--gen2 \
--runtime=python310 \
--region=REGION \
--source=. \
--entry-point=kg_search \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する Python バージョンを指定するには、--runtime フラグに次の値を使用します。
  • python310(推奨)
  • python39
  • python38
  • python37

Go

gcloud functions deploy go-slack-function \
--gen2 \
--runtime=go116 \
--region=REGION \
--source=. \
--entry-point=KGSearch \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する Go バージョンを指定するには、--runtime フラグに次の値を使用します。
  • go116(推奨)
  • go118(プレビュー版)
  • go119(プレビュー版)
  • go113
  • go111

Java

gcloud functions deploy java-slack-function \
--gen2 \
--runtime=java17 \
--region=REGION \
--source=. \
--entry-point=functions.SlackSlashCommand \
--memory=512MB \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する Java バージョンを指定するには、--runtime フラグに次の値を使用します。
  • java17(推奨)
  • java11

C#

gcloud functions deploy csharp-slack-function \
--gen2 \
--runtime=dotnet6 \
--region=REGION \
--source=. \
--entry-point=SlackKnowledgeGraphSearch.Function \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する .NET バージョンを指定するには、--runtime フラグに次の値を使用します。
  • dotnet6(推奨)
  • dotnet3

Ruby

gcloud functions deploy ruby-slack-function \
--gen2 \
--runtime=ruby30 \
--region=REGION \
--source=. \
--entry-point=kg_search \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する Ruby バージョンを指定するには、--runtime フラグに次の値を使用します。
  • ruby30(推奨)
  • ruby27
  • ruby26

PHP

gcloud functions deploy php-slack-function \
--gen2 \
--runtime=php81 \
--region=REGION \
--source=. \
--entry-point=receiveRequest \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated
優先する PHP バージョンを指定するには、--runtime フラグに次の値を使用します。
  • php81(推奨)
  • php74

アプリケーションの構成

関数をデプロイした後、Slack Slash コマンドを作成する必要があります。このコマンドがトリガーされるたびに Cloud Functions の関数にクエリが送信されます。

  1. 上記で作成した Slack アプリに戻ります。

  2. アプリを選択し、[Slash commands] に移動して [Create New Command] ボタンをクリックします。

  3. コマンドの名前として「/kg」を入力します。

  4. [Request URL] フィールドに、関数の URL を入力します。

    deploy コマンド出力から URL をコピーするか、Google Cloud コンソールで Cloud Functions の概要ページに移動し、関数をクリックして [関数の詳細] ページを開き、そのページの URL をコピーします。

  5. 簡単な説明を入力して [Save] をクリックします。

  6. [Basic Information] に移動します。

  7. [Install to Workspace] をクリックします。画面の指示に従って、ワークスペース用にアプリケーションを有効にします。

    少し待つと、Slack Slash コマンドがオンラインになります。

コードについて

依存関係をインポートする

アプリケーションが Google Cloud Platform サービスとやり取りするには、いくつかの依存関係をインポートする必要があります。

Node.js

const functions = require('@google-cloud/functions-framework');
const google = require('@googleapis/kgsearch');
const {verifyRequestSignature} = require('@slack/events-api');

// Get a reference to the Knowledge Graph Search component
const kgsearch = google.kgsearch('v1');

Python

import os

from flask import jsonify
import functions_framework
import googleapiclient.discovery
from slack.signature import SignatureVerifier

kgsearch = googleapiclient.discovery.build(
    'kgsearch',
    'v1',
    developerKey=os.environ['KG_API_KEY'],
    cache_discovery=False)

Go


package slack

import (
	"context"
	"log"
	"os"

	"google.golang.org/api/kgsearch/v1"
	"google.golang.org/api/option"
)

var (
	entitiesService *kgsearch.EntitiesService
	kgKey           string
	slackSecret     string
)

func setup(ctx context.Context) {
	kgKey = os.Getenv("KG_API_KEY")
	slackSecret = os.Getenv("SLACK_SECRET")

	if entitiesService == nil {
		kgService, err := kgsearch.NewService(ctx, option.WithAPIKey(kgKey))
		if err != nil {
			log.Fatalf("kgsearch.NewService: %v", err)
		}
		entitiesService = kgsearch.NewEntitiesService(kgService)
	}
}

Java

private static final Logger logger = Logger.getLogger(SlackSlashCommand.class.getName());
private static final String API_KEY = getenv("KG_API_KEY");
private static final String SLACK_SECRET = getenv("SLACK_SECRET");
private static final Gson gson = new Gson();

private final String apiKey;
private final Kgsearch kgClient;
private final SlackSignature.Verifier verifier;

public SlackSlashCommand() throws IOException, GeneralSecurityException {
  this(new SlackSignature.Verifier(new SlackSignature.Generator(SLACK_SECRET)));
}

SlackSlashCommand(SlackSignature.Verifier verifier) throws IOException, GeneralSecurityException {
  this(verifier, API_KEY);
}

SlackSlashCommand(SlackSignature.Verifier verifier, String apiKey)
    throws IOException, GeneralSecurityException {
  this.verifier = verifier;
  this.apiKey = apiKey;
  this.kgClient = new Kgsearch.Builder(
      GoogleNetHttpTransport.newTrustedTransport(), new JacksonFactory(), null).build();
}

// Avoid ungraceful deployment failures due to unset environment variables.
// If you get this warning you should redeploy with the variable set.
private static String getenv(String name) {
  String value = System.getenv(name);
  if (value == null) {
    logger.warning("Environment variable " + name + " was not set");
    value = "MISSING";
  }
  return value;
}

C#

using Google.Apis.Kgsearch.v1;
using Google.Apis.Services;
using Google.Cloud.Functions.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;

// Specify a startup class to use for dependency injection.
// This can also be specified on the function type.
[assembly: FunctionsStartup(typeof(SlackKnowledgeGraphSearch.Startup))]

namespace SlackKnowledgeGraphSearch
{
    public class Startup : FunctionsStartup
    {
        private static readonly TimeSpan SlackTimestampTolerance = TimeSpan.FromMinutes(5);

        public override void ConfigureServices(WebHostBuilderContext context, IServiceCollection services)
        {
            // These can come from an environment variable or a configuration file.
            string kgApiKey = context.Configuration["KG_API_KEY"];
            string slackSigningSecret = context.Configuration["SLACK_SECRET"];

            services.AddSingleton(new KgsearchService(new BaseClientService.Initializer
            {
                ApiKey = kgApiKey
            }));
            services.AddSingleton(new SlackRequestVerifier(slackSigningSecret, SlackTimestampTolerance));
            base.ConfigureServices(context, services);
        }
    }
}

Ruby

require "functions_framework"
require "slack-ruby-client"
require "google/apis/kgsearch_v1"

# This block is executed during cold start, before the function begins
# handling requests. This is the recommended way to create shared resources
# and objects.
FunctionsFramework.on_startup do
  # Create a global handler object, configured with the environment-provided
  # API key and signing secret.
  kg_search = KGSearch.new kg_api_key:     ENV["KG_API_KEY"],
                           signing_secret: ENV["SLACK_SECRET"]
  set_global :kg_search, kg_search
end

# The KGSearch class implements the logic of validating and responding
# to requests. More methods of this class are shown below.
class KGSearch
  def initialize kg_api_key:, signing_secret:
    # Create the global client for the Knowledge Graph Search Service,
    # configuring it with your API key.
    @client = Google::Apis::KgsearchV1::KgsearchService.new
    @client.key = kg_api_key

    # Save signing secret for use by the signature validation method.
    @signing_secret = signing_secret
  end

PHP

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Psr7\Response;

ウェブフックの受け取り

ユーザー(または Slack)が関数のエンドポイントに HTTP POST リクエストを送信すると、次の関数が実行されます。

Node.js

/**
 * Receive a Slash Command request from Slack.
 *
 * Trigger this function by creating a Slack slash command with the HTTP Trigger URL.
 * You can find the HTTP URL in the Cloud Console or using `gcloud functions describe`
 *
 * @param {object} req Cloud Function request object.
 * @param {object} req.body The request payload.
 * @param {string} req.rawBody Raw request payload used to validate Slack's message signature.
 * @param {string} req.body.text The user's search query.
 * @param {object} res Cloud Function response object.
 */
functions.http('kgSearch', async (req, res) => {
  try {
    if (req.method !== 'POST') {
      const error = new Error('Only POST requests are accepted');
      error.code = 405;
      throw error;
    }

    if (!req.body.text) {
      const error = new Error('No text found in body.');
      error.code = 400;
      throw error;
    }

    // Verify that this request came from Slack
    verifyWebhook(req);

    // Make the request to the Knowledge Graph Search API
    const response = await makeSearchRequest(req.body.text);

    // Send the formatted message back to Slack
    res.json(response);

    return Promise.resolve();
  } catch (err) {
    console.error(err);
    res.status(err.code || 500).send(err);
    return Promise.reject(err);
  }
});

Python

@functions_framework.http
def kg_search(request):
    if request.method != 'POST':
        return 'Only POST requests are accepted', 405

    verify_signature(request)
    kg_search_response = make_search_request(request.form['text'])
    return jsonify(kg_search_response)

Go


// Package slack is a Cloud Function which receives a query from
// a Slack command and responds with the KG API result.
package slack

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

const (
	version                     = "v0"
	slackRequestTimestampHeader = "X-Slack-Request-Timestamp"
	slackSignatureHeader        = "X-Slack-Signature"
)

type Attachment struct {
	Color     string `json:"color"`
	Title     string `json:"title"`
	TitleLink string `json:"title_link"`
	Text      string `json:"text"`
	ImageURL  string `json:"image_url"`
}

// Message is the a Slack message event.
// see https://api.slack.com/docs/message-formatting
type Message struct {
	ResponseType string       `json:"response_type"`
	Text         string       `json:"text"`
	Attachments  []Attachment `json:"attachments"`
}

func init() {
	functions.HTTP("KGSearch", kgSearch)
	setup(context.Background())
}

// kgSearch uses the Knowledge Graph API to search for a query provided
// by a Slack command.
func kgSearch(w http.ResponseWriter, r *http.Request) {

	bodyBytes, err := ioutil.ReadAll(r.Body)
	if err != nil {
		log.Printf("failed to read body: %v", err)
		http.Error(w, "could not read request body", http.StatusBadRequest)
		return
	}
	if r.Method != "POST" {
		http.Error(w, "Only POST requests are accepted", http.StatusMethodNotAllowed)
		return
	}
	formData, err := url.ParseQuery(string(bodyBytes))
	if err != nil {
		log.Printf("Error: Failed to Parse Form: %v", err)
		http.Error(w, "Couldn't parse form", http.StatusBadRequest)
		return
	}

	result, err := verifyWebHook(r, bodyBytes, slackSecret)
	if err != nil || !result {
		log.Printf("verifyWebhook failed: %v", err)
		http.Error(w, "Failed to verify request signature", http.StatusBadRequest)
		return
	}

	if len(formData.Get("text")) == 0 {
		log.Printf("no search text found: %v", formData)
		http.Error(w, "search text was empty", http.StatusBadRequest)
		return
	}
	kgSearchResponse, err := makeSearchRequest(formData.Get("text"))
	if err != nil {
		log.Printf("makeSearchRequest failed: %v", err)
		http.Error(w, "makeSearchRequest failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	if err = json.NewEncoder(w).Encode(kgSearchResponse); err != nil {
		http.Error(w, fmt.Sprintf("failed to marshal results: %v", err), 500)
		return
	}
}

Java

/**
 * Receive a Slash Command request from Slack.
 *
 * @param request Cloud Function request object.
 * @param response Cloud Function response object.
 * @throws IOException if Knowledge Graph request fails
 */
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {

  // Validate request
  if (!"POST".equals(request.getMethod())) {
    response.setStatusCode(HttpURLConnection.HTTP_BAD_METHOD);
    return;
  }

  // reader can only be read once per request, so we preserve its contents
  String bodyString = request.getReader().lines().collect(Collectors.joining());

  // Slack sends requests as URL-encoded strings
  //   Java 11 doesn't have a standard library
  //   function for this, so do it manually
  Map<String, String> body = new HashMap<>();
  for (String keyValuePair : bodyString.split("&")) {
    String[] keyAndValue = keyValuePair.split("=");
    if (keyAndValue.length == 2) {
      String key = keyAndValue[0];
      String value = keyAndValue[1];

      body.put(key, value);
    }
  }

  if (body == null || !body.containsKey("text")) {
    response.setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST);
    return;
  }

  if (!isValidSlackWebhook(request, bodyString)) {
    response.setStatusCode(HttpURLConnection.HTTP_UNAUTHORIZED);
    return;
  }

  String query = body.get("text");

  // Call knowledge graph API
  JsonObject kgResponse = searchKnowledgeGraph(query);

  // Format response to Slack
  // See https://api.slack.com/docs/message-formatting
  BufferedWriter writer = response.getWriter();

  writer.write(formatSlackMessage(kgResponse, query));

  response.setContentType("application/json");
}

C#

private readonly ILogger _logger;
private readonly KgsearchService _kgService;
private readonly SlackRequestVerifier _verifier;

public Function(ILogger<Function> logger, KgsearchService kgService, SlackRequestVerifier verifier) =>
    (_logger, _kgService, _verifier) = (logger, kgService, verifier);

public async Task HandleAsync(HttpContext context)
{
    var request = context.Request;
    var response = context.Response;

    // Validate request
    if (request.Method != "POST")
    {
        _logger.LogWarning("Unexpected request method '{method}'", request.Method);
        response.StatusCode = (int) HttpStatusCode.MethodNotAllowed;
        return;
    }

    if (!request.HasFormContentType)
    {
        _logger.LogWarning("Unexpected content type '{contentType}'", request.ContentType);
        response.StatusCode = (int) HttpStatusCode.BadRequest;
        return;
    }

    // We need to read the request body twice: once to validate the signature,
    // and once to read the form content. We copy it into a memory stream,
    // so that we can rewind it after reading.
    var bodyCopy = new MemoryStream();
    await request.Body.CopyToAsync(bodyCopy);
    request.Body = bodyCopy;
    bodyCopy.Position = 0;

    if (!_verifier.VerifyRequest(request, bodyCopy.ToArray()))
    {
        _logger.LogWarning("Slack request verification failed");
        response.StatusCode = (int) HttpStatusCode.Unauthorized;
        return;
    }

    var form = await request.ReadFormAsync();
    if (!form.TryGetValue("text", out var query))
    {
        _logger.LogWarning("Slack request form did not contain a text element");
        response.StatusCode = (int) HttpStatusCode.BadRequest;
        return;
    }

    var kgResponse = await SearchKnowledgeGraphAsync(query);
    string formattedResponse = FormatSlackMessage(kgResponse, query);
    response.ContentType = "application/json";
    await response.WriteAsync(formattedResponse);
}

Ruby

# Handler for the function endpoint.
FunctionsFramework.http "kg_search" do |request|
  # Return early if the request is not a POST.
  unless request.post?
    return [405, {}, ["Only POST requests are accepted."]]
  end

  # Access the global Knowledge Graph Search client
  kg_search = global :kg_search

  # Verify the request signature and return early if it failed.
  unless kg_search.signature_valid? request
    return [401, {}, ["Signature validation failed."]]
  end

  # Query the Knowledge Graph and format a Slack message with the response.
  # This method returns a nested hash, which the Functions Framework will
  # convert to JSON automatically.
  kg_search.make_search_request request.params["text"]
end

PHP

/**
 * Receive a Slash Command request from Slack.
 */
function receiveRequest(ServerRequestInterface $request): ResponseInterface
{
    // Validate request
    if ($request->getMethod() !== 'POST') {
        // [] = empty headers
        return new Response(405);
    }

    // Parse incoming URL-encoded requests from Slack
    // (Slack requests use the "application/x-www-form-urlencoded" format)
    $bodyStr = $request->getBody();
    parse_str($bodyStr, $bodyParams);

    if (!isset($bodyParams['text'])) {
        // [] = empty headers
        return new Response(400);
    }

    if (!isValidSlackWebhook($request, $bodyStr)) {
        // [] = empty headers
        return new Response(403);
    }

    $query = $bodyParams['text'];

    // Call knowledge graph API
    $kgResponse = searchKnowledgeGraph($query);

    // Format response to Slack
    // See https://api.slack.com/docs/message-formatting
    $formatted_message = formatSlackMessage($kgResponse, $query);

    return new Response(
        200,
        ['Content-Type' => 'application/json'],
        $formatted_message
    );
}

次の関数は、Slack から提供された X-Slack-Signature ヘッダーを検証することで、受信リクエストを認証します。

Node.js

/**
 * Verify that the webhook request came from Slack.
 *
 * @param {object} req Cloud Function request object.
 * @param {string} req.headers Headers Slack SDK uses to authenticate request.
 * @param {string} req.rawBody Raw body of webhook request to check signature against.
 */
const verifyWebhook = req => {
  const signature = {
    signingSecret: process.env.SLACK_SECRET,
    requestSignature: req.headers['x-slack-signature'],
    requestTimestamp: req.headers['x-slack-request-timestamp'],
    body: req.rawBody,
  };

  // This method throws an exception if an incoming request is invalid.
  verifyRequestSignature(signature);
};

Python

def verify_signature(request):
    request.get_data()  # Decodes received requests into request.data

    verifier = SignatureVerifier(os.environ['SLACK_SECRET'])

    if not verifier.is_valid_request(request.data, request.headers):
        raise ValueError('Invalid request/credentials.')

Go


// verifyWebHook verifies the request signature.
// See https://api.slack.com/docs/verifying-requests-from-slack.
func verifyWebHook(r *http.Request, body []byte, slackSigningSecret string) (bool, error) {
	timeStamp := r.Header.Get(slackRequestTimestampHeader)
	slackSignature := r.Header.Get(slackSignatureHeader)

	t, err := strconv.ParseInt(timeStamp, 10, 64)
	if err != nil {
		return false, fmt.Errorf("strconv.ParseInt(%s): %v", timeStamp, err)
	}

	if ageOk, age := checkTimestamp(t); !ageOk {
		return false, fmt.Errorf("checkTimestamp(%v): %v %v", t, ageOk, age)
	}

	if timeStamp == "" || slackSignature == "" {
		return false, fmt.Errorf("timeStamp and/or signature headers were blank")
	}

	baseString := fmt.Sprintf("%s:%s:%s", version, timeStamp, body)

	signature := getSignature([]byte(baseString), []byte(slackSigningSecret))

	trimmed := strings.TrimPrefix(slackSignature, fmt.Sprintf("%s=", version))
	signatureInHeader, err := hex.DecodeString(trimmed)

	if err != nil {
		return false, fmt.Errorf("hex.DecodeString(%v): %v", trimmed, err)
	}

	return hmac.Equal(signature, signatureInHeader), nil
}

func getSignature(base []byte, secret []byte) []byte {
	h := hmac.New(sha256.New, secret)
	h.Write(base)

	return h.Sum(nil)
}

// checkTimestamp allows requests time stamped less than 5 minutes ago.
func checkTimestamp(timeStamp int64) (bool, time.Duration) {
	t := time.Since(time.Unix(timeStamp, 0))

	return t.Minutes() <= 5, t
}

Java

/**
 * Verify that the webhook request came from Slack.
 *
 * @param request Cloud Function request object in {@link HttpRequest} format.
 * @param requestBody Raw body of webhook request to check signature against.
 * @return true if the provided request came from Slack, false otherwise
 */
boolean isValidSlackWebhook(HttpRequest request, String requestBody) {
  // Check for headers
  Optional<String> maybeTimestamp = request.getFirstHeader("X-Slack-Request-Timestamp");
  Optional<String> maybeSignature = request.getFirstHeader("X-Slack-Signature");
  if (!maybeTimestamp.isPresent() || !maybeSignature.isPresent()) {
    return false;
  }

  Long nowInMs = ZonedDateTime.now().toInstant().toEpochMilli();

  return verifier.isValid(maybeTimestamp.get(), requestBody, maybeSignature.get(), nowInMs);
}

C#

using Microsoft.AspNetCore.Http;
using System;
using System.Security.Cryptography;
using System.Text;

namespace SlackKnowledgeGraphSearch
{
    public class SlackRequestVerifier
    {
        private const string Version = "v0";
        private const string SignaturePrefix = Version + "=";
        private static readonly byte[] VersionBytes = Encoding.UTF8.GetBytes(Version);
        private static readonly byte[] ColonBytes = Encoding.UTF8.GetBytes(":");

        private readonly byte[] _signingKeyBytes;
        private readonly TimeSpan _timestampTolerance;

        public SlackRequestVerifier(string signingKey, TimeSpan timestampTolerance) =>
            (_signingKeyBytes, _timestampTolerance) =
            (Encoding.UTF8.GetBytes(signingKey), timestampTolerance);

        /// <summary>
        /// Verifies a request signature with the given content, which would
        /// normally have been read from the request beforehand.
        /// </summary>
        public bool VerifyRequest(HttpRequest request, byte[] content)
        {
            if (!request.Headers.TryGetValue("X-Slack-Request-Timestamp", out var timestampHeader) ||
                !request.Headers.TryGetValue("X-Slack-Signature", out var signatureHeader))
            {
                return false;
            }

            // Validate that the request isn't too old.
            if (!int.TryParse(timestampHeader, out var unixSeconds))
            {
                return false;
            }
            var timestamp = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
            if (timestamp + _timestampTolerance < DateTimeOffset.UtcNow)
            {
                return false;
            }

            // Hash the version:timestamp:content
            using var hmac = new HMACSHA256(_signingKeyBytes);
            AddToHash(VersionBytes);
            AddToHash(ColonBytes);
            AddToHash(Encoding.UTF8.GetBytes(timestampHeader));
            AddToHash(ColonBytes);
            byte[] hash = hmac.ComputeHash(content);
            void AddToHash(byte[] bytes) => hmac.TransformBlock(bytes, 0, bytes.Length, null, 0);

            // Compare the resulting signature with the signature in the request.
            return CreateSignature(hash) == (string) signatureHeader;
        }

        private const string Hex = "0123456789abcdef";
        private static string CreateSignature(byte[] hash)
        {
            var builder = new StringBuilder(SignaturePrefix, SignaturePrefix.Length + hash.Length * 2);
            foreach (var b in hash)
            {
                builder.Append(Hex[b >> 4]);
                builder.Append(Hex[b & 0xf]);
            }
            return builder.ToString();
        }
    }
}

Ruby

# slack-ruby-client expects a Rails-style request object with a "headers"
# method, but the Functions Framework provides only a Rack request.
# To avoid bringing in Rails as a dependency, we'll create a simple class
# that implements the "headers" method and delegates everything else back to
# the Rack request object.
require "delegate"
class RequestWithHeaders < SimpleDelegator
  def headers
    env.each_with_object({}) do |(key, val), result|
      if /^HTTP_(\w+)$/ =~ key
        header = Regexp.last_match(1).split("_").map(&:capitalize).join("-")
        result[header] = val
      end
    end
  end
end

# This is a method of the KGSearch class.
# It determines whether the given request's signature is valid.
def signature_valid? request
  # Wrap the request with our class that provides the "headers" method.
  request = RequestWithHeaders.new request

  # Validate the request signature.
  slack_request = Slack::Events::Request.new request,
                                             signing_secret: @signing_secret
  slack_request.valid?
end

PHP

/**
 * Verify that the webhook request came from Slack.
 */
function isValidSlackWebhook(ServerRequestInterface $request): bool
{
    $SLACK_SECRET = getenv('SLACK_SECRET');

    // Check for headers
    $timestamp = $request->getHeaderLine('X-Slack-Request-Timestamp');
    $signature = $request->getHeaderLine('X-Slack-Signature');
    if (!$timestamp || !$signature) {
        return false;
    }

    // Compute signature
    $plaintext = sprintf('v0:%s:%s', $timestamp, $request->getBody());
    $hash = sprintf('v0=%s', hash_hmac('sha256', $plaintext, $SLACK_SECRET));

    return $hash === $signature;
}

Knowledge Graph API のクエリ作成

次の関数はユーザーの検索クエリとともにリクエストを Knowledge Graph API に送信します。

Node.js

/**
 * Send the user's search query to the Knowledge Graph API.
 *
 * @param {string} query The user's search query.
 */
const makeSearchRequest = query => {
  return new Promise((resolve, reject) => {
    kgsearch.entities.search(
      {
        auth: process.env.KG_API_KEY,
        query: query,
        limit: 1,
      },
      (err, response) => {
        console.log(err);
        if (err) {
          reject(err);
          return;
        }

        // Return a formatted message
        resolve(formatSlackMessage(query, response));
      }
    );
  });
};

Python

def make_search_request(query):
    req = kgsearch.entities().search(query=query, limit=1)
    res = req.execute()
    return format_slack_message(query, res)

Go

func makeSearchRequest(query string) (*Message, error) {
	res, err := entitiesService.Search().Query(query).Limit(1).Do()
	if err != nil {
		return nil, err
	}
	return formatSlackMessage(query, res)
}

Java

/**
 * Send the user's search query to the Knowledge Graph API.
 *
 * @param query The user's search query.
 * @return The Knowledge graph API results as a {@link JsonObject}.
 * @throws IOException if Knowledge Graph request fails
 */
JsonObject searchKnowledgeGraph(String query) throws IOException {
  Kgsearch.Entities.Search kgRequest = kgClient.entities().search();
  kgRequest.setQuery(query);
  kgRequest.setKey(apiKey);

  return gson.fromJson(kgRequest.execute().toString(), JsonObject.class);
}

C#

private async Task<SearchResponse> SearchKnowledgeGraphAsync(string query)
{
    _logger.LogInformation("Performing Knowledge Graph search for '{query}'", query);
    var request = _kgService.Entities.Search();
    request.Limit = 1;
    request.Query = query;
    return await request.ExecuteAsync();
}

Ruby

# This is a method of the KGSearch class.
# It makes an API call to the Knowledge Graph Search Service, and formats
# a Slack message as a nested Hash object.
def make_search_request query
  response = @client.search_entities query: query, limit: 1
  format_slack_message query, response
end

PHP

/**
 * Send the user's search query to the Knowledge Graph API.
 */
function searchKnowledgeGraph(string $query): Google_Service_Kgsearch_SearchResponse
{
    $API_KEY = getenv('KG_API_KEY');

    $apiClient = new Google\Client();
    $apiClient->setDeveloperKey($API_KEY);

    $service = new Google_Service_Kgsearch($apiClient);

    $params = ['query' => $query];

    $kgResults = $service->entities->search($params);

    return $kgResults;
}

Slack メッセージのフォーマット化

最後に、次の関数は Knowledge Graph の結果をフォーマット化し、ユーザーに表示される適切な形式の Slack メッセージにします。

Node.js

/**
 * Format the Knowledge Graph API response into a richly formatted Slack message.
 *
 * @param {string} query The user's search query.
 * @param {object} response The response from the Knowledge Graph API.
 * @returns {object} The formatted message.
 */
const formatSlackMessage = (query, response) => {
  let entity;

  // Extract the first entity from the result list, if any
  if (
    response &&
    response.data &&
    response.data.itemListElement &&
    response.data.itemListElement.length > 0
  ) {
    entity = response.data.itemListElement[0].result;
  }

  // Prepare a rich Slack message
  // See https://api.slack.com/docs/message-formatting
  const slackMessage = {
    response_type: 'in_channel',
    text: `Query: ${query}`,
    attachments: [],
  };

  if (entity) {
    const attachment = {
      color: '#3367d6',
    };
    if (entity.name) {
      attachment.title = entity.name;
      if (entity.description) {
        attachment.title = `${attachment.title}: ${entity.description}`;
      }
    }
    if (entity.detailedDescription) {
      if (entity.detailedDescription.url) {
        attachment.title_link = entity.detailedDescription.url;
      }
      if (entity.detailedDescription.articleBody) {
        attachment.text = entity.detailedDescription.articleBody;
      }
    }
    if (entity.image && entity.image.contentUrl) {
      attachment.image_url = entity.image.contentUrl;
    }
    slackMessage.attachments.push(attachment);
  } else {
    slackMessage.attachments.push({
      text: 'No results match your query...',
    });
  }

  return slackMessage;
};

Python

def format_slack_message(query, response):
    entity = None
    if response and response.get('itemListElement') is not None and \
       len(response['itemListElement']) > 0:
        entity = response['itemListElement'][0]['result']

    message = {
        'response_type': 'in_channel',
        'text': 'Query: {}'.format(query),
        'attachments': []
    }

    attachment = {}
    if entity:
        name = entity.get('name', '')
        description = entity.get('description', '')
        detailed_desc = entity.get('detailedDescription', {})
        url = detailed_desc.get('url')
        article = detailed_desc.get('articleBody')
        image_url = entity.get('image', {}).get('contentUrl')

        attachment['color'] = '#3367d6'
        if name and description:
            attachment['title'] = '{}: {}'.format(entity["name"],
                                                  entity["description"])
        elif name:
            attachment['title'] = name
        if url:
            attachment['title_link'] = url
        if article:
            attachment['text'] = article
        if image_url:
            attachment['image_url'] = image_url
    else:
        attachment['text'] = 'No results match your query.'
    message['attachments'].append(attachment)

    return message

Go


package slack

import (
	"encoding/json"
	"fmt"

	"google.golang.org/api/kgsearch/v1"
)

type ItemList struct {
	Items []ItemListElement `json:"itemListElement"`
}
type ItemListElement struct {
	Result EntitySearchResult `json:"result"`
}
type EntitySearchResult struct {
	Name         string       `json:"name"`
	Description  string       `json:"description"`
	DetailedDesc DetailedDesc `json:"detailedDescription"`
	URL          string       `json:"url"`
	Image        Image
}
type DetailedDesc struct {
	ArticleBody string
	URL         string
}
type Image struct {
	ContentURL string
}

func formatSlackMessage(query string, response *kgsearch.SearchResponse) (*Message, error) {
	if response == nil {
		return nil, fmt.Errorf("empty response")
	}

	if response.ItemListElement == nil || len(response.ItemListElement) == 0 {
		message := &Message{
			ResponseType: "in_channel",
			Text:         fmt.Sprintf("Query: %s", query),
			Attachments: []Attachment{
				{
					Color: "#d6334b",
					Text:  "No results match your query.",
				},
			},
		}
		return message, nil
	}

	// The KnowledgeGraph API returns an empty interface. To make this more
	// useful, we convert it back to json, and unmarshal into specific types.
	jsonstring, err := response.MarshalJSON()
	if err != nil {
		return nil, err
	}
	r := &ItemList{}
	if err := json.Unmarshal(jsonstring, r); err != nil {
		return nil, fmt.Errorf("failed to unmarshal json: %v", err)
	}
	result := r.Items[0].Result

	attach := Attachment{Color: "#3367d6"}
	attach.Title = result.Name
	attach.TitleLink = result.DetailedDesc.URL
	attach.Text = result.DetailedDesc.ArticleBody
	attach.ImageURL = result.Image.ContentURL

	message := &Message{
		ResponseType: "in_channel",
		Text:         fmt.Sprintf("Query: %s", query),
		Attachments:  []Attachment{attach},
	}
	return message, nil
}

Java

/**
 * Helper method to copy properties between {@link JsonObject}s
 */
void addPropertyIfPresent(
    JsonObject target, String targetName, JsonObject source, String sourceName) {
  if (source.has(sourceName)) {
    target.addProperty(targetName, source.get(sourceName).getAsString());
  }
}

/**
 * Format the Knowledge Graph API response into a richly formatted Slack message.
 *
 * @param kgResponse The response from the Knowledge Graph API as a {@link JsonObject}.
 * @param query The user's search query.
 * @return The formatted Slack message as a JSON string.
 */
String formatSlackMessage(JsonObject kgResponse, String query) {
  JsonObject attachmentJson = new JsonObject();

  JsonObject responseJson = new JsonObject();
  responseJson.addProperty("response_type", "in_channel");
  responseJson.addProperty("text", String.format("Query: %s", query));

  JsonArray entityList = kgResponse.getAsJsonArray("itemListElement");

  // Extract the first entity from the result list, if any
  if (entityList.size() == 0) {
    attachmentJson.addProperty("text", "No results match your query...");
    responseJson.add("attachments", attachmentJson);

    return gson.toJson(responseJson);
  }

  JsonObject entity = entityList.get(0).getAsJsonObject().getAsJsonObject("result");

  // Construct Knowledge Graph response attachment
  String title = entity.get("name").getAsString();
  if (entity.has("description")) {
    title = String.format("%s: %s", title, entity.get("description").getAsString());
  }
  attachmentJson.addProperty("title", title);

  if (entity.has("detailedDescription")) {
    JsonObject detailedDescJson = entity.getAsJsonObject("detailedDescription");
    addPropertyIfPresent(attachmentJson, "title_link", detailedDescJson, "url");
    addPropertyIfPresent(attachmentJson, "text", detailedDescJson, "articleBody");
  }

  if (entity.has("image")) {
    JsonObject imageJson = entity.getAsJsonObject("image");
    addPropertyIfPresent(attachmentJson, "image_url", imageJson, "contentUrl");
  }

  JsonArray attachmentList = new JsonArray();
  attachmentList.add(attachmentJson);

  responseJson.add("attachments", attachmentList);

  return gson.toJson(responseJson);
}

C#

private string FormatSlackMessage(SearchResponse kgResponse, string query)
{
    JObject attachment = new JObject();
    JObject response = new JObject();

    response["response_type"] = "in_channel";
    response["text"] = $"Query: {query}";

    var element = kgResponse.ItemListElement?.FirstOrDefault() as JObject;
    if (element is object && element.TryGetValue("result", out var entityToken) &&
        entityToken is JObject entity)
    {
        string title = (string) entity["name"];
        if (entity.TryGetValue("description", out var description))
        {
            title = $"{title}: {description}";
        }
        attachment["title"] = title;
        if (entity.TryGetValue("detailedDescription", out var detailedDescriptionToken) &&
            detailedDescriptionToken is JObject detailedDescription)
        {
            AddPropertyIfPresent(detailedDescription, "url", "title_link");
            AddPropertyIfPresent(detailedDescription, "articleBody", "text");
        }
        if (entity.TryGetValue("image", out var imageToken) &&
            imageToken is JObject image)
        {
            AddPropertyIfPresent(image, "contentUrl", "image_url");
        }
    }
    else
    {
        attachment["text"] = "No results match your query...";
    }
    response["attachments"] = new JArray { attachment };
    return response.ToString();

    void AddPropertyIfPresent(JObject parent, string sourceProperty, string targetProperty)
    {
        if (parent.TryGetValue(sourceProperty, out var propertyValue))
        {
            attachment[targetProperty] = propertyValue;
        }
    }
}

Ruby

# This is a method of the KGSearch class.
# It takes a raw SearchResponse from the Knowledge Graph Search Service,
# and formats a Slack message.
def format_slack_message query, response
  result = response.item_list_element&.first&.fetch "result", nil
  attachment =
    if result
      name = result.fetch "name", nil
      description = result.fetch "description", nil
      details = result.fetch "detailedDescription", {}
      { "title"      => name && description ? "#{name}: #{description}" : name,
        "title_link" => details.fetch("url", nil),
        "text"       => details.fetch("articleBody", nil),
        "image_url"  => result.fetch("image", nil)&.fetch("contentUrl", nil) }
    else
      { "text" => "No results match your query." }
    end
  { "response_type" => "in_channel",
    "text"          => "Query: #{query}",
    "attachments"   => [attachment.compact] }
end

PHP

/**
 * Format the Knowledge Graph API response into a richly formatted Slack message.
 */
function formatSlackMessage(Google_Service_Kgsearch_SearchResponse $kgResponse, string $query): string
{
    $responseJson = [
        'response_type' => 'in_channel',
        'text' => 'Query: ' . $query
    ];

    $entityList = $kgResponse['itemListElement'];

    // Extract the first entity from the result list, if any
    if (empty($entityList)) {
        $attachmentJson = ['text' => 'No results match your query...'];
        $responseJson['attachments'] = $attachmentJson;

        return json_encode($responseJson);
    }

    $entity = $entityList[0]['result'];

    // Construct Knowledge Graph response attachment
    $title = $entity['name'];
    if (isset($entity['description'])) {
        $title = $title . ' ' . $entity['description'];
    }
    $attachmentJson = ['title' => $title];

    if (isset($entity['detailedDescription'])) {
        $detailedDescJson = $entity['detailedDescription'];
        $attachmentJson = array_merge([
            'title_link' => $detailedDescJson[ 'url'],
            'text' => $detailedDescJson['articleBody'],
        ], $attachmentJson);
    }

    if (isset($entity['image'])) {
        $imageJson = $entity['image'];
        $attachmentJson['image_url'] = $imageJson['contentUrl'];
    }

    $responseJson['attachments'] = array($attachmentJson);

    return json_encode($responseJson);
}

Slack API のタイムアウト

Slack API では、関数が Webhook リクエストを受け取ってから 3 秒以内に応答することが想定されています。

通常、このチュートリアルのコマンドは 3 秒以内に応答します。実行時間が長いコマンドの場合、タスクキューとして機能する Pub/Sub トピックに(リクエストの response_url フィールドを含む)リクエストを push する関数を構成することをおすすめします。

この構成によって、2 番目の関数を作成して Pub/Sub によってトリガーすることで、それらのタスクを処理し、Slack の response_url に結果を送信できます。

スラッシュ コマンドの使用

  1. 関数のデプロイが完了したら、Slack チャンネルに次のコマンドを入力します。

    /kg giraffe

    「giraffe」のナレッジグラフ エントリが表示されます。

  2. ログを確認して、関数実行の出力を確認します。

    gcloud functions logs read --limit 100
    

クリーンアップ

このチュートリアルで使用したリソースについて、Google Cloud アカウントに課金されないようにするには、リソースを含むプロジェクトを削除するか、プロジェクトを維持して個々のリソースを削除します。

プロジェクトの削除

課金をなくす最も簡単な方法は、チュートリアル用に作成したプロジェクトを削除することです。

プロジェクトを削除するには:

  1. Google Cloud コンソールで、[リソースの管理] ページに移動します。

    [リソースの管理] に移動

  2. プロジェクト リストで、削除するプロジェクトを選択し、[削除] をクリックします。
  3. ダイアログでプロジェクト ID を入力し、[シャットダウン] をクリックしてプロジェクトを削除します。

Cloud Functions の関数の削除

このチュートリアルでデプロイした Cloud Functions の関数を削除するには、次のコマンドを実行します。

Node.js

gcloud functions delete nodejs-slack-function --gen2 --region REGION 

Python

gcloud functions delete python-slack-function --gen2 --region REGION 

Go

gcloud functions delete go-slack-function --gen2 --region REGION 

Java

gcloud functions delete java-slack-function --gen2 --region REGION 

C#

gcloud functions delete csharp-slack-function --gen2 --region REGION 

Ruby

gcloud functions delete ruby-slack-function --gen2 --region REGION 

PHP

gcloud functions delete php-slack-function --gen2 --region REGION 

Google Cloud Console から Cloud Functions を削除することもできます。