Tutorial Slack - Perintah Slash (generasi ke-1)


Tutorial ini menunjukkan penggunaan fungsi Cloud Run untuk menerapkan Perintah Slash Slack yang menelusuri Google Knowledge Graph API.

Tujuan

  • Membuat Perintah Slash di Slack.
  • Tulis dan deploy fungsi HTTP Cloud Run.
  • Menelusuri Google Knowledge Graph API menggunakan Perintah Slash.

Biaya

Dalam dokumen ini, Anda menggunakan komponen Google Cloud yang dapat ditagih berikut:

  • Cloud Run functions

Untuk membuat perkiraan biaya berdasarkan proyeksi penggunaan Anda, gunakan kalkulator harga. Pengguna baru Google Cloud mungkin memenuhi syarat untuk mendapatkan uji coba gratis.

Sebelum memulai

  1. Login ke akun Google Cloud Anda. Jika Anda baru menggunakan Google Cloud, buat akun untuk mengevaluasi performa produk kami dalam skenario dunia nyata. Pelanggan baru juga mendapatkan kredit gratis senilai $300 untuk menjalankan, menguji, dan men-deploy workload.
  2. Di konsol Google Cloud, pada halaman pemilih project, pilih atau buat project Google Cloud.

    Buka pemilih project

  3. Pastikan penagihan telah diaktifkan untuk project Google Cloud Anda.

  4. Aktifkan API Cloud Functions, Cloud Build, and Google Knowledge Graph Search.

    Mengaktifkan API

  5. Menginstal Google Cloud CLI.
  6. Untuk initialize gcloud CLI, jalankan perintah berikut:

    gcloud init
  7. Di konsol Google Cloud, pada halaman pemilih project, pilih atau buat project Google Cloud.

    Buka pemilih project

  8. Pastikan penagihan telah diaktifkan untuk project Google Cloud Anda.

  9. Aktifkan API Cloud Functions, Cloud Build, and Google Knowledge Graph Search.

    Mengaktifkan API

  10. Menginstal Google Cloud CLI.
  11. Untuk initialize gcloud CLI, jalankan perintah berikut:

    gcloud init
  12. Jika Anda sudah menginstal gcloud CLI, update dengan menjalankan perintah berikut:

    gcloud components update
  13. Siapkan lingkungan pengembangan Anda.

Memvisualisasikan aliran data

Alur data di aplikasi tutorial Slash Command di Slack melibatkan beberapa langkah:

  1. Pengguna menjalankan Perintah Slack /kg <search_query> di saluran Slack.
  2. Slack mengirimkan payload perintah ke endpoint pemicu fungsi.
  3. Fungsi tersebut mengirimkan permintaan beserta kueri penelusuran pengguna ke Knowledge Graph API.
  4. API Pustaka Pengetahuan merespons dengan hasil yang cocok.
  5. Fungsi ini memformat respons menjadi pesan Slack.
  6. Fungsi ini mengirimkan pesan kembali ke Slack.
  7. Pengguna akan melihat respons terformat di saluran Slack.

Anda dapat memvisualisasikan langkah-langkahnya:

Membuat kunci API Pustaka Pengetahuan

Di halaman Kredensial Google Cloud Console, klik tombol Buat kredensial dan pilih Kunci API. Ingat kunci ini, karena Anda akan menggunakannya untuk mengakses API Pustaka Pengetahuan di bagian berikutnya.

Menyiapkan fungsi

  1. Clone repositori aplikasi contoh ke komputer lokal Anda:

    Node.js

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

    Python

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

    Go

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

    Java

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

    Atau, Anda dapat mendownload contoh sebagai file ZIP dan mengekstraknya.

    C#

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

    Ruby

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

    PHP

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

    Atau, Anda dapat mendownload contoh dalam file ZIP dan mengekstraknya.

  2. Ubah ke direktori yang berisi contoh fungsi Cloud Run kode:

    Node.js

    cd nodejs-docs-samples/functions/slack/

    Python

    cd python-docs-samples/functions/slack/

    Go

    cd golang-samples/functions/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/

Men-deploy fungsi

Untuk menerapkan fungsi yang dijalankan saat Anda (atau Slack) membuat permintaan POST HTTP ke endpoint fungsi, jalankan perintah berikut di direktori yang berisi kode contoh (atau file pom.xml untuk Java):

Ganti YOUR_SLACK_SIGNING_SECRET dengan secret penandatanganan yang disediakan oleh Slack di halaman Informasi dasar pada konfigurasi aplikasi Anda, dan YOUR_KG_API_KEY dengan kunci API Pustaka Pengetahuan yang Anda buat sebelumnya.

Node.js

