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
+4 -3
View File
@@ -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`.
+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