328 lines
11 KiB
Go
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)
|
|
}
|