Files
gochat/internal/channel/whatsapp/webhook_handler.go
T
2026-06-04 15:44:48 +08:00

239 lines
8.5 KiB
Go

package whatsapp
// WebhookHandler processes incoming WhatsApp webhook HTTP requests.
// Reference: Chatwoot's webhook handling for WhatsApp:
// - app/controllers/api/v1/accounts/channels/whatsapp_channels_controller.rb (CRUD + webhook)
// - app/services/whatsapp/incoming_message_service.rb (message parsing)
// - WhatsApp Cloud API webhook verification: GET with hub.mode=subscribe, hub.verify_token, hub.challenge
// - WhatsApp Cloud API webhook events: POST with object=whatsapp_business_account, entry[].changes[]
//
// The handler:
// 1. GET verification: Meta Cloud API sends hub.mode=subscribe, hub.verify_token=<token>,
// hub.challenge=<challenge> — respond with hub.challenge if verify_token matches
// 2. POST processing: Receives JSON payload with WAWebhookEvent structure,
// delegates to WhatsAppProvider.ProcessIncoming for pipeline processing
// 3. Return 200 OK immediately (WhatsApp expects fast response)
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// WebhookHandler processes WhatsApp webhook requests.
type WebhookHandler struct {
provider *WhatsAppProvider
}
// NewWebhookHandler creates a WhatsApp webhook handler.
func NewWebhookHandler(provider *WhatsAppProvider) *WebhookHandler {
return &WebhookHandler{
provider: provider,
}
}
// HandleVerification handles GET requests for WhatsApp webhook verification.
// Meta Cloud API sends: hub.mode=subscribe, hub.verify_token=<token>, hub.challenge=<string>
// We respond with hub.challenge if verify_token matches the channel's WebhookVerifyToken.
//
// Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#verify-webhook
func (h *WebhookHandler) HandleVerification(c *gin.Context) {
mode := c.Query("hub.mode")
token := c.Query("hub.verify_token")
challenge := c.Query("hub.challenge")
if mode != "subscribe" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hub.mode"})
return
}
// Look up the WhatsApp channel by verify token
waChannel, err := h.lookupByVerifyToken(token)
if err != nil {
applogger.L().Warn("WhatsApp webhook verification: token lookup failed",
"token", token,
"error", err,
)
c.JSON(http.StatusForbidden, gin.H{"error": "Invalid verify token"})
return
}
applogger.L().Info("WhatsApp webhook verification successful",
"phone_number", waChannel.PhoneNumber,
)
// Echo back the challenge string
c.String(http.StatusOK, challenge)
}
// HandleWebhookEvent handles POST requests for WhatsApp webhook events.
// Receives JSON payload with WAWebhookEvent structure.
// Responds 200 OK immediately and processes via provider pipeline.
//
// Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks
func (h *WebhookHandler) HandleWebhookEvent(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
applogger.L().Error("WhatsApp webhook: failed to read request body", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
// Parse the webhook event to extract phone_number_id for inbox lookup
event := &WAWebhookEvent{}
if err := json.Unmarshal(body, event); err != nil {
applogger.L().Error("WhatsApp webhook: failed to parse payload", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON payload"})
return
}
// Extract phone_number_id from the webhook metadata to identify the inbox
phoneNumberID := extractPhoneNumberID(event)
if phoneNumberID == "" {
applogger.L().Warn("WhatsApp webhook: no phone_number_id in payload")
c.JSON(http.StatusOK, gin.H{"status": "received"})
return
}
// Look up the WhatsApp channel by phone_number_id, then find its inbox
inbox, err := h.resolveInbox(phoneNumberID)
if err != nil {
applogger.L().Warn("WhatsApp webhook: inbox resolution failed",
"phone_number_id", phoneNumberID,
"error", err,
)
c.JSON(http.StatusOK, gin.H{"status": "received"})
return
}
// Get WhatsApp channel config for provider-specific verification
waChannel, _ := h.getChannelConfig(inbox)
if waChannel != nil && waChannel.Provider == "whatsapp_cloud" {
if err := h.verifyCloudSignature(c, body, waChannel.AccessToken); err != nil {
applogger.L().Warn("WhatsApp webhook: signature verification failed", "error", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Signature verification failed"})
return
}
}
// Delegate to provider's ProcessIncoming for full pipeline processing
if h.provider != nil {
_, processErr := h.provider.ProcessIncoming(c.Request.Context(), inbox, body)
if processErr != nil {
applogger.L().Error("WhatsApp webhook: message processing failed", "error", processErr)
}
}
// Always return 200 OK — WhatsApp requires fast response
c.JSON(http.StatusOK, gin.H{"status": "received"})
}
// HandleWebhookVerification is an alias for HandleVerification for routing convenience.
func (h *WebhookHandler) HandleWebhookVerification(c *gin.Context) {
h.HandleVerification(c)
}
// HandleWebhook is an alias for HandleWebhookEvent for routing convenience.
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
h.HandleWebhookEvent(c)
}
// === Internal Helpers ===
// extractPhoneNumberID extracts the phone_number_id from a webhook event.
func extractPhoneNumberID(event *WAWebhookEvent) string {
for _, entry := range event.Entry {
for _, change := range entry.Changes {
if change.Value.Metadata.PhoneNumberID != "" {
return change.Value.Metadata.PhoneNumberID
}
}
}
return ""
}
// verifyCloudSignature verifies the HMAC-SHA256 signature for Cloud API webhooks.
// Meta sends X-Hub-Signature-256 header with signature = hmac(appSecret, body).
// NOTE: The app secret should come from environment config, not the channel model.
// For now, we use the access token as a placeholder (signature verification will be
// properly implemented when AppSecret is added to the model or env config).
func (h *WebhookHandler) verifyCloudSignature(c *gin.Context, body []byte, appSecret string) error {
signature := c.GetHeader("X-Hub-Signature-256")
if signature == "" {
return fmt.Errorf("missing X-Hub-Signature-256 header")
}
if appSecret == "" {
applogger.L().Warn("WhatsApp app secret not configured, skipping webhook signature verification")
return nil
}
mac := hmac.New(sha256.New, []byte(appSecret))
mac.Write(body)
expectedSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return fmt.Errorf("webhook signature verification failed")
}
return nil
}
// lookupByVerifyToken finds the WhatsApp channel config by webhook verify token.
func (h *WebhookHandler) lookupByVerifyToken(token string) (*channelmodel.ChannelWhatsApp, error) {
if h.provider == nil || h.provider.repository == nil {
return nil, fmt.Errorf("provider or repository not configured")
}
// Iterate WhatsApp channels to find one matching the verify token
// TODO: Add a dedicated GetByWebhookVerifyToken query for efficiency
channels, err := h.provider.repository.FindByAccountID(nil, 0)
if err != nil {
return nil, fmt.Errorf("channel lookup failed: %w", err)
}
for i := range channels {
if channels[i].WebhookVerifyToken == token {
return &channels[i], nil
}
}
return nil, fmt.Errorf("no WhatsApp channel found with verify token: %s", token)
}
// resolveInbox finds the inbox for a given phone_number_id.
// Steps: GetByPhoneNumberID → find ChannelWhatsApp → use InboxID to find Inbox.
func (h *WebhookHandler) resolveInbox(phoneNumberID string) (*model.Inbox, error) {
if h.provider == nil || h.provider.repository == nil {
return nil, fmt.Errorf("provider or repository not configured")
}
// Step 1: Find the WhatsApp channel by phone_number_id
waChannel, err := h.provider.repository.GetByPhoneNumberID(nil, phoneNumberID)
if err != nil {
return nil, fmt.Errorf("WhatsApp channel lookup by phone_number_id failed: %w", err)
}
// Step 2: Find the inbox using InboxRepository
inboxRepo := &InboxRepository{db: h.provider.repository.db}
return inboxRepo.FindByID(nil, waChannel.InboxID)
}
// getChannelConfig retrieves the ChannelWhatsApp configuration for the given inbox.
func (h *WebhookHandler) getChannelConfig(inbox *model.Inbox) (*channelmodel.ChannelWhatsApp, error) {
if h.provider == nil || h.provider.repository == nil {
return nil, fmt.Errorf("provider or repository not configured")
}
return h.provider.repository.GetByInboxID(nil, inbox.ID)
}