gcloud functions deploy kgSearch \
--runtime nodejs20 \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime dari versi Node.js yang didukung untuk menjalankan fungsi Anda.

Python

gcloud functions deploy kg_search \
--runtime python312 \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime versi Python yang didukung untuk menjalankan fungsi Anda.

Go

gcloud functions deploy KGSearch \
--runtime go121 \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime versi Go yang didukung untuk menjalankan fungsi Anda.

Java

gcloud functions deploy java-slack-function \
--entry-point functions.SlackSlashCommand \
--runtime java17 \
--memory 512MB \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime versi Java yang didukung guna menjalankan fungsi Anda.

C#

gcloud functions deploy csharp-slack-function \
--entry-point SlackKnowledgeGraphSearch.Function \
--runtime dotnet6 \
--trigger-http \
--set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
--allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime versi .NET yang didukung guna menjalankan fungsi Anda.

Ruby

gcloud functions deploy kg_search --runtime ruby32 \
-
-trigger-http \
-
-set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
-
-allow-unauthenticated

Gunakan flag --runtime untuk menentukan ID runtime versi Ruby yang didukung untuk menjalankan fungsi Anda.

PHP

 gcloud functions deploy searchKnowledgeGraph --runtime php82 \
-
-trigger-http \
-
-set-env-vars "SLACK_SECRET=YOUR_SLACK_SIGNING_SECRET,KG_API_KEY=YOUR_KG_API_KEY" \
-
-allow-unauthenticated

Gunakan tanda --runtime untuk menentukan ID runtime versi PHP yang didukung untuk menjalankan fungsi Anda.

Mengonfigurasi aplikasi

Setelah fungsi di-deploy, Anda perlu membuat Perintah Garis miring Slack yang mengirimkan kueri ke fungsi Anda setiap kali perintah dipicu:

  1. Buat Aplikasi Slack untuk menghosting Perintah Slash di Slack. Kaitkan dengan tim Slack tempat Anda memiliki izin untuk menginstal integrasi.

  2. Buka Perintah Slash, lalu klik tombol Buat perintah baru.

  3. Masukkan /kg sebagai nama perintah.

  4. Masukkan URL untuk perintah:

    Node.js

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/kgSearch

    Python

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/kg_search

    Go

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/KGSearch

    Java

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/java-slack-function

    C#

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/csharp-slack-function

    Ruby

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/kg_search

    PHP

    https://YOUR_REGION-YOUR_PROJECT_ID.cloudfunctions.net/searchKnowledgeGraph

    dengan YOUR_REGION adalah wilayah tempat di-deploy dan YOUR_PROJECT_ID adalah project ID Cloud Anda.

    Kedua nilai akan terlihat di terminal Anda setelah fungsi selesai di-deploy.

  5. Klik Simpan.

  6. Buka Informasi Dasar.

  7. Klik Instal aplikasi ke ruang kerja Anda dan ikuti petunjuk di layar guna mengaktifkan aplikasi untuk ruang kerja Anda.

    Perintah Slash di Slack Anda akan segera online.

Memahami kode

Mengimpor dependensi

Aplikasi harus mengimpor beberapa dependensi untuk berkomunikasi dengan layanan 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 GsonFactory(), 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;

Menerima webhook

Fungsi berikut dijalankan saat Anda (atau Slack) membuat permintaan POST HTTP ke endpoint fungsi:

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 recieves a query from
// a Slack command and responds with the KG API result.
package slack

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

type oldTimeStampError struct {
	s string
}

func (e *oldTimeStampError) Error() string {
	return e.s
}

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"`
}

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

	bodyBytes, err := ioutil.ReadAll(r.Body)
	if err != nil {
		log.Fatalf("Couldn't read request body: %v", err)
	}
	r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))

	if r.Method != "POST" {
		http.Error(w, "Only POST requests are accepted", 405)
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Couldn't parse form", 400)
		log.Fatalf("ParseForm: %v", err)
	}

	// Reset r.Body as ParseForm depletes it by reading the io.ReadCloser.
	r.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
	result, err := verifyWebHook(r, slackSecret)
	if err != nil {
		log.Fatalf("verifyWebhook: %v", err)
	}
	if !result {
		log.Fatalf("signatures did not match.")
	}

	if len(r.Form["text"]) == 0 {
		log.Fatalf("empty text in form")
	}
	kgSearchResponse, err := makeSearchRequest(r.Form["text"][0])
	if err != nil {
		log.Fatalf("makeSearchRequest: %v", err)
	}
	w.Header().Set("Content-Type", "application/json")
	if err = json.NewEncoder(w).Encode(kgSearchResponse); err != nil {
		log.Fatalf("json.Marshal: %v", err)
	}
}

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;
    var cancellationToken = context.RequestAborted;

    // 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, cancellationToken);
    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, cancellationToken);
    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
    );
}

Fungsi berikut mengautentikasi permintaan masuk dengan memverifikasi header X-Slack-Signature yang disediakan oleh Slack:

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, 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): %w", timeStamp, err)
	}

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

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

	body, err := ioutil.ReadAll(r.Body)
	if err != nil {
		return false, fmt.Errorf("ioutil.ReadAll(%v): %w", r.Body, err)
	}

	// Reset the body so other calls won't fail.
	r.Body = ioutil.NopCloser(bytes.NewBuffer(body))

	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): %w", 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)
}

// Arbitrarily trusting 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;
}

Membuat Kueri API Pustaka Pengetahuan

Fungsi berikut mengirim permintaan dengan kueri penelusuran pengguna ke API Pustaka Pengetahuan:

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, fmt.Errorf("Do: %w", 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, CancellationToken cancellationToken)
{
    _logger.LogInformation("Performing Knowledge Graph search for '{query}'", query);
    var request = _kgService.Entities.Search();
    request.Limit = 1;
    request.Query = query;
    return await request.ExecuteAsync(cancellationToken);
}

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;
}

Memformat pesan Slack

Terakhir, fungsi berikut memformat hasil Pustaka Pengetahuan menjadi pesan Slack berformat lengkap yang akan ditampilkan kepada pengguna:

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": f"Query: {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 (
	"fmt"

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

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
	}

	entity, ok := response.ItemListElement[0].(map[string]interface{})
	if !ok {
		return nil, fmt.Errorf("could not parse response entity")
	}
	result, ok := entity["result"].(map[string]interface{})
	if !ok {
		return nil, fmt.Errorf("error formatting response result")
	}

	attach := attachment{Color: "#3367d6"}
	if name, ok := result["name"].(string); ok {
		if description, ok := result["description"].(string); ok {
			attach.Title = fmt.Sprintf("%s: %s", name, description)
		} else {
			attach.Title = name
		}
	}
	if detailedDesc, ok := result["detailedDescription"].(map[string]interface{}); ok {
		if url, ok := detailedDesc["url"].(string); ok {
			attach.TitleLink = url
		}
		if article, ok := detailedDesc["articleBody"].(string); ok {
			attach.Text = article
		}
	}
	if image, ok := result["image"].(map[string]interface{}); ok {
		if imageURL, ok := image["contentUrl"].(string); ok {
			attach.ImageURL = imageURL
		}
	}

	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);
}

Waktu tunggu Slack API

Slack API mengharapkan fungsi Anda merespons dalam waktu 3 detik setelah menerima permintaan webhook.

Perintah dalam tutorial ini biasanya memerlukan waktu kurang dari 3 detik untuk merespons. Untuk perintah yang berjalan lebih lama, sebaiknya konfigurasikan fungsi untuk permintaan push (termasuk response_url) ke Pub/Sub topik yang bertindak sebagai antrean tugas.

Kemudian, Anda dapat membuat fungsi kedua yang dipicu oleh Pub/Sub yang memproses tugas tersebut dan mengirimkan hasilnya kembali ke response_url Slack.

Menggunakan perintah Slash

  1. Ketik perintah ke saluran Slack Anda:

    /kg giraffe
  2. Perhatikan log untuk memastikan eksekusi telah selesai:

    gcloud functions logs read --limit 100

Pembersihan

Agar tidak dikenakan biaya pada akun Google Cloud Anda untuk resource yang digunakan dalam tutorial ini, hapus project yang berisi resource tersebut, atau simpan project dan hapus setiap resource-nya.

Menghapus project

Cara termudah untuk menghilangkan penagihan adalah dengan menghapus project yang Anda buat untuk tutorial.

Untuk menghapus project:

  1. Di konsol Google Cloud, buka halaman Manage resource.

    Buka Manage resource

  2. Pada daftar project, pilih project yang ingin Anda hapus, lalu klik Delete.
  3. Pada dialog, ketik project ID, lalu klik Shut down untuk menghapus project.

Menghapus fungsi

Untuk menghapus fungsi yang Anda deploy dalam tutorial ini, jalankan perintah berikut:

Node.js

gcloud functions delete kgSearch 

Python

gcloud functions delete kg_search 

Go

gcloud functions delete KGSearch 

Java

gcloud functions delete java-slack-function 

C#

gcloud functions delete csharp-slack-function 

Ruby

gcloud functions delete kg_search 

PHP

gcloud functions delete searchKnowledgeGraph 

Anda juga dapat menghapus fungsi Cloud Run dari Konsol Google Cloud.