feat(webhook): implement instagram and shopify ingress

This commit is contained in:
2026-06-04 23:37:18 +08:00
parent 9e3f561bed
commit bc7da9e12e
8 changed files with 634 additions and 62 deletions
+2
View File
@@ -393,6 +393,7 @@ func Bootstrap(env string) (*App, error) {
// Create Facebook webhook handler (Gin HTTP handler for FB/IG webhook endpoints)
facebookWebhookHandler := webhook.NewFacebookWebhookHandler(fbProvider, igProvider, db)
shopifyWebhookHandler := webhook.NewShopifyWebhookHandler(db, "")
// Create Telegram webhook handler (Gin HTTP handler for Telegram webhook endpoint)
telWebhook := telegramchannel.NewWebhookHandler(tgProvider)
@@ -740,6 +741,7 @@ func Bootstrap(env string) (*App, error) {
TikTokWebhook: tiktokWebhookHandler,
LineWebhook: lineWebhookHandler,
TwilioWebhook: twilioWebhookHandler,
ShopifyWebhook: shopifyWebhookHandler,
Label: v1.NewLabelHandler(tagService, labelService),
Campaign: v1.NewCampaignHandler(campaignService),
AssignmentPolicy: v1.NewAssignmentPolicyHandler(assignmentPolicyService),
+31 -17
View File
@@ -22,11 +22,12 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/gin-gonic/gin"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
applogger "github.com/gochat/gochat/pkg/logger"
)
@@ -119,7 +120,7 @@ func (h *WebhookHandler) HandleWebhookEvent(c *gin.Context) {
// 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 {
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
@@ -164,9 +165,6 @@ func extractPhoneNumberID(event *WAWebhookEvent) string {
// 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 == "" {
@@ -174,8 +172,7 @@ func (h *WebhookHandler) verifyCloudSignature(c *gin.Context, body []byte, appSe
}
if appSecret == "" {
applogger.L().Warn("WhatsApp app secret not configured, skipping webhook signature verification")
return nil
return fmt.Errorf("WhatsApp app secret not configured")
}
mac := hmac.New(sha256.New, []byte(appSecret))
@@ -195,20 +192,37 @@ func (h *WebhookHandler) lookupByVerifyToken(token string) (*channelmodel.Channe
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)
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 ""
}
for i := range channels {
if channels[i].WebhookVerifyToken == token {
return &channels[i], nil
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
}
}
}
}
return nil, fmt.Errorf("no WhatsApp channel found with verify token: %s", token)
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.
@@ -236,4 +250,4 @@ func (h *WebhookHandler) getChannelConfig(inbox *model.Inbox) (*channelmodel.Cha
}
return h.provider.repository.GetByInboxID(nil, inbox.ID)
}
}
@@ -0,0 +1,86 @@
package whatsapp
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newWhatsAppWebhookTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Inbox{}, &channelmodel.ChannelWhatsApp{}); err != nil {
t.Fatalf("migrate whatsapp models: %v", err)
}
return db
}
func TestWhatsAppWebhookLookupByVerifyToken(t *testing.T) {
db := newWhatsAppWebhookTestDB(t)
inbox := model.Inbox{AccountID: 1, Name: "wa", ChannelType: "whatsapp", ChannelID: 1, Enabled: true}
if err := db.Create(&inbox).Error; err != nil {
t.Fatalf("create inbox: %v", err)
}
channel := channelmodel.ChannelWhatsApp{
AccountID: 1,
InboxID: inbox.ID,
PhoneNumber: "+15551234567",
PhoneNumberID: "phone-number-id",
AccessToken: "access-token",
Provider: "whatsapp_cloud",
WebhookVerifyToken: "verify-token",
ProviderConfig: `{"app_secret":"app-secret"}`,
BusinessAccountID: "waba-id",
WhatsAppAccountName: "WA",
}
if err := db.Create(&channel).Error; err != nil {
t.Fatalf("create whatsapp channel: %v", err)
}
repo := NewRepository(db)
provider := NewWhatsAppProvider(nil, repo, nil)
h := NewWebhookHandler(provider)
found, err := h.lookupByVerifyToken("verify-token")
if err != nil {
t.Fatalf("lookup verify token: %v", err)
}
if found.ID != channel.ID {
t.Fatalf("unexpected channel id: %d", found.ID)
}
if secret := resolveCloudAppSecret(found); secret != "app-secret" {
t.Fatalf("unexpected app secret: %q", secret)
}
}
func TestWhatsAppCloudSignatureUsesAppSecret(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"object":"whatsapp_business_account"}`)
mac := hmac.New(sha256.New, []byte("app-secret"))
mac.Write(body)
signature := "sha256=" + hex.EncodeToString(mac.Sum(nil))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/webhooks/whatsapp/phone", nil)
c.Request.Header.Set("X-Hub-Signature-256", signature)
h := NewWebhookHandler(nil)
if err := h.verifyCloudSignature(c, body, "app-secret"); err != nil {
t.Fatalf("verify signature: %v", err)
}
if err := h.verifyCloudSignature(c, body, "wrong-secret"); err == nil {
t.Fatal("expected wrong app secret to fail")
}
}
+206 -39
View File
@@ -28,23 +28,25 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
"github.com/gochat/gochat/internal/channel"
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
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
fbProvider *fbchannel.FacebookProvider
igProvider *fbchannel.InstagramProvider
webhookParser *fbchannel.WebhookParser
db *gorm.DB
db *gorm.DB
}
// NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration.
@@ -70,25 +72,20 @@ func NewFacebookWebhookHandler(
// 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
}
pageID := c.Param("page_id")
inboxID, _ := parseOptionalUintParam(c.Param("inbox_id"))
// 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"),
"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))
inbox, err := h.lookupFacebookInbox(pageID, inboxID)
if err != nil {
applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for id %d: %v", inboxID, err)
applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for page_id=%s inbox_id=%d: %v", pageID, inboxID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
return
}
@@ -96,7 +93,7 @@ func (h *FacebookWebhookHandler) HandleFacebookVerification(c *gin.Context) {
// 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)
applogger.L().Warnf("Facebook webhook verification: no verify_token for inbox %d", inbox.ID)
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "verify token not configured"})
return
}
@@ -104,12 +101,12 @@ func (h *FacebookWebhookHandler) HandleFacebookVerification(c *gin.Context) {
// 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)
applogger.L().Warnf("Facebook webhook verification: token mismatch for inbox %d", inbox.ID)
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
return
}
applogger.L().Infof("Facebook webhook verified for inbox %d", inboxID)
applogger.L().Infof("Facebook webhook verified for inbox %d", inbox.ID)
// Facebook expects the challenge value as the plain response body
c.String(http.StatusOK, challenge)
@@ -131,18 +128,13 @@ func (h *FacebookWebhookHandler) HandleFacebookVerification(c *gin.Context) {
// 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
}
pageID := c.Param("page_id")
inboxID, _ := parseOptionalUintParam(c.Param("inbox_id"))
// 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)
applogger.L().Errorf("Facebook webhook: failed to read body for page_id=%s inbox_id=%d: %v", pageID, inboxID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
@@ -150,9 +142,9 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
// Validate webhook signature (X-Hub-Signature-256)
signatureHeader := c.GetHeader("X-Hub-Signature-256")
inbox, err := h.lookupInbox(uint(inboxID))
inbox, err := h.lookupFacebookInbox(pageID, inboxID)
if err != nil {
applogger.L().Warnf("Facebook webhook: inbox lookup failed for id %d: %v", inboxID, err)
applogger.L().Warnf("Facebook webhook: inbox lookup failed for page_id=%s inbox_id=%d: %v", pageID, inboxID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
@@ -161,7 +153,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
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)
applogger.L().Warnf("Facebook webhook: signature validation failed for inbox %d", inbox.ID)
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
return
}
@@ -171,12 +163,12 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
// 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)
applogger.L().Errorf("Facebook webhook: failed to parse payload for inbox %d: %v", inbox.ID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inboxID)
applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inbox.ID)
// Process each parsed event
for _, event := range parsedEvents {
@@ -188,7 +180,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
}
applogger.L().Debug("Facebook webhook: skipping echo message",
"mid", mid,
"inbox_id", inboxID,
"inbox_id", inbox.ID,
)
continue
}
@@ -197,7 +189,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
if fbchannel.IsDeliveryOrReadReceipt(event) {
applogger.L().Debug("Facebook webhook: skipping delivery/read receipt",
"event_type", event.EventType,
"inbox_id", inboxID,
"inbox_id", inbox.ID,
)
continue
}
@@ -206,7 +198,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
if fbchannel.IsThreadControlEvent(event) {
applogger.L().Debug("Facebook webhook: skipping thread control event",
"event_type", event.EventType,
"inbox_id", inboxID,
"inbox_id", inbox.ID,
)
continue
}
@@ -217,7 +209,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
if fbchannel.IsIGCommentEvent(event) {
applogger.L().Info("Facebook webhook: processing Instagram comment event",
"event_type", event.EventType,
"inbox_id", inboxID,
"inbox_id", inbox.ID,
)
commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType)
@@ -237,7 +229,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
if !fbchannel.ShouldCreateMessage(event) {
applogger.L().Debug("Facebook webhook: skipping non-message event",
"event_type", event.EventType,
"inbox_id", inboxID,
"inbox_id", inbox.ID,
)
continue
}
@@ -260,7 +252,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
continue
}
default:
applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inboxID)
applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inbox.ID)
continue
}
@@ -276,6 +268,63 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// HandleInstagramVerification handles the Chatwoot-compatible no-param Instagram webhook verification route.
func (h *FacebookWebhookHandler) HandleInstagramVerification(c *gin.Context) {
token := c.Query("hub.verify_token")
challenge := c.Query("hub.challenge")
if h.validInstagramVerifyToken(token) {
applogger.L().Info("Instagram webhook verified")
c.String(http.StatusOK, challenge)
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "Error; wrong verify token"})
}
// HandleInstagramWebhook processes the Chatwoot-compatible no-param Instagram event route.
func (h *FacebookWebhookHandler) HandleInstagramWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
applogger.L().Errorf("Instagram webhook: failed to read body: %v", err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
defer c.Request.Body.Close()
parsedEvents, err := h.webhookParser.ParseWebhookPayload(body)
if err != nil {
applogger.L().Errorf("Instagram webhook: failed to parse payload: %v", err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
if len(parsedEvents) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
if parsedEvents[0].Object != "instagram" {
applogger.L().Warnf("Message is not received from the instagram webhook event: %s", parsedEvents[0].Object)
c.Status(http.StatusUnprocessableEntity)
return
}
if err := h.verifyInstagramSignature(c, body, parsedEvents); err != nil {
applogger.L().Warnf("Instagram webhook: signature verification failed: %v", err)
c.Status(http.StatusUnauthorized)
return
}
for _, event := range parsedEvents {
inbox, err := h.lookupInstagramInboxForEvent(event)
if err != nil {
applogger.L().Warnf("Instagram webhook: inbox lookup failed for page_id=%s sender=%s recipient=%s: %v", event.PageID, event.SenderID, event.RecipientID, err)
continue
}
h.processInstagramEvent(c, inbox, event)
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ===========================
// Helper methods
// ===========================
@@ -289,6 +338,124 @@ func (h *FacebookWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error)
return &inbox, nil
}
func (h *FacebookWebhookHandler) lookupFacebookInbox(pageID string, inboxID uint) (*model.Inbox, error) {
if inboxID != 0 {
return h.lookupInbox(inboxID)
}
return h.lookupInboxByFacebookPageID(pageID)
}
func (h *FacebookWebhookHandler) lookupInboxByFacebookPageID(pageID string) (*model.Inbox, error) {
var fb channelmodel.ChannelFacebook
if err := h.db.Where("page_id = ?", pageID).First(&fb).Error; err != nil {
return nil, err
}
return h.lookupInbox(fb.InboxID)
}
func (h *FacebookWebhookHandler) lookupInstagramInboxForEvent(event *fbchannel.ParsedWebhookEvent) (*model.Inbox, error) {
instagramID := event.RecipientID
if fbchannel.IsEchoMessage(event) && event.SenderID != "" {
instagramID = event.SenderID
}
if instagramID == "" {
instagramID = event.PageID
}
var ig channelmodel.ChannelInstagram
if err := h.db.Where("instagram_account_id = ? OR instagram_business_account_id = ?", instagramID, instagramID).First(&ig).Error; err == nil {
return h.lookupInbox(ig.InboxID)
}
var fb channelmodel.ChannelFacebook
if err := h.db.Where("instagram_business_account_id = ?", instagramID).First(&fb).Error; err != nil {
return nil, err
}
return h.lookupInbox(fb.InboxID)
}
func (h *FacebookWebhookHandler) validInstagramVerifyToken(token string) bool {
if token == "" {
return false
}
return token == os.Getenv("IG_VERIFY_TOKEN") || token == os.Getenv("INSTAGRAM_VERIFY_TOKEN")
}
func (h *FacebookWebhookHandler) verifyInstagramSignature(c *gin.Context, body []byte, events []*fbchannel.ParsedWebhookEvent) error {
signatureHeader := c.GetHeader("X-Hub-Signature-256")
if signatureHeader == "" {
return strconv.ErrSyntax
}
for _, secret := range h.instagramAppSecrets(events) {
if fbchannel.ValidateWebhookSignature(secret, signatureHeader, body) {
return nil
}
}
return strconv.ErrSyntax
}
func (h *FacebookWebhookHandler) instagramAppSecrets(events []*fbchannel.ParsedWebhookEvent) []string {
seen := map[string]bool{}
secrets := make([]string, 0, 4)
add := func(secret string) {
if secret != "" && !seen[secret] {
seen[secret] = true
secrets = append(secrets, secret)
}
}
add(os.Getenv("INSTAGRAM_APP_SECRET"))
add(os.Getenv("FB_APP_SECRET"))
for _, event := range events {
if inbox, err := h.lookupInstagramInboxForEvent(event); err == nil {
add(h.resolveAppSecret(inbox))
}
}
return secrets
}
func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *model.Inbox, event *fbchannel.ParsedWebhookEvent) {
if fbchannel.IsEchoMessage(event) || fbchannel.IsDeliveryOrReadReceipt(event) || fbchannel.IsThreadControlEvent(event) {
return
}
if fbchannel.IsIGCommentEvent(event) {
if h.igProvider == nil {
applogger.L().Warn("Instagram webhook: comment event ignored because Instagram provider is not configured")
return
}
commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType)
if err != nil {
applogger.L().Errorf("Instagram webhook: comment processing failed: %v", err)
}
if commentMsg != nil {
applogger.L().Infof("Instagram webhook: comment processed (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType)
}
return
}
if !fbchannel.ShouldCreateMessage(event) {
return
}
incomingMsg, err := fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram)
if err != nil {
applogger.L().Errorf("Instagram webhook: extract message failed: %v", err)
return
}
if incomingMsg != nil {
applogger.L().Infof("Instagram webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
}
}
func parseOptionalUintParam(value string) (uint, error) {
if value == "" {
return 0, nil
}
parsed, err := strconv.ParseUint(value, 10, 32)
return uint(parsed), err
}
// resolveVerifyToken extracts the webhook verify token from the inbox ChannelConfig JSON.
func (h *FacebookWebhookHandler) resolveVerifyToken(inbox *model.Inbox) string {
config := h.parseChannelConfig(inbox)
@@ -318,4 +485,4 @@ func (h *FacebookWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[stri
return map[string]interface{}{}
}
return config
}
}
+153
View File
@@ -0,0 +1,153 @@
package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
)
// ShopifyWebhookHandler handles Chatwoot-compatible Shopify public webhooks.
// Reference: reference/chatwoot/app/controllers/webhooks/shopify_controller.rb
type ShopifyWebhookHandler struct {
db *gorm.DB
clientSecret string
}
func NewShopifyWebhookHandler(db *gorm.DB, clientSecret string) *ShopifyWebhookHandler {
return &ShopifyWebhookHandler{db: db, clientSecret: clientSecret}
}
func (h *ShopifyWebhookHandler) HandleShopifyWebhook(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
}
defer c.Request.Body.Close()
if err := h.verifyHMAC(c.GetHeader("X-Shopify-Hmac-SHA256"), body); err != nil {
applogger.L().Warnf("Shopify webhook: HMAC verification failed: %v", err)
c.Status(http.StatusUnauthorized)
return
}
var payload map[string]interface{}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON payload"})
return
}
} else {
payload = map[string]interface{}{}
}
topic := c.GetHeader("X-Shopify-Topic")
payload["_shopify_topic"] = topic
shopDomain := c.GetHeader("X-Shopify-Shop-Domain")
if shopDomain == "" {
shopDomain, _ = payload["shop_domain"].(string)
}
if topic == "shop/redact" {
if shopDomain != "" {
if err := h.deleteShopifyHooksByDomain(c.Request.Context(), shopDomain); err != nil {
applogger.L().Warnf("Shopify webhook: shop/redact cleanup failed for %s: %v", shopDomain, err)
}
}
c.Status(http.StatusOK)
return
}
if shopDomain != "" {
if hook, err := h.findShopifyHookByDomain(c.Request.Context(), shopDomain); err == nil && hook != nil {
processor := service.NewShopifyEventProcessor(nil, nil)
if err := processor.ProcessEvent(c.Request.Context(), hook, payload); err != nil {
applogger.L().Warnf("Shopify webhook: event processing failed for hook %d: %v", hook.ID, err)
}
}
}
c.Status(http.StatusOK)
}
func (h *ShopifyWebhookHandler) verifyHMAC(signature string, body []byte) error {
secret := h.clientSecret
if secret == "" {
secret = os.Getenv("SHOPIFY_CLIENT_SECRET")
}
if secret == "" {
return fmt.Errorf("SHOPIFY_CLIENT_SECRET is not configured")
}
if signature == "" {
return fmt.Errorf("missing X-Shopify-Hmac-SHA256 header")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
return fmt.Errorf("invalid Shopify HMAC")
}
return nil
}
func (h *ShopifyWebhookHandler) deleteShopifyHooksByDomain(ctx context.Context, shopDomain string) error {
if h.db == nil {
return fmt.Errorf("shopify webhook database is not configured")
}
hooks, err := h.shopifyHooks(ctx, shopDomain)
if err != nil {
return err
}
for i := range hooks {
if err := h.db.Delete(&model.IntegrationHook{}, hooks[i].ID).Error; err != nil {
return err
}
}
return nil
}
func (h *ShopifyWebhookHandler) findShopifyHookByDomain(ctx context.Context, shopDomain string) (*model.IntegrationHook, error) {
hooks, err := h.shopifyHooks(ctx, shopDomain)
if err != nil || len(hooks) == 0 {
return nil, err
}
return &hooks[0], nil
}
func (h *ShopifyWebhookHandler) shopifyHooks(ctx context.Context, shopDomain string) ([]model.IntegrationHook, error) {
if h.db == nil {
return nil, fmt.Errorf("shopify webhook database is not configured")
}
var hooks []model.IntegrationHook
if err := h.db.WithContext(ctx).Where("hook_type = ?", model.HookTypeShopify).Find(&hooks).Error; err != nil {
return nil, err
}
matched := make([]model.IntegrationHook, 0, len(hooks))
for _, hook := range hooks {
var settings model.ShopifySettings
if len(hook.Settings) == 0 || json.Unmarshal(hook.Settings, &settings) != nil {
continue
}
if settings.ShopDomain == shopDomain {
matched = append(matched, hook)
}
}
return matched, nil
}
@@ -1,8 +1,17 @@
package webhook
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"gorm.io/driver/sqlite"
@@ -22,12 +31,26 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
&channelmodel.ChannelLINE{},
&channelmodel.ChannelTwilioSMS{},
&channelmodel.ChannelTikTok{},
&channelmodel.ChannelInstagram{},
&model.IntegrationHook{},
); err != nil {
t.Fatalf("migrate webhook lookup models: %v", err)
}
return db
}
func shopifyHMAC(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func metaSignature(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox {
t.Helper()
@@ -140,3 +163,110 @@ func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) {
t.Fatalf("unexpected extracted business id: %s", got)
}
}
func TestShopifyWebhookShopRedactDeletesMatchingHook(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
settings, _ := json.Marshal(model.ShopifySettings{ShopDomain: "store.myshopify.com"})
hook := model.IntegrationHook{
AccountID: 1,
HookType: model.HookTypeShopify,
Status: model.HookStatusActive,
AccessToken: "access-token",
Settings: settings,
}
if err := db.Create(&hook).Error; err != nil {
t.Fatalf("create shopify hook: %v", err)
}
body := []byte(`{"shop_domain":"store.myshopify.com"}`)
h := NewShopifyWebhookHandler(db, "client-secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
req := httptest.NewRequest(http.MethodPost, "/webhooks/shopify", bytes.NewReader(body))
req.Header.Set("X-Shopify-Hmac-SHA256", shopifyHMAC("client-secret", body))
req.Header.Set("X-Shopify-Topic", "shop/redact")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var count int64
if err := db.Model(&model.IntegrationHook{}).Where("id = ?", hook.ID).Count(&count).Error; err != nil {
t.Fatalf("count hook: %v", err)
}
if count != 0 {
t.Fatalf("expected shopify hook to be deleted, count=%d", count)
}
}
func TestShopifyWebhookRejectsInvalidHMAC(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
h := NewShopifyWebhookHandler(db, "client-secret")
r := gin.New()
r.POST("/webhooks/shopify", h.HandleShopifyWebhook)
req := httptest.NewRequest(http.MethodPost, "/webhooks/shopify", bytes.NewReader([]byte(`{}`)))
req.Header.Set("X-Shopify-Hmac-SHA256", "invalid")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestInstagramWebhookVerificationUsesChatwootGlobalTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("INSTAGRAM_VERIFY_TOKEN", "verify-me")
h := NewFacebookWebhookHandler(nil, nil, nil)
r := gin.New()
r.GET("/webhooks/instagram", h.HandleInstagramVerification)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/webhooks/instagram?hub.verify_token=verify-me&hub.challenge=challenge-1", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Body.String() != "challenge-1" {
t.Fatalf("unexpected challenge body: %q", w.Body.String())
}
}
func TestInstagramWebhookEventsVerifySignatureAndResolveInbox(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "instagram")
channel := channelmodel.ChannelInstagram{
AccountID: 1,
InboxID: inbox.ID,
InstagramAccountID: "ig-123",
InstagramBusinessAccountID: "ig-business-123",
PageAccessToken: "page-token",
ConnectedFBPageID: "page-123",
InstagramAccountName: "gochat",
}
if err := db.Create(&channel).Error; err != nil {
t.Fatalf("create instagram channel: %v", err)
}
t.Setenv("INSTAGRAM_APP_SECRET", "ig-secret")
body := []byte(`{"object":"instagram","entry":[{"id":"ig-123","time":1,"messaging":[{"sender":{"id":"user-1"},"recipient":{"id":"ig-123"},"timestamp":1,"message":{"mid":"mid-1","text":"hello"}}]}]}`)
h := NewFacebookWebhookHandler(nil, nil, db)
r := gin.New()
r.POST("/webhooks/instagram", h.HandleInstagramWebhook)
req := httptest.NewRequest(http.MethodPost, "/webhooks/instagram", bytes.NewReader(body))
req.Header.Set("X-Hub-Signature-256", metaSignature("ig-secret", body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
+22 -3
View File
@@ -77,6 +77,7 @@ type Handlers struct {
TikTokWebhook *webhook.TikTokWebhookHandler
LineWebhook *webhook.LineWebhookHandler
TwilioWebhook *webhook.TwilioWebhookHandler
ShopifyWebhook *webhook.ShopifyWebhookHandler
AssignmentPolicy *v1.AssignmentPolicyHandler
Label *v1.LabelHandler
Search *v1.SearchHandler
@@ -429,9 +430,27 @@ func RegisterRoutes(
handlers.TwitterChannel.WebhookEvent(c)
})
webhookGroup.GET("/instagram", chatwootParityStub)
webhookGroup.POST("/instagram", chatwootParityStub)
webhookGroup.POST("/shopify", chatwootParityStub)
webhookGroup.GET("/instagram", func(c *gin.Context) {
if handlers == nil || handlers.FacebookWebhook == nil {
chatwootParityStub(c)
return
}
handlers.FacebookWebhook.HandleInstagramVerification(c)
})
webhookGroup.POST("/instagram", func(c *gin.Context) {
if handlers == nil || handlers.FacebookWebhook == nil {
chatwootParityStub(c)
return
}
handlers.FacebookWebhook.HandleInstagramWebhook(c)
})
webhookGroup.POST("/shopify", func(c *gin.Context) {
if handlers == nil || handlers.ShopifyWebhook == nil {
chatwootParityStub(c)
return
}
handlers.ShopifyWebhook.HandleShopifyWebhook(c)
})
// Microsoft webhook — Graph API subscription validation + notifications
// POST: validation request (returns validationToken) + change notifications