second commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package webhook
|
||||
|
||||
// Email Gin webhook adapter — bridges HTTP requests from the Gin router
|
||||
// to the Email channel package's WebhookHandler and IncomingProcessor.
|
||||
// Reference: LINE/TikTok webhook adapter pattern (line_webhook.go, tiktok_webhook.go)
|
||||
//
|
||||
// URL pattern: /webhooks/email/:inbox_id
|
||||
// Methods:
|
||||
// - POST: HandleEmailWebhook — processes incoming email relay requests
|
||||
// - GET: HandleEmailVerification — health check / verification endpoint
|
||||
//
|
||||
// Email webhook relay providers (Mailgun, SendGrid, SES, Postfix) send
|
||||
// inbound email data as HTTP POST to this endpoint. The adapter:
|
||||
// 1. Reads the request body
|
||||
// 2. Parses it via WebhookHandler.ParseWebhookBody into an EmailMessage
|
||||
// 3. Processes it via IncomingProcessor.Process for full pipeline handling
|
||||
// 4. Returns JSON response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
emailchannel "github.com/gochat/gochat/internal/channel/email"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EmailWebhookHandler adapts Email webhook handling to Gin HTTP requests.
|
||||
type EmailWebhookHandler struct {
|
||||
emailWebhook *emailchannel.WebhookHandler
|
||||
pipeline *emailchannel.IncomingProcessor
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewEmailWebhookHandler creates a Gin-compatible Email webhook handler.
|
||||
func NewEmailWebhookHandler(emailWebhook *emailchannel.WebhookHandler, pipeline *emailchannel.IncomingProcessor, db *gorm.DB) *EmailWebhookHandler {
|
||||
return &EmailWebhookHandler{
|
||||
emailWebhook: emailWebhook,
|
||||
pipeline: pipeline,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEmailWebhook processes incoming email relay webhook HTTP requests.
|
||||
// This handles ActionMailbox-style inbound email relay from providers
|
||||
// like Mailgun, SendGrid, SES, or Postfix pipe-to-webhook.
|
||||
func (h *EmailWebhookHandler) HandleEmailWebhook(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Email webhook: invalid inbox_id %s", inboxIDStr)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup inbox from database
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Email webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Email webhook: failed to read body for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
// Parse the email message using the channel-level handler
|
||||
emailMsg, err := h.emailWebhook.ParseWebhookBody(body, c.Request.Header)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Email webhook: parse request failed for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Process the message via the pipeline
|
||||
if _, err := h.pipeline.Process(c.Request.Context(), inbox, emailMsg); err != nil {
|
||||
applogger.L().Errorf("Email webhook: process message failed for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
applogger.L().Infof("Email webhook: processed message for inbox=%d from=%s", inboxID, emailMsg.FromAddress)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
||||
}
|
||||
|
||||
// HandleEmailVerification responds to email webhook URL verification / health check.
|
||||
// This endpoint can be used by relay providers to verify the webhook URL is active.
|
||||
func (h *EmailWebhookHandler) HandleEmailVerification(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "verified"})
|
||||
}
|
||||
|
||||
// lookupInbox fetches an Inbox record from the database.
|
||||
func (h *EmailWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func (h *EmailWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
||||
applogger.L().Warnf("Email: Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package webhook
|
||||
|
||||
// FacebookWebhookHandler processes incoming Facebook/Instagram webhook HTTP requests via Gin.
|
||||
//
|
||||
// Reference: Chatwoot routes Facebook webhooks at:
|
||||
// post '/webhooks/facebook/:page_id' => 'channels/facebook#process_message'
|
||||
// FB webhook verification (GET) at the same URL
|
||||
//
|
||||
// The handler:
|
||||
// 1. GET: Webhook verification — responds with hub.challenge when hub.mode=subscribe
|
||||
// and hub.verify_token matches the stored token (FB requirement for initial setup)
|
||||
// 2. POST: Incoming message/event processing — reads the JSON payload, validates
|
||||
// the X-Hub-Signature-256 header, parses events, and delegates to the
|
||||
// Facebook/Instagram provider pipeline
|
||||
//
|
||||
// Facebook webhook requirements:
|
||||
// - GET verification: must echo hub.challenge back when hub.verify_token matches
|
||||
// - POST events: must validate X-Hub-Signature-256 (SHA256 HMAC with app_secret)
|
||||
// - Must respond within 20 seconds
|
||||
// - Must return 200 OK even on processing errors (FB retries on non-200)
|
||||
//
|
||||
// Instagram DMs use the same webhook endpoint format (Meta Business Suite):
|
||||
// - object="instagram" for IG events vs object="page" for FB Messenger
|
||||
// - Same X-Hub-Signature-256 verification
|
||||
// - Same hub.mode/hub.verify_token/hub.challenge verification flow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
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
|
||||
webhookParser *fbchannel.WebhookParser
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration.
|
||||
func NewFacebookWebhookHandler(
|
||||
fbProvider *fbchannel.FacebookProvider,
|
||||
igProvider *fbchannel.InstagramProvider,
|
||||
db *gorm.DB,
|
||||
) *FacebookWebhookHandler {
|
||||
return &FacebookWebhookHandler{
|
||||
fbProvider: fbProvider,
|
||||
igProvider: igProvider,
|
||||
webhookParser: fbchannel.NewWebhookParser(),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleFacebookVerification handles the GET webhook verification request from Facebook.
|
||||
// URL pattern: /webhooks/facebook/:inbox_id
|
||||
// Method: GET
|
||||
// Query params: hub.mode=subscribe, hub.verify_token=<token>, hub.challenge=<challenge>
|
||||
//
|
||||
// Reference: https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"),
|
||||
}
|
||||
|
||||
// Look up the inbox to find the verify token stored in ChannelConfig
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "verify token not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
applogger.L().Infof("Facebook webhook verified for inbox %d", inboxID)
|
||||
|
||||
// Facebook expects the challenge value as the plain response body
|
||||
c.String(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// HandleFacebookWebhook processes an incoming Facebook/Instagram webhook POST request.
|
||||
// URL pattern: /webhooks/facebook/:inbox_id
|
||||
// Method: POST
|
||||
// Content-Type: application/json
|
||||
// Header: X-Hub-Signature-256 for signature verification
|
||||
//
|
||||
// Reference: Chatwoot processes Facebook webhook events via IncomingMessageService
|
||||
// FB sends webhook events as JSON POST to the configured callback URL
|
||||
//
|
||||
// Flow:
|
||||
// 1. Read raw body from request
|
||||
// 2. Validate X-Hub-Signature-256 HMAC signature
|
||||
// 3. Parse the payload to determine object type (page=FB, instagram=IG)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
// Validate webhook signature (X-Hub-Signature-256)
|
||||
signatureHeader := c.GetHeader("X-Hub-Signature-256")
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Facebook webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve app_secret from channel config for signature validation
|
||||
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)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the payload to determine if it's Facebook or Instagram
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inboxID)
|
||||
|
||||
// Process each parsed event
|
||||
for _, event := range parsedEvents {
|
||||
// Skip echo messages (sent by the page/business itself)
|
||||
if fbchannel.IsEchoMessage(event) {
|
||||
mid := ""
|
||||
if event.Message != nil {
|
||||
mid = event.Message.Mid
|
||||
}
|
||||
applogger.L().Debug("Facebook webhook: skipping echo message",
|
||||
"mid", mid,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip delivery/read receipts — no actionable message content
|
||||
if fbchannel.IsDeliveryOrReadReceipt(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping delivery/read receipt",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip thread control events (handled by TakeThreadControl in listener)
|
||||
if fbchannel.IsThreadControlEvent(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping thread control event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Instagram comment events separately from DM messages
|
||||
// Comment events use the Changes array (field="comments") and require
|
||||
// ProcessCommentIncoming to transform them into IncomingMessage objects.
|
||||
if fbchannel.IsIGCommentEvent(event) {
|
||||
applogger.L().Info("Facebook webhook: processing Instagram comment event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
|
||||
commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: IG comment processing failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if commentMsg != nil {
|
||||
applogger.L().Infof("Facebook webhook: IG comment processed (inbox_id=%d, source_id=%s, type=%s)",
|
||||
commentMsg.InboxID, commentMsg.SourceID, event.EventType)
|
||||
// TODO: Push to message broker/dispatcher for persistence + notification
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process events that should create messages
|
||||
if !fbchannel.ShouldCreateMessage(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping non-message event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delegate to the appropriate provider based on object type
|
||||
var incomingMsg *channel.IncomingMessage
|
||||
switch event.Object {
|
||||
case "page":
|
||||
// Facebook Messenger
|
||||
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelFacebook)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: extract message failed (FB): %v", err)
|
||||
continue
|
||||
}
|
||||
case "instagram":
|
||||
// Instagram DMs
|
||||
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: extract message failed (IG): %v", err)
|
||||
continue
|
||||
}
|
||||
default:
|
||||
applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inboxID)
|
||||
continue
|
||||
}
|
||||
|
||||
if incomingMsg != nil {
|
||||
applogger.L().Infof("Facebook webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)",
|
||||
incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
|
||||
// TODO: Push to message broker/dispatcher for persistence + notification
|
||||
// Reference: Chatwoot pushes to IncomingMessageService → Conversation + Message creation
|
||||
}
|
||||
}
|
||||
|
||||
// Always return 200 OK to Facebook — it retries if not 200
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Helper methods
|
||||
// ===========================
|
||||
|
||||
// lookupInbox finds the Inbox by ID via GORM.
|
||||
func (h *FacebookWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
// resolveVerifyToken extracts the webhook verify token from the inbox ChannelConfig JSON.
|
||||
func (h *FacebookWebhookHandler) resolveVerifyToken(inbox *model.Inbox) string {
|
||||
config := h.parseChannelConfig(inbox)
|
||||
if token, ok := config["webhook_verify_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveAppSecret extracts the Facebook app secret from the inbox ChannelConfig JSON.
|
||||
func (h *FacebookWebhookHandler) resolveAppSecret(inbox *model.Inbox) string {
|
||||
config := h.parseChannelConfig(inbox)
|
||||
if secret, ok := config["app_secret"].(string); ok {
|
||||
return secret
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func (h *FacebookWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
||||
applogger.L().Warnf("Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package webhook
|
||||
|
||||
// FacebookWebhookHandler processes incoming Facebook/Instagram webhook HTTP requests via Gin.
|
||||
//
|
||||
// Reference: Chatwoot routes Facebook webhooks at:
|
||||
// post '/webhooks/facebook/:page_id' => 'channels/facebook#process_message'
|
||||
// FB webhook verification (GET) at the same URL
|
||||
//
|
||||
// The handler:
|
||||
// 1. GET: Webhook verification — responds with hub.challenge when hub.mode=subscribe
|
||||
// and hub.verify_token matches the stored token (FB requirement for initial setup)
|
||||
// 2. POST: Incoming message/event processing — reads the JSON payload, validates
|
||||
// the X-Hub-Signature-256 header, parses events, and delegates to the
|
||||
// Facebook/Instagram provider pipeline
|
||||
//
|
||||
// Facebook webhook requirements:
|
||||
// - GET verification: must echo hub.challenge back when hub.verify_token matches
|
||||
// - POST events: must validate X-Hub-Signature-256 (SHA256 HMAC with app_secret)
|
||||
// - Must respond within 20 seconds
|
||||
// - Must return 200 OK even on processing errors (FB retries on non-200)
|
||||
//
|
||||
// Instagram DMs use the same webhook endpoint format (Meta Business Suite):
|
||||
// - object="instagram" for IG events vs object="page" for FB Messenger
|
||||
// - Same X-Hub-Signature-256 verification
|
||||
// - Same hub.mode/hub.verify_token/hub.challenge verification flow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
fbchannel "github.com/gochat/gochat/internal/channel/facebook"
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
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
|
||||
webhookParser *fbchannel.WebhookParser
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration.
|
||||
func NewFacebookWebhookHandler(
|
||||
fbProvider *fbchannel.FacebookProvider,
|
||||
igProvider *fbchannel.InstagramProvider,
|
||||
db *gorm.DB,
|
||||
) *FacebookWebhookHandler {
|
||||
return &FacebookWebhookHandler{
|
||||
fbProvider: fbProvider,
|
||||
igProvider: igProvider,
|
||||
webhookParser: fbchannel.NewWebhookParser(),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleFacebookVerification handles the GET webhook verification request from Facebook.
|
||||
// URL pattern: /webhooks/facebook/:inbox_id
|
||||
// Method: GET
|
||||
// Query params: hub.mode=subscribe, hub.verify_token=<token>, hub.challenge=<challenge>
|
||||
//
|
||||
// Reference: https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"),
|
||||
}
|
||||
|
||||
// Look up the inbox to find the verify token stored in ChannelConfig
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Facebook webhook verification: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "inbox not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "verify token not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "verification failed"})
|
||||
return
|
||||
}
|
||||
|
||||
applogger.L().Infof("Facebook webhook verified for inbox %d", inboxID)
|
||||
|
||||
// Facebook expects the challenge value as the plain response body
|
||||
c.String(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// HandleFacebookWebhook processes an incoming Facebook/Instagram webhook POST request.
|
||||
// URL pattern: /webhooks/facebook/:inbox_id
|
||||
// Method: POST
|
||||
// Content-Type: application/json
|
||||
// Header: X-Hub-Signature-256 for signature verification
|
||||
//
|
||||
// Reference: Chatwoot processes Facebook webhook events via IncomingMessageService
|
||||
// FB sends webhook events as JSON POST to the configured callback URL
|
||||
//
|
||||
// Flow:
|
||||
// 1. Read raw body from request
|
||||
// 2. Validate X-Hub-Signature-256 HMAC signature
|
||||
// 3. Parse the payload to determine object type (page=FB, instagram=IG)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
// Validate webhook signature (X-Hub-Signature-256)
|
||||
signatureHeader := c.GetHeader("X-Hub-Signature-256")
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Facebook webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve app_secret from channel config for signature validation
|
||||
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)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the payload to determine if it's Facebook or Instagram
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
applogger.L().Infof("Facebook webhook: received %d events for inbox %d", len(parsedEvents), inboxID)
|
||||
|
||||
// Process each parsed event
|
||||
for _, event := range parsedEvents {
|
||||
// Skip echo messages (sent by the page/business itself)
|
||||
if fbchannel.IsEchoMessage(event) {
|
||||
mid := ""
|
||||
if event.Message != nil {
|
||||
mid = event.Message.Mid
|
||||
}
|
||||
applogger.L().Debug("Facebook webhook: skipping echo message",
|
||||
"mid", mid,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip delivery/read receipts — no actionable message content
|
||||
if fbchannel.IsDeliveryOrReadReceipt(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping delivery/read receipt",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip thread control events (handled by TakeThreadControl in listener)
|
||||
if fbchannel.IsThreadControlEvent(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping thread control event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Instagram comment events separately from DM messages
|
||||
// Comment events use the Changes array (field="comments") and require
|
||||
// ProcessCommentIncoming to transform them into IncomingMessage objects.
|
||||
if fbchannel.IsIGCommentEvent(event) {
|
||||
applogger.L().Info("Facebook webhook: processing Instagram comment event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
|
||||
commentMsg, err := h.igProvider.ProcessCommentIncoming(c.Request.Context(), inbox, event.Comment, event.EventType)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: IG comment processing failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if commentMsg != nil {
|
||||
applogger.L().Infof("Facebook webhook: IG comment processed (inbox_id=%d, source_id=%s, type=%s)",
|
||||
commentMsg.InboxID, commentMsg.SourceID, event.EventType)
|
||||
// TODO: Push to message broker/dispatcher for persistence + notification
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Only process events that should create messages
|
||||
if !fbchannel.ShouldCreateMessage(event) {
|
||||
applogger.L().Debug("Facebook webhook: skipping non-message event",
|
||||
"event_type", event.EventType,
|
||||
"inbox_id", inboxID,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Delegate to the appropriate provider based on object type
|
||||
var incomingMsg *channel.IncomingMessage
|
||||
switch event.Object {
|
||||
case "page":
|
||||
// Facebook Messenger
|
||||
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelFacebook)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: extract message failed (FB): %v", err)
|
||||
continue
|
||||
}
|
||||
case "instagram":
|
||||
// Instagram DMs
|
||||
incomingMsg, err = fbchannel.ExtractIncomingMessageFromEvent(event, inbox, channel.ChannelInstagram)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Facebook webhook: extract message failed (IG): %v", err)
|
||||
continue
|
||||
}
|
||||
default:
|
||||
applogger.L().Warnf("Facebook webhook: unknown object type %s for inbox %d", event.Object, inboxID)
|
||||
continue
|
||||
}
|
||||
|
||||
if incomingMsg != nil {
|
||||
applogger.L().Infof("Facebook webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)",
|
||||
incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
|
||||
// TODO: Push to message broker/dispatcher for persistence + notification
|
||||
// Reference: Chatwoot pushes to IncomingMessageService → Conversation + Message creation
|
||||
}
|
||||
}
|
||||
|
||||
// Always return 200 OK to Facebook — it retries if not 200
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Helper methods
|
||||
// ===========================
|
||||
|
||||
// lookupInbox finds the Inbox by ID via GORM.
|
||||
func (h *FacebookWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
// resolveVerifyToken extracts the webhook verify token from the inbox ChannelConfig JSON.
|
||||
func (h *FacebookWebhookHandler) resolveVerifyToken(inbox *model.Inbox) string {
|
||||
config := h.parseChannelConfig(inbox)
|
||||
if token, ok := config["webhook_verify_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveAppSecret extracts the Facebook app secret from the inbox ChannelConfig JSON.
|
||||
func (h *FacebookWebhookHandler) resolveAppSecret(inbox *model.Inbox) string {
|
||||
config := h.parseChannelConfig(inbox)
|
||||
if secret, ok := config["app_secret"].(string); ok {
|
||||
return secret
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func (h *FacebookWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
||||
applogger.L().Warnf("Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package webhook
|
||||
|
||||
// LineWebhookHandler processes incoming LINE webhook HTTP requests via Gin.
|
||||
// Reference: Facebook webhook adapter pattern (facebook_webhook.go)
|
||||
//
|
||||
// URL pattern: /webhooks/line/:inbox_id
|
||||
// Method: POST (LINE Messaging API sends events as JSON)
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
linechannel "github.com/gochat/gochat/internal/channel/line"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LineWebhookHandler processes LINE webhook requests via Gin.
|
||||
type LineWebhookHandler struct {
|
||||
lineWebhook *linechannel.WebhookHandler
|
||||
pipeline *linechannel.IncomingProcessor
|
||||
service *linechannel.LineService
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLineWebhookHandler creates a LINE webhook handler for Gin integration.
|
||||
func NewLineWebhookHandler(lineWebhook *linechannel.WebhookHandler, pipeline *linechannel.IncomingProcessor, service *linechannel.LineService, db *gorm.DB) *LineWebhookHandler {
|
||||
return &LineWebhookHandler{
|
||||
lineWebhook: lineWebhook,
|
||||
pipeline: pipeline,
|
||||
service: service,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLineWebhook processes an incoming LINE webhook Gin request.
|
||||
func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("LINE webhook: invalid inbox_id %s", inboxIDStr)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup inbox from database
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("LINE webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("LINE webhook: failed to read body for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
// Verify webhook signature
|
||||
config := h.parseChannelConfig(inbox)
|
||||
channelSecret := ""
|
||||
if v, ok := config["channel_secret"].(string); ok {
|
||||
channelSecret = v
|
||||
}
|
||||
signature := c.GetHeader("X-Line-Signature")
|
||||
|
||||
if channelSecret != "" && signature != "" {
|
||||
if !h.service.VerifySignature(channelSecret, string(body), signature) {
|
||||
applogger.L().Warnf("LINE webhook: invalid signature for inbox=%d", inboxID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse webhook event
|
||||
var webhookEvent linechannel.WebhookEvent
|
||||
if err := json.Unmarshal(body, &webhookEvent); err != nil {
|
||||
applogger.L().Errorf("LINE webhook: failed to parse JSON for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
// Process each event via the pipeline
|
||||
for _, event := range webhookEvent.Events {
|
||||
incomingMsg, err := h.pipeline.ProcessEvent(c.Request.Context(), inbox, event)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("LINE webhook: process event failed for inbox %d: %v", inboxID, err)
|
||||
continue
|
||||
}
|
||||
if incomingMsg != nil {
|
||||
applogger.L().Debugf("LINE webhook: processed event type=%s sender=%s",
|
||||
event.Type, incomingMsg.SenderID)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
||||
}
|
||||
|
||||
// HandleLineVerification responds to LINE webhook URL verification.
|
||||
func (h *LineWebhookHandler) HandleLineVerification(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "verified"})
|
||||
}
|
||||
|
||||
// lookupInbox fetches an Inbox record from the database.
|
||||
func (h *LineWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func (h *LineWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
||||
applogger.L().Warnf("LINE: Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
channelprovider "github.com/gochat/gochat/internal/channel/provider"
|
||||
telegramchannel "github.com/gochat/gochat/internal/channel/telegram"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// TelegramWebhookHandler processes incoming Telegram webhook HTTP requests via Gin.
|
||||
// Reference: Chatwoot routes Telegram webhooks at:
|
||||
// post '/webhooks/telegram/:bot_token' => 'telegram_bots#process_message'
|
||||
//
|
||||
// The handler:
|
||||
// 1. Receives raw Update JSON from Telegram's webhook POST
|
||||
// 2. Validates the update structure
|
||||
// 3. Routes to appropriate processing (message, edited_message, callback_query)
|
||||
// 4. Returns 200 OK to Telegram (required to prevent retries)
|
||||
//
|
||||
// Telegram webhook requirements:
|
||||
// - Must respond within 10 seconds
|
||||
// - Must return 200 OK even on processing errors (async processing recommended)
|
||||
// - Updates are sent as JSON POST to the webhook URL
|
||||
|
||||
// TelegramWebhookHandler processes Telegram webhook requests via Gin.
|
||||
type TelegramWebhookHandler struct {
|
||||
provider *channelprovider.TelegramProvider
|
||||
telWebhook *telegramchannel.WebhookHandler
|
||||
// inboxRepo would be injected here for inbox lookup in production
|
||||
}
|
||||
|
||||
// NewTelegramWebhookHandler creates a Telegram webhook handler for Gin integration.
|
||||
func NewTelegramWebhookHandler(
|
||||
provider *channelprovider.TelegramProvider,
|
||||
telWebhook *telegramchannel.WebhookHandler,
|
||||
) *TelegramWebhookHandler {
|
||||
return &TelegramWebhookHandler{
|
||||
provider: provider,
|
||||
telWebhook: telWebhook,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTelegramWebhook processes an incoming Telegram webhook Gin request.
|
||||
// URL pattern: /webhooks/telegram/:bot_token
|
||||
// Method: POST
|
||||
// Content-Type: application/json
|
||||
//
|
||||
// Reference: Chatwoot: Telegram webhook receives Update payloads
|
||||
// at /webhooks/telegram/{bot_token}
|
||||
//
|
||||
// Flow:
|
||||
// 1. Parse bot_token from URL path → lookup Inbox + ChannelTelegram
|
||||
// 2. Read Update JSON from request body
|
||||
// 3. Dispatch to appropriate processing pipeline
|
||||
// 4. Return 200 OK immediately (Telegram requires fast response)
|
||||
func (h *TelegramWebhookHandler) HandleTelegramWebhook(c *gin.Context) {
|
||||
botToken := c.Param("bot_token")
|
||||
if botToken == "" {
|
||||
applogger.L().Warn("Telegram webhook: missing bot_token in path")
|
||||
// Still return 200 to prevent Telegram retries
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Telegram webhook: failed to read body: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
applogger.L().Infof("Telegram webhook received for bot_token prefix: %s", maskBotToken(botToken))
|
||||
|
||||
// Parse the update to extract update_id for logging
|
||||
var update telegramchannel.TelegramUpdate
|
||||
if err := json.Unmarshal(body, &update); err != nil {
|
||||
applogger.L().Errorf("Telegram webhook: failed to parse update: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up Inbox for this bot_token
|
||||
// In production: query GORM for Inbox with channel_type=telegram matching the bot_token
|
||||
inbox, err := h.lookupInbox(botToken)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Telegram webhook: inbox lookup failed: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse raw payload for provider processing
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
applogger.L().Errorf("Telegram webhook: failed to parse payload: %v", err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Process the incoming message via the provider pipeline
|
||||
// Reference: Chatwoot processes updates via IncomingMessageService
|
||||
incomingMsg, err := h.provider.ProcessIncomingMessage(c.Request.Context(), inbox, payload)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Telegram webhook: message processing failed (update_id=%d): %v", update.UpdateID, err)
|
||||
} else if incomingMsg != nil {
|
||||
applogger.L().Infof("Telegram webhook: message processed (inbox_id=%d, source_id=%s)",
|
||||
incomingMsg.InboxID, incomingMsg.SourceID)
|
||||
// TODO: Push to message broker/dispatcher for persistence + notification
|
||||
}
|
||||
|
||||
// Always return 200 OK to Telegram — it retries if not 200
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// lookupInbox finds the Inbox associated with a Telegram bot token.
|
||||
// In production: queries GORM for Inbox with channel_type=telegram matching the bot_token.
|
||||
// Reference: Chatwoot: Inbox.find_by(channel: telegram, bot_token: token)
|
||||
func (h *TelegramWebhookHandler) lookupInbox(botToken string) (*model.Inbox, error) {
|
||||
// TODO: Implement with GORM repository
|
||||
// Placeholder — production would use:
|
||||
// var ch channelmodel.ChannelTelegram
|
||||
// db.Where("bot_token = ?", botToken).First(&ch)
|
||||
// var inbox model.Inbox
|
||||
// db.Where("id = ?", ch.InboxID).First(&inbox)
|
||||
//
|
||||
// For now, return a placeholder inbox
|
||||
inbox := &model.Inbox{
|
||||
ChannelType: "telegram",
|
||||
}
|
||||
return inbox, nil
|
||||
}
|
||||
|
||||
// maskBotToken masks a bot token for safe logging (show first 8 chars only).
|
||||
func maskBotToken(token string) string {
|
||||
if len(token) > 8 {
|
||||
return token[:8] + "..."
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package webhook
|
||||
|
||||
// TikTok Gin webhook adapter — bridges HTTP requests from the Gin router
|
||||
// to the TikTok channel package's WebhookHandler and IncomingProcessor.
|
||||
// Reference: Facebook webhook adapter pattern (facebook_webhook.go)
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TikTokWebhookHandler adapts TikTok webhook handling to Gin HTTP requests.
|
||||
type TikTokWebhookHandler struct {
|
||||
tiktokWebhook *tiktokchannel.WebhookHandler
|
||||
pipeline *tiktokchannel.IncomingProcessor
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTikTokWebhookHandler creates a Gin-compatible TikTok webhook handler.
|
||||
func NewTikTokWebhookHandler(tiktokWebhook *tiktokchannel.WebhookHandler, pipeline *tiktokchannel.IncomingProcessor, db *gorm.DB) *TikTokWebhookHandler {
|
||||
return &TikTokWebhookHandler{
|
||||
tiktokWebhook: tiktokWebhook,
|
||||
pipeline: pipeline,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTikTokWebhook processes incoming TikTok webhook HTTP requests.
|
||||
func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("TikTok webhook: invalid inbox_id %s", inboxIDStr)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup inbox from database
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("TikTok webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the webhook event using the channel-level handler
|
||||
event, err := h.tiktokWebhook.HandleWebhookRequest(c.Request)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("TikTok webhook: parse request failed for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Process the event via the pipeline
|
||||
if err := h.pipeline.ProcessUpdate(c.Request.Context(), inbox, *event); err != nil {
|
||||
applogger.L().Errorf("TikTok webhook: process event failed for inbox %d: %v", inboxID, err)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "processed"})
|
||||
}
|
||||
|
||||
// HandleTikTokVerification handles TikTok webhook verification (challenge-response).
|
||||
func (h *TikTokWebhookHandler) HandleTikTokVerification(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
defer c.Request.Body.Close()
|
||||
|
||||
// Parse verification challenge
|
||||
var verifyReq map[string]interface{}
|
||||
if err := json.Unmarshal(body, &verifyReq); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
challenge, _ := verifyReq["challenge"].(string)
|
||||
applogger.L().Infof("TikTok webhook verification: inbox=%d challenge=%s", inboxID, challenge)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"challenge": challenge})
|
||||
}
|
||||
|
||||
// lookupInbox fetches an Inbox record from the database.
|
||||
func (h *TikTokWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
|
||||
func (h *TikTokWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
|
||||
if inbox.ChannelConfig == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil {
|
||||
applogger.L().Warnf("TikTok: Failed to parse ChannelConfig for inbox %d: %v", inbox.ID, err)
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package webhook
|
||||
|
||||
// TwilioWebhookHandler processes incoming Twilio SMS webhook HTTP requests via Gin.
|
||||
// Reference: Facebook webhook adapter pattern (facebook_webhook.go)
|
||||
//
|
||||
// URL patterns:
|
||||
// /webhooks/twilio/sms/:inbox_id — inbound SMS/MMS
|
||||
// /webhooks/twilio/status/:inbox_id — delivery status callbacks
|
||||
//
|
||||
// Method: POST (Twilio sends form-encoded data, not JSON)
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
twiliochannel "github.com/gochat/gochat/internal/channel/twilio"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TwilioWebhookHandler processes Twilio SMS webhook requests via Gin.
|
||||
type TwilioWebhookHandler struct {
|
||||
twilioWebhook *twiliochannel.WebhookHandler
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTwilioWebhookHandler creates a Twilio SMS webhook handler for Gin integration.
|
||||
func NewTwilioWebhookHandler(twilioWebhook *twiliochannel.WebhookHandler, db *gorm.DB) *TwilioWebhookHandler {
|
||||
return &TwilioWebhookHandler{
|
||||
twilioWebhook: twilioWebhook,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTwilioInboundSMS processes an incoming Twilio SMS webhook Gin request.
|
||||
func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Twilio webhook: invalid inbox_id %s", inboxIDStr)
|
||||
c.Data(http.StatusOK, "application/xml", []byte("<Response></Response>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup inbox from database
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Twilio webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.Data(http.StatusOK, "application/xml", []byte("<Response></Response>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch to the channel-level webhook handler
|
||||
// Twilio expects a TwiML XML response, not JSON
|
||||
h.twilioWebhook.HandleInboundSMS(c.Writer, c.Request, inbox)
|
||||
}
|
||||
|
||||
// HandleTwilioDeliveryStatus processes a Twilio delivery status callback.
|
||||
func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) {
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Twilio status webhook: invalid inbox_id %s", inboxIDStr)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Lookup inbox from database
|
||||
inbox, err := h.lookupInbox(uint(inboxID))
|
||||
if err != nil {
|
||||
applogger.L().Warnf("Twilio status webhook: inbox lookup failed for id %d: %v", inboxID, err)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch to the channel-level delivery status handler
|
||||
h.twilioWebhook.HandleDeliveryStatus(c.Writer, c.Request, inbox)
|
||||
}
|
||||
|
||||
// lookupInbox fetches an Inbox record from the database.
|
||||
func (h *TwilioWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) {
|
||||
var inbox model.Inbox
|
||||
if err := h.db.Where("id = ?", inboxID).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
)
|
||||
|
||||
// WebhookHandler processes incoming webhook events from external channels.
|
||||
type WebhookHandler struct {
|
||||
registry *channel.ChannelRegistry
|
||||
}
|
||||
|
||||
// NewHandler creates a new WebhookHandler.
|
||||
func NewHandler(registry *channel.ChannelRegistry) *WebhookHandler {
|
||||
return &WebhookHandler{registry: registry}
|
||||
}
|
||||
|
||||
// Handle processes an incoming webhook request.
|
||||
// The URL pattern is /webhooks/:channel_type/:inbox_id
|
||||
// The handler looks up the ChannelProvider for the given channel_type
|
||||
// and delegates processing to that provider's ProcessIncoming method.
|
||||
func (h *WebhookHandler) Handle(c *gin.Context) {
|
||||
channelType := c.Param("channel_type")
|
||||
inboxIDStr := c.Param("inbox_id")
|
||||
|
||||
provider, err := h.registry.Get(channel.ChannelType(channelType))
|
||||
if err != nil || provider == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unknown channel type: " + channelType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"})
|
||||
return
|
||||
}
|
||||
|
||||
rawPayload, err := c.GetRawData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to read payload"})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct an Inbox stub for the provider.
|
||||
inbox := &model.Inbox{Base: model.Base{ID: uint(inboxID)}}
|
||||
|
||||
msg, err := provider.ProcessIncoming(c.Request.Context(), inbox, rawPayload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "processed",
|
||||
"conversation_id": msg.ConversationID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package webhook provides Gin-compatible HTTP handler adapters for external
|
||||
// channel webhook endpoints. Each adapter wraps the corresponding channel
|
||||
// sub-package's WebhookHandler and exposes methods that can be bound directly
|
||||
// to Gin router groups.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel/whatsapp"
|
||||
)
|
||||
|
||||
// WhatsAppWebhookHandler is a Gin adapter that wraps the WhatsApp
|
||||
// sub-package's WebhookHandler. It translates Gin context calls into the
|
||||
// underlying handler methods so that WhatsApp webhook routes can be registered
|
||||
// on a Gin engine with minimal glue code.
|
||||
//
|
||||
// Chatwoot-style routes:
|
||||
//
|
||||
// GET /webhooks/whatsapp/:phone_number → HandleWhatsAppVerification
|
||||
// POST /webhooks/whatsapp/:phone_number → HandleWhatsAppWebhook
|
||||
//
|
||||
// WhatsApp Cloud API webhook verification (GET):
|
||||
//
|
||||
// The Meta platform sends a GET request with query parameters:
|
||||
// - hub.mode = "subscribe"
|
||||
// - hub.verify_token = the token configured in the Meta dashboard
|
||||
// - hub.challenge = a string the endpoint must echo back verbatim
|
||||
//
|
||||
// The handler validates hub.verify_token against the stored token and
|
||||
// returns hub.challenge as the response body with HTTP 200, or returns
|
||||
// HTTP 403 on mismatch.
|
||||
//
|
||||
// WhatsApp Cloud API webhook events (POST):
|
||||
//
|
||||
// Meta delivers event payloads as JSON with structure:
|
||||
// {
|
||||
// "object": "whatsapp_business_account",
|
||||
// "entry": [ { "changes": [ ... ] } ]
|
||||
// }
|
||||
//
|
||||
// The handler parses the payload, dispatches incoming messages and status
|
||||
// updates, and must respond with HTTP 200 OK within 10 seconds to avoid
|
||||
// Meta retrying delivery.
|
||||
type WhatsAppWebhookHandler struct {
|
||||
provider *whatsapp.WhatsAppProvider
|
||||
waWebhook *whatsapp.WebhookHandler
|
||||
}
|
||||
|
||||
// NewWhatsAppWebhookHandler creates a Gin adapter wrapping the WhatsApp
|
||||
// sub-package's WebhookHandler. The provider is stored for future use (e.g.
|
||||
// health checks or direct API calls) while waWebhook is the core handler that
|
||||
// processes verification and event requests.
|
||||
func NewWhatsAppWebhookHandler(provider *whatsapp.WhatsAppProvider, waWebhook *whatsapp.WebhookHandler) *WhatsAppWebhookHandler {
|
||||
return &WhatsAppWebhookHandler{
|
||||
provider: provider,
|
||||
waWebhook: waWebhook,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWhatsAppVerification handles GET requests for WhatsApp Cloud API
|
||||
// webhook verification. Meta sends this request during initial webhook setup
|
||||
// and periodic re-verification. It delegates to the underlying
|
||||
// waWebhook.HandleVerification method which validates hub.verify_token and
|
||||
// echoes hub.challenge on success.
|
||||
//
|
||||
// Expected Gin route: GET /webhooks/whatsapp/:phone_number
|
||||
func (h *WhatsAppWebhookHandler) HandleWhatsAppVerification(c *gin.Context) {
|
||||
h.waWebhook.HandleVerification(c)
|
||||
}
|
||||
|
||||
// HandleWhatsAppWebhook handles POST requests carrying WhatsApp Cloud API
|
||||
// event payloads (messages, status updates, template events, etc.). It
|
||||
// delegates to the underlying waWebhook.HandleWebhookEvent method which
|
||||
// parses the JSON body and dispatches events to the appropriate processor.
|
||||
//
|
||||
// The endpoint must respond with HTTP 200 OK within 10 seconds; Meta retries
|
||||
// delivery on timeout or non-200 responses.
|
||||
//
|
||||
// Expected Gin route: POST /webhooks/whatsapp/:phone_number
|
||||
func (h *WhatsAppWebhookHandler) HandleWhatsAppWebhook(c *gin.Context) {
|
||||
h.waWebhook.HandleWebhookEvent(c)
|
||||
}
|
||||
Reference in New Issue
Block a user