functions/functionsv2/slack/search.go (116 lines of code) (raw):
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// [START functions_slack_search]
// 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"
"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 := io.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
}
}
// [END functions_slack_search]
// [START functions_slack_request]
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)
}
// [END functions_slack_request]
// [START functions_verify_webhook]
// 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): %w", 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): %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)
}
// 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
}
// [END functions_verify_webhook]