Files
gochat/internal/handler/webhook/facebook_webhook.go
T
2026-06-04 15:44:48 +08:00

321 lines
12 KiB
Go

package webhook
// FacebookWebhookHandler processes incoming Facebook/Instagram webhook HTTP requests via Gin.
//
// Reference: Chatwoot routes Facebook webhooks at:
// post '/webhooks/facebook/:page_id' => 'channels/facebook#process_message'
// FB webhook verification (GET) at the same URL
//
// The handler:
// 1. GET: Webhook verification — responds with hub.challenge when hub.mode=subscribe
// and hub.verify_token matches the stored token (FB requirement for initial setup)
// 2. POST: Incoming message/event processing — reads the JSON payload, validates
// the X-Hub-Signature-256 header, parses events, and delegates to the
// Facebook/Instagram provider pipeline
//
// Facebook webhook requirements:
// - GET verification: must echo hub.challenge back when hub.verify_token matches
// - POST events: must validate X-Hub-Signature-256 (SHA256 HMAC with app_secret)
// - Must respond within 20 seconds
// - Must return 200 OK even on processing errors (FB retries on non-200)
//
// Instagram DMs use the same webhook endpoint format (Meta Business Suite):
// - object="instagram" for IG events vs object="page" for FB Messenger
// - Same X-Hub-Signature-256 verification
// - Same hub.mode/hub.verify_token/hub.challenge verification flow
import (
"encoding/json"
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// FacebookWebhookHandler processes Facebook/Instagram webhook requests via Gin.
type FacebookWebhookHandler struct {
fbProvider *fbchannel.FacebookProvider
igProvider *fbchannel.InstagramProvider
webhookParser *fbchannel.WebhookParser
db *gorm.DB
}
// NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration.
func NewFacebookWebhookHandler(
fbProvider *fbchannel.FacebookProvider,
igProvider *fbchannel.InstagramProvider,
db *gorm.DB,
) *FacebookWebhookHandler {
return &FacebookWebhookHandler{
fbProvider: fbProvider,
igProvider: igProvider,
webhookParser: fbchannel.NewWebhookParser(),
db: db,
}
}
// HandleFacebookVerification handles the GET webhook verification request from Facebook.
// URL pattern: /webhooks/facebook/:inbox_id
// Method: GET
// Query params: hub.mode=subscribe, hub.verify_token=<token>, hub.challenge=<challenge>
//
// Reference: https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests
// Facebook sends a GET request with hub.mode=subscribe when verifying a new webhook subscription.
// The server must respond with the hub.challenge value if hub.verify_token matches.
func (h *FacebookWebhookHandler) HandleFacebookVerification(c *gin.Context) {
inboxIDStr := c.Param("inbox_id")
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
if err != nil {
applogger.L().Warnf("Facebook webhook verification: invalid inbox_id %s", inboxIDStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"})
return
}
// Extract verification parameters
queryParams := map[string]string{
"hub.mode": c.Query("hub.mode"),
"hub.verify_token": c.Query("hub.verify_token"),
"hub.challenge": c.Query("hub.challenge"),
}
// Look up the inbox to find the verify token stored in ChannelConfig
inbox, err := h.lookupInbox(uint(inboxID))
if err != nil {
applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for id %d: %v", inboxID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
return
}
// Resolve the verify token from channel config
verifyToken := h.resolveVerifyToken(inbox)
if verifyToken == "" {
applogger.L().Warnf("Facebook webhook verification: no verify_token for inbox %d", inboxID)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "verify token not configured"})
return
}
// Delegate to the channel-level verification logic
challenge, ok := fbchannel.VerifyWebhookChallenge(queryParams, verifyToken)
if !ok {
applogger.L().Warnf("Facebook webhook verification: token mismatch for inbox %d", inboxID)
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
return
}
applogger.L().Infof("Facebook webhook verified for inbox %d", inboxID)
// Facebook expects the challenge value as the plain response body
c.String(http.StatusOK, challenge)
}
// HandleFacebookWebhook processes an incoming Facebook/Instagram webhook POST request.
// URL pattern: /webhooks/facebook/:inbox_id
// Method: POST
// Content-Type: application/json
// Header: X-Hub-Signature-256 for signature verification
//
// Reference: Chatwoot processes Facebook webhook events via IncomingMessageService
// FB sends webhook events as JSON POST to the configured callback URL
//
// Flow:
// 1. Read raw body from request
// 2. Validate X-Hub-Signature-256 HMAC signature
// 3. Parse the payload to determine object type (page=FB, instagram=IG)
// 4. Delegate to appropriate provider's ProcessIncoming method
// 5. Return 200 OK immediately (Facebook retries on non-200)
func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
inboxIDStr := c.Param("inbox_id")
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
if err != nil {
applogger.L().Warnf("Facebook webhook: invalid inbox_id %s", inboxIDStr)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
// Read request body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
applogger.L().Errorf("Facebook webhook: failed to read body for inbox %d: %v", inboxID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
defer c.Request.Body.Close()
// Validate webhook signature (X-Hub-Signature-256)
signatureHeader := c.GetHeader("X-Hub-Signature-256")
inbox, err := h.lookupInbox(uint(inboxID))
if err != nil {
applogger.L().Warnf("Facebook webhook: inbox lookup failed for id %d: %v", inboxID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
// Resolve app_secret from channel config for signature validation
appSecret := h.resolveAppSecret(inbox)
if appSecret != "" && signatureHeader != "" {
if !fbchannel.ValidateWebhookSignature(appSecret, signatureHeader, body) {
applogger.L().Warnf("Facebook webhook: signature validation failed for inbox %d", inboxID)
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
return
}
}
// Parse the payload to determine if it's Facebook or Instagram
// object="page" → Facebook Messenger, object="instagram" → Instagram DMs
parsedEvents, err := h.webhookParser.ParseWebhookPayload(body)
if err != nil {
applogger.L().Errorf("Facebook webhook: failed to parse payload for inbox %d: %v", inboxID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inboxID)
// Process each parsed event
for _, event := range parsedEvents {
// Skip echo messages (sent by the page/business itself)
if fbchannel.IsEchoMessage(event) {
mid := ""
if event.Message != nil {
mid = event.Message.Mid
}
applogger.L().Debug("Facebook webhook: skipping echo message",
"mid", mid,
"inbox_id", inboxID,
)
continue
}
// Skip delivery/read receipts — no actionable message content
if fbchannel.IsDeliveryOrReadReceipt(event) {
applogger.L().Debug("Facebook webhook: skipping delivery/read receipt",
"event_type", event.EventType,
"inbox_id", inboxID,
)
continue
}
// Skip thread control events (handled by TakeThreadControl in listener)
if fbchannel.IsThreadControlEvent(event) {
applogger.L().Debug("Facebook webhook: skipping thread control event",
"event_type", event.EventType,
"inbox_id", inboxID,
)
continue
}
// Handle Instagram comment events separately from DM messages
// Comment events use the Changes array (field="comments") and require
// ProcessCommentIncoming to transform them into IncomingMessage objects.
if fbchannel.IsIGCommentEvent(event) {
applogger.L().Info("Facebook webhook: processing Instagram comment event",
"event_type", event.EventType,
"inbox_id", inboxID,
)
commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType)
if err != nil {
applogger.L().Errorf("Facebook webhook: IG comment processing failed: %v", err)
continue
}
if commentMsg != nil {
applogger.L().Infof("Facebook webhook: IG comment processed (inbox_id=%d, source_id=%s, type=%s)",
commentMsg.InboxID, commentMsg.SourceID, event.EventType)
// TODO: Push to message broker/dispatcher for persistence + notification
}
continue
}
// Only process events that should create messages
if !fbchannel.ShouldCreateMessage(event) {
applogger.L().Debug("Facebook webhook: skipping non-message event",
"event_type", event.EventType,
"inbox_id", inboxID,
)
continue
}
// Delegate to the appropriate provider based on object type
var incomingMsg *channel.IncomingMessage
switch event.Object {
case "page":
// Facebook Messenger
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelFacebook)
if err != nil {
applogger.L().Errorf("Facebook webhook: extract message failed (FB): %v", err)
continue
}
case "instagram":
// Instagram DMs
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram)
if err != nil {
applogger.L().Errorf("Facebook webhook: extract message failed (IG): %v", err)
continue
}
default:
applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inboxID)
continue
}
if incomingMsg != nil {
applogger.L().Infof("Facebook webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)",
incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
// TODO: Push to message broker/dispatcher for persistence + notification
// Reference: Chatwoot pushes to IncomingMessageService → Conversation + Message creation
}
}
// Always return 200 OK to Facebook — it retries if not 200
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ===========================
// Helper methods
// ===========================
// lookupInbox finds the Inbox by ID via GORM.
func (h *FacebookWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
var inbox model.Inbox
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
return nil, err
}
return &inbox, nil
}
// resolveVerifyToken extracts the webhook verify token from the inbox ChannelConfig JSON.
func (h *FacebookWebhookHandler) resolveVerifyToken(inbox *model.Inbox) string {
config := h.parseChannelConfig(inbox)
if token, ok := config["webhook_verify_token"].(string); ok {
return token
}
return ""
}
// resolveAppSecret extracts the Facebook app secret from the inbox ChannelConfig JSON.
func (h *FacebookWebhookHandler) resolveAppSecret(inbox *model.Inbox) string {
config := h.parseChannelConfig(inbox)
if secret, ok := config["app_secret"].(string); ok {
return secret
}
return ""
}
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
func (h *FacebookWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
if inbox.ChannelConfig == "" {
return map[string]interface{}{}
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
applogger.L().Warnf("Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
return map[string]interface{}{}
}
return config
}