如何在 Golang 中开发 Facebook Messenger Bot

Facebook 是当今全球最大的社交网络。它每月有超过 29 亿活跃用户。这就是为什么许多公司开始将 Facebook Messenger 用于商业目的的原因。

最近,我们开发了 Facebook Messenger 机器人作为 2FA(双因素身份验证)的另一种方式,它显着降低了 SMS 成本。这就是为什么我想告诉你如何开发 Facebook Messenger 机器人。

建筑学

让我们看看整个架构是什么样子的。

因此,用户向 Facebook Messenger 机器人发送消息。在消息中,Facebook 将 webhook 发送到我们的服务器(Golang 应用程序)。服务器处理消息并通过 Facebook Messenger API 响应用户。

Golang Webhook 处理程序

我制作了一个示例存储库来展示如何快速开发 Facebook Messenger 机器人。

在从 Facebook 端设置 webhook 之前,我们需要实现将处理请求的服务器应用程序:

package main

import (
	"go-facebook-bot/pkg/fb"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/webhook", fb.HandleMessenger)

	port := ":8099" // port to listen to
	log.Fatal(http.ListenAndServe(port, nil))
}
// HandleMessenger handles all incoming webhooks from Facebook Messenger.
func HandleMessenger(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodGet {
		HandleVerification(w, r)
		return
	}

	HandleWebHook(w, r)
}

Facebook messenger 可以发送两种类型的 webhook:GET 或 POST。这取决于 webhook 的用途:

  • GET 请求意味着验证。当您在机器人的设置中添加新的 webhook URL 时,Facebook 会发送它们。这是一种验证您的服务器是否正常工作的方法。在该方法中,您必须比较是否hub.verify_token与您的验证令牌匹配(我们将在 Facebook 设置部分中获得它):
// HandleVerification handles the verification request from Facebook.
func HandleVerification(w http.ResponseWriter, r *http.Request) {
	if verifyToken != r.URL.Query().Get("hub.verify_token") {
		w.WriteHeader(http.StatusUnauthorized)
		w.Write(nil)
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(r.URL.Query().Get("hub.challenge")))
}
  • 当用户向您的机器人发送消息时触发 POST 请求:
// HandleWebHook handles a webhook incoming from Facebook.
func HandleWebHook(w http.ResponseWriter, r *http.Request) {
	err := Authorize(r)
	if err != nil {
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte("unauthorized"))
		log.Println("authorize", err)
		return
	}

	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("bad request"))
		log.Println("read webhook body", err)
		return
	}

	wr := WebHookRequest{}
	err = json.Unmarshal(body, &wr)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("bad request"))
		log.Println("unmarshal request", err)
		return
	}

	err = handleWebHookRequest(wr)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		w.Write([]byte("internal"))
		log.Println("handle webhook request", err)
		return
	}

	// Facebook waits for the constant message to get that everything is OK
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("EVENT_RECEIVED"))
}

首先,我们需要授权 webhook,因为潜在的攻击者可以使用我们的服务器:

package fb

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"fmt"
	"io/ioutil"
	"net/http"
	"strings"
)

const (
	headerNameXSign = "X-Hub-Signature"
	signaturePrefix = "sha1="
)

// errors
var (
	errNoXSignHeader      = errors.New("there is no x-sign header")
	errInvalidXSignHeader = errors.New("invalid x-sign header")
)

// Authorize authorizes web hooks from FB
// https://developers.facebook.com/docs/graph-api/webhooks/getting-started/#validate-payloads
func Authorize(r *http.Request) error {
	signature := r.Header.Get(headerNameXSign)
	if !strings.HasPrefix(signature, signaturePrefix) {
		return errNoXSignHeader
	}

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

	// We read the request body and now it's empty. We have to rewrite it for further reads.
	r.Body.Close() //nolint:errcheck
	r.Body = ioutil.NopCloser(bytes.NewBuffer(body))

	validSignature, err := isValidSignature(signature, body)
	if err != nil {
		return fmt.Errorf("is valid signature: %w", err)
	}
	if !validSignature {
		return errInvalidXSignHeader
	}

	return nil
}

func signBody(body []byte) []byte {
	h := hmac.New(sha1.New, []byte(appSecret))
	h.Reset()
	h.Write(body)

	return h.Sum(nil)
}

func isValidSignature(signature string, body []byte) (bool, error) {
	actualSign, err := hex.DecodeString(signature[len(signaturePrefix):])
	if err != nil {
		return false, fmt.Errorf("decode string: %w", err)
	}

	return hmac.Equal(signBody(body), actualSign), nil
}

当我们对 webhook 来自 Facebook 感到满意时,我们可以处理它。在我的示例中,机器人使用以下算法获取 webhook:如果用户发送带有 text 的消息hello,它会以word. 否则,机器人会询问用户:What can I do for you?.

func handleWebHookRequest(r WebHookRequest) error {
	if r.Object != "page" {
		return errUnknownWebHookObject
	}

	for _, we := range r.Entry {
		err := handleWebHookRequestEntry(we)
		if err != nil {
			return fmt.Errorf("handle webhook request entry: %w", err)
		}
	}

	return nil
}

func handleWebHookRequestEntry(we WebHookRequestEntry) error {
    // Facebook claims that the arr always contains a single item but we don't trust them :)
	if len(we.Messaging) == 0 { 
		return errNoMessageEntry
	}

	em := we.Messaging[0]

	// message action
	if em.Message != nil {
		err := handleMessage(em.Sender.ID, em.Message.Text)
		if err != nil {
			return fmt.Errorf("handle message: %w", err)
		}
	}

	return nil
}

func handleMessage(recipientID, msgText string) error {
	msgText = strings.TrimSpace(msgText)

	var responseText string
	switch msgText {
	case "hello":
		responseText = "world"
	// @TODO your custom cases
	default:
		responseText = "What can I do for you?"
	}

	return Respond(context.TODO(), recipientID, responseText)
}

最后,还有一种Respond通过 Facebook API 向用户发送消息的方法:

package fb

import (
	"context"
	"encoding/json"
	"fmt"
	"github.com/valyala/fasthttp"
	"time"
)

const (
	uriSendMessage = "https://graph.facebook.com/v12.0/me/messages"

	defaultRequestTimeout = 10 * time.Second
)

// https://developers.facebook.com/docs/messenger-platform/send-messages/#messaging_types
const (
	messageTypeResponse = "RESPONSE"
)

var (
	client = fasthttp.Client{}
)

// Respond responds to a user in FB messenger. This includes promotional and non-promotional messages sent inside the 24-hour standard messaging window.
// For example, use this tag to respond if a person asks for a reservation confirmation or an status update.
func Respond(ctx context.Context, recipientID, msgText string) error {
	return callAPI(ctx, uriSendMessage, SendMessageRequest{
		MessagingType: messageTypeResponse,
		RecipientID: MessageRecipient{
			ID: recipientID,
		},
		Message: Message{
			Text: msgText,
		},
	})
}

func callAPI(ctx context.Context, reqURI string, reqBody interface{}) error {
	req := fasthttp.AcquireRequest()
	defer fasthttp.ReleaseRequest(req)

	req.SetRequestURI(fmt.Sprintf("%s?access_token=%s", reqURI, accessToken))
	req.Header.SetMethod(fasthttp.MethodPost)
	req.Header.Add("Content-Type", "application/json")

	body, err := json.Marshal(&reqBody)
	if err != nil {
		return fmt.Errorf("marshal: %w", err)
	}
	req.SetBody(body)

	res := fasthttp.AcquireResponse()
	defer fasthttp.ReleaseResponse(res)

	dl, ok := ctx.Deadline()
	if !ok {
		dl = time.Now().Add(defaultRequestTimeout)
	}

	err = client.DoDeadline(req, res, dl)
	if err != nil {
		return fmt.Errorf("do deadline: %w", err)
	}

	resp := APIResponse{}
	err = json.Unmarshal(res.Body(), &resp)
	if err != nil {
		return fmt.Errorf("unmarshal response: %w", err)
	}
	if resp.Error != nil {
		return fmt.Errorf("response error: %s", resp.Error.Error())
	}
	if res.StatusCode() != fasthttp.StatusOK {
		return fmt.Errorf("unexpected rsponse status %d", res.StatusCode())
	}

	return nil
}

脸书配置

我们开发了一个可以处理 webhook 的机器人,但我们仍然需要实时运行它。关键是 Facebook 会检查我们的服务器是否可以处理请求。

幸运的是,有一个工具可以帮助我们将本地端口从我们的机器暴露到外部互联网。是恩格罗克。如果您还没有使用它,请立即开始,因为它在机器人开发中必不可少。

启动 ngrok:

ngrok http 8099

运行 Go 服务器:

go run main.go

现在您已准备好设置 Facebook 内容。幸运的是,他们有很好的文档说明如何为您的机器人准备一切。简而言之,您需要:

  • 创建一个新的 Facebook 页面,该页面将用作您的 Messenger 机器人的身份
  • 注册 Facebook 开发者帐户
  • 使用上一项在开发者帐户中创建一个新的 Facebook 应用
  • 在您的 Facebook 应用程序中设置 WebHook URL。它会是这样的https://5e5a-188-168-215-46.ngrok.io/v1/webhook(用你自己的替换 ngrok 部分)

检查一切正常

现在让我们检查一下我们的机器人是否正确处理了 Facebook Messenger webhook:

它看起来像它的作品?

结论

起初,与 Facebook Messenger 的集成可能看起来很可怕:复杂的文档和架构。但是当您开始开发时,您会发现它非常简单。在 Go 等优秀编程语言的支持下,您可以开发出可靠且速度极快的机器人。