Files
gochat/internal/channel/whatsapp/webhook_handler.go
T

328 lines
11 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 (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
applogger "github.com/gochat/gochat/pkg/logger"
)
// WebhookHandler processes WhatsApp webhook requests.
type WebhookHandler struct {
provider *WhatsAppProvider
persister IncomingPersister
}
type IncomingPersister interface {
PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (interface{}, error)
UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error
}
// NewWebhookHandler creates a WhatsApp webhook handler.
func NewWebhookHandler(provider *WhatsAppProvider) *WebhookHandler {
return &WebhookHandler{
provider: provider,
}
}
// SetIncomingPersister wires the durable message persistence boundary used after parsing.
func (h *WebhookHandler) SetIncomingPersister(persister IncomingPersister) {
h.persister = persister
}
// 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, resolveCloudAppSecret(waChannel)); 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 {
messages, processErr := h.provider.ProcessIncomingMessages(c.Request.Context(), inbox, body)
if processErr != nil {
applogger.L().Error("WhatsApp webhook: message processing failed", "error", processErr)
} else {
for _, incomingMsg := range messages {
if h.persister == nil || incomingMsg == nil {
continue
}
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Error("WhatsApp webhook: message persistence failed", "source_id", incomingMsg.SourceID, "error", persistErr)
}
}
}
}
if h.persister != nil {
h.persistStatusUpdates(c.Request.Context(), inbox, event)
}
// Always return 200 OK — WhatsApp requires fast response
c.JSON(http.StatusOK, gin.H{"status": "received"})
}
func (h *WebhookHandler) persistStatusUpdates(ctx context.Context, inbox *model.Inbox, event *WAWebhookEvent) {
for _, entry := range event.Entry {
for _, change := range entry.Changes {
for _, status := range change.Value.Statuses {
mapped, ok := mapWhatsAppMessageStatus(status.Status)
if !ok {
continue
}
var occurredAt *time.Time
if parsed := parseUnixTimestamp(status.Timestamp); parsed != nil {
occurredAt = parsed
}
if err := h.persister.UpdateMessageStatus(ctx, inbox, status.ID, mapped, occurredAt); err != nil {
applogger.L().Error("WhatsApp webhook: status persistence failed", "message_id", status.ID, "status", status.Status, "error", err)
}
}
}
}
}
func mapWhatsAppMessageStatus(status string) (model.MessageStatus, bool) {
switch status {
case "sent":
return model.MessageStatusSent, true
case "delivered":
return model.MessageStatusDelivered, true
case "read":
return model.MessageStatusRead, true
case "failed":
return model.MessageStatusFailed, true
default:
return "", false
}
}
func parseUnixTimestamp(value string) *time.Time {
if value == "" {
return nil
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return nil
}
t := time.Unix(parsed, 0)
return &t
}
// 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).
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 == "" {
return fmt.Errorf("WhatsApp app secret not configured")
}
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")
}
var channel channelmodel.ChannelWhatsApp
if err := h.provider.repository.db.
Where("webhook_verify_token = ?", token).
First(&channel).Error; err != nil {
return nil, fmt.Errorf("no WhatsApp channel found with verify token: %w", err)
}
return &channel, nil
}
func resolveCloudAppSecret(channel *channelmodel.ChannelWhatsApp) string {
if channel == nil {
return ""
}
if channel.ProviderConfig != "" {
var config map[string]interface{}
if err := json.Unmarshal([]byte(channel.ProviderConfig), &config); err == nil {
for _, key := range []string{"app_secret", "app_secret_key", "client_secret", "api_secret"} {
if secret, ok := config[key].(string); ok && secret != "" {
return secret
}
}
}
}
for _, key := range []string{"WHATSAPP_APP_SECRET", "FB_APP_SECRET"} {
if secret := os.Getenv(key); secret != "" {
return secret
}
}
return ""
}
// 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)
}