feat(webhook): implement instagram and shopify ingress
This commit is contained in:
@@ -474,10 +474,10 @@ Webhook ingress subtracking:
|
||||
| P6.7b | LINE `POST /webhooks/line/:line_channel_id` | `webhooks/line#process_payload` | Router param and handler lookup were mismatched; handler read an inbox-style param instead of line channel ID. | Handler resolves `ChannelLINE` by `channel_id`, verifies `X-Line-Signature`, and dispatches/acks like Chatwoot. | Review |
|
||||
| P6.7c | Telegram `POST /webhooks/telegram/:bot_token` | `webhooks/telegram#process_payload` | Handler lookup was a placeholder and did not resolve the real inbox by bot token. | Handler resolves `ChannelTelegram` by `bot_token`, loads inbox, processes update, and returns provider-safe `200 OK`. | Review |
|
||||
| P6.7d | SMS/Twilio `POST /webhooks/sms/:phone_number` | `webhooks/sms#process_payload` | Go path was `/webhooks/twilio/sms/:phone_number`; handler read an inbox-style param. | Chatwoot path is registered, phone number resolves `ChannelTwilioSMS`, signature verification is applied where configured, and message/status events dispatch. | Review |
|
||||
| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Param naming and verification/secret behavior need Chatwoot comparison; existing handler mostly delegates to channel package. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Review |
|
||||
| P6.7f | Instagram `GET/POST /webhooks/instagram` | `webhooks/instagram#verify`, `#events` | Chatwoot no-param route is absent; Meta verification/signature behavior is not exposed separately from Facebook routes. | Verify/event routes exist at Chatwoot paths and resolve account/inbox from payload/subscription data. | Todo |
|
||||
| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Verify-token lookup scanned only account `0`, and Cloud signature verification used access token as a placeholder secret. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Review |
|
||||
| P6.7f | Instagram `GET/POST /webhooks/instagram` | `webhooks/instagram#verify`, `#events` | Chatwoot no-param route was registered but still returned parity stub responses. | Verify/event routes exist at Chatwoot paths and resolve account/inbox from payload/subscription data. | Review |
|
||||
| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expected `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, resolves business/inbox from payload, and acks/dispatches provider events. | Review |
|
||||
| P6.7h | Shopify `POST /webhooks/shopify` | `webhooks/shopify#events` | Chatwoot route exists; Go provider surface needs inventory before implementation. | Route either has a real verified handler or is explicitly tracked as unsupported without placeholder success. | Todo |
|
||||
| P6.7h | Shopify `POST /webhooks/shopify` | `webhooks/shopify#events` | Chatwoot route existed but returned parity stub responses. | Route either has a real verified handler or is explicitly tracked as unsupported without placeholder success. | Review |
|
||||
| P6.7i | Generic fallback and auth middleware | Go `WebhookAuth`, `webhookStub` | Generic middleware reads `:channel_type/:identifier`, which breaks provider-specific routes; fallback returned placeholder success. | Provider routes perform provider-specific verification; fallback no longer masks missing providers with success JSON. | Done |
|
||||
|
||||
P6.7 implementation notes:
|
||||
@@ -564,3 +564,4 @@ Verification milestone gates:
|
||||
- 2026-06-04: Completed P6.6f public CSAT deep behavior. `/public/api/v1/csat_survey/:id` now resolves the conversation UUID to the `input_csat` message and returns the Chatwoot public survey payload (`csat_survey_response`, display type, inbox avatar/name, locale, conversation/message IDs). Public CSAT submit now accepts nested `message.submitted_values`, updates the survey message content attributes, upserts a message-linked CSAT response, and enforces Chatwoot's 14-day lock with `422`. Public inbox message update now applies the same lock/response-builder path for `input_csat` messages. Added handler coverage for public CSAT show/update/lock and public inbox CSAT message update/lock. Focused package tests passed.
|
||||
- 2026-06-04: Consolidated the Hermes-era planning into this master tracker. Added the locked decision ledger, end-to-end milestone map, ordered slice backlog, enterprise work package breakdown, and detailed P6.7 provider webhook ingress checklist. Current next implementation slice is B1/P6.7 webhook ingress.
|
||||
- 2026-06-04: Started P6.7 webhook ingress parity. Added Chatwoot public webhook paths for Twitter, Telegram, LINE, SMS/Twilio, WhatsApp, Instagram, TikTok, and Shopify; removed the generic success fallback so unsupported providers no longer return placeholder success. Telegram, LINE, Twilio SMS, and TikTok handlers now resolve inboxes through provider channel records instead of inbox-id placeholders; TikTok model column naming now matches existing repository queries. Added provider lookup tests and router boot coverage. Regenerated route dump: `TOTAL: 801`; tracked route parity remains `251 exact, 0 missing`.
|
||||
- 2026-06-04: Continued P6.7 webhook ingress parity. Instagram `/webhooks/instagram` now performs Chatwoot-style global verify-token challenge handling, verifies Meta signatures against env/channel app secrets, resolves Instagram inboxes from webhook sender/recipient IDs, and dispatches parsed DM/comment events through the existing Meta pipeline boundary. Shopify `/webhooks/shopify` now verifies `X-Shopify-Hmac-SHA256` with `SHOPIFY_CLIENT_SECRET`, handles `shop/redact` by deleting matching Shopify integration hooks, and forwards supported topics to the existing Shopify event processor. WhatsApp verification now queries channels by verify token directly, and Cloud API signature verification uses app secrets from provider config/env instead of access tokens. Added focused webhook tests; route dump remains `TOTAL: 801` and tracked route parity remains `251 exact, 0 missing`.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user