package v1 // FacebookChannelHandler handles Facebook channel-specific configuration CRUD and OAuth flow. // Reference: Chatwoot app/controllers/api/v1/accounts/channels/facebook_pages_controller.rb // // Chatwoot's FacebookPagesController provides: // - create: authorize FB page → create inbox (via OmniAuth callback or direct page_access_token) // - destroy: remove FB page channel + unsubscribe webhook // - reauthorize: refresh expired page access tokens // // gochat maps these to: // - POST /api/v1/accounts/:id/facebook_pages → create FB inbox // - DELETE /api/v1/accounts/:id/facebook_pages/:page_id → destroy FB inbox // - POST /api/v1/accounts/:id/facebook_pages/reauthorize → refresh token // - GET /api/v1/accounts/:id/facebook_pages/authorization → OAuth authorize URL import ( "net/http" "strconv" "github.com/gin-gonic/gin" facebookchannel "github.com/gochat/gochat/internal/channel/facebook" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" ) // FacebookChannelHandler handles Facebook Messenger channel management. type FacebookChannelHandler struct { fbService *facebookchannel.Service inboxSvc *service.InboxService fbProvider *facebookchannel.FacebookProvider } // NewFacebookChannelHandler creates a new Facebook channel handler. func NewFacebookChannelHandler( fbService *facebookchannel.Service, inboxSvc *service.InboxService, fbProvider *facebookchannel.FacebookProvider, ) *FacebookChannelHandler { return &FacebookChannelHandler{ fbService: fbService, inboxSvc: inboxSvc, fbProvider: fbProvider, } } // === OAuth Authorization === // FacebookAuthorizationRequest is the DTO for initiating FB OAuth flow. // Reference: Chatwoot facebook_pages_controller#authorization → redirects to FB login type FacebookAuthorizationRequest struct { RedirectURL string `json:"redirect_url" validate:"required,url"` } // Authorization generates a Facebook OAuth authorize URL. // The user visits this URL, grants permissions, and Facebook redirects back with a code. // The callback endpoint then exchanges the code for a long-lived token. // // GET /api/v1/accounts/:id/facebook_pages/authorization func (h *FacebookChannelHandler) Authorization(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req FacebookAuthorizationRequest if err := c.ShouldBindJSON(&req); err != nil { // Also accept query parameter for redirect_url (convenience for browser-initiated flows) redirectURL := c.Query("redirect_url") if redirectURL == "" { applogger.L().Errorf("Failed to bind authorization request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "redirect_url is required"}) return } req.RedirectURL = redirectURL } authURL, err := h.fbProvider.BuildAuthURL(c.Request.Context(), uint(accountID), req.RedirectURL) if err != nil { applogger.L().Errorf("Failed to build Facebook auth URL: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization URL"}) return } c.JSON(http.StatusOK, gin.H{ "authorization_url": authURL, "account_id": accountID, }) } // OAuthCallbackRequest is the DTO for the OAuth callback after FB redirects back. type OAuthCallbackRequest struct { Code string `json:"code" validate:"required"` RedirectURL string `json:"redirect_url" validate:"required,url"` } // OAuthCallback exchanges the Facebook OAuth code for a long-lived access token // and returns available pages for inbox creation. // After the user grants permissions on Facebook, FB redirects to redirect_url with a code param. // The frontend then calls this endpoint with the code to complete the flow. // // POST /api/v1/accounts/:id/facebook_pages/oauth_callback func (h *FacebookChannelHandler) OAuthCallback(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req OAuthCallbackRequest if err := c.ShouldBindJSON(&req); err != nil { applogger.L().Errorf("Failed to bind OAuth callback request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "code and redirect_url are required"}) return } tokenResult, err := h.fbProvider.ExchangeToken(c.Request.Context(), req.Code, req.RedirectURL) if err != nil { applogger.L().Errorf("Facebook token exchange failed: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "token exchange failed", "details": err.Error()}) return } // With the user access token, fetch available pages // Reference: FB Graph API /me/accounts?fields=id,name,access_token pages, pagesErr := h.fbService.ListUserPages(c.Request.Context(), tokenResult.AccessToken) if pagesErr != nil { applogger.L().Warnf("Failed to list user pages after token exchange: %v", pagesErr) // Still return the token — user can create inbox manually with a page_access_token c.JSON(http.StatusOK, gin.H{ "access_token": tokenResult.AccessToken, "expires_at": tokenResult.ExpiresAt, "pages": []gin.H{}, "message": "token obtained but page listing failed; provide page_access_token manually", }) return } pageList := make([]gin.H, 0, len(pages)) for _, p := range pages { pageList = append(pageList, gin.H{ "page_id": p.ID, "page_name": p.Name, "access_token": p.AccessToken, }) } c.JSON(http.StatusOK, gin.H{ "access_token": tokenResult.AccessToken, "expires_at": tokenResult.ExpiresAt, "pages": pageList, }) } // === Facebook Pages CRUD === // CreateFacebookPageRequest is the DTO for creating a Facebook Messenger inbox. // Reference: Chatwoot FacebookPagesController#create type CreateFacebookPageRequest struct { Name string `json:"name" validate:"required,min=2"` PageID string `json:"page_id" validate:"required"` PageAccessToken string `json:"page_access_token" validate:"required"` WebhookVerifyToken string `json:"webhook_verify_token,omitempty"` // auto-generated if empty EnableAutoAssignment bool `json:"enable_auto_assignment,omitempty"` } // CreateFacebookPage creates a new Facebook Messenger inbox for a Facebook Page. // Flow: validate page_access_token → create ChannelFacebook → setup webhook → create Inbox // // POST /api/v1/accounts/:id/facebook_pages func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req CreateFacebookPageRequest if err := c.ShouldBindJSON(&req); err != nil { applogger.L().Errorf("Failed to bind create facebook page request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Auto-generate webhook verify token if not provided verifyToken := req.WebhookVerifyToken if verifyToken == "" { verifyToken = generateFBVerifyToken() } // Step 1: Create ChannelFacebook via service ctx := c.Request.Context() ch, err := h.fbService.CreateFacebookChannel(ctx, uint(accountID), req.PageID, req.PageAccessToken, verifyToken) if err != nil { applogger.L().Errorf("Failed to create Facebook channel: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Facebook channel", "details": err.Error()}) return } // Step 2: Create Inbox that wraps this channel fbConfig := gin.H{ "page_access_token": ch.PageAccessToken, "page_id": ch.PageID, "page_name": ch.PageName, "verify_token": ch.WebhookVerifyToken, "app_id": ch.AppID, } configJSON, marshalErr := json.Marshal(fbConfig) if marshalErr != nil { applogger.L().Errorf("Failed to marshal FB channel config: %v", marshalErr) c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } inbox := &model.Inbox{ AccountID: uint(accountID), Name: req.Name, ChannelType: "facebook", ChannelID: ch.ID, Enabled: true, EnableAutoAssignment: req.EnableAutoAssignment, ChannelConfig: string(configJSON), } if err := h.inboxSvc.Create(ctx, inbox); err != nil { applogger.L().Errorf("Failed to create inbox for Facebook channel: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create inbox", "details": err.Error()}) return } // Update ChannelFacebook with inbox_id ch.InboxID = inbox.ID if updateErr := h.fbService.UpdateFacebookChannel(ctx, ch.ID, map[string]interface{}{ "inbox_id": inbox.ID, }); updateErr != nil { applogger.L().Warnf("Failed to update ChannelFacebook inbox_id: %v", updateErr) } c.JSON(http.StatusCreated, gin.H{ "inbox": gin.H{ "id": inbox.ID, "name": inbox.Name, "channel_type": inbox.ChannelType, "channel_id": inbox.ChannelID, "enabled": inbox.Enabled, "enable_auto_assignment": inbox.EnableAutoAssignment, }, "facebook_channel": gin.H{ "id": ch.ID, "page_id": ch.PageID, "page_name": ch.PageName, "webhook_verify_token": ch.WebhookVerifyToken, }, }) } // DeleteFacebookPage removes a Facebook Page channel and its inbox. // Reference: Chatwoot FacebookPagesController#destroy // // DELETE /api/v1/accounts/:id/facebook_pages/:page_id func (h *FacebookChannelHandler) DeleteFacebookPage(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } pageID := c.Param("page_id") if pageID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "page_id is required"}) return } ctx := c.Request.Context() // Find ChannelFacebook by page_id ch, err := h.fbService.GetFacebookByPageID(ctx, pageID) if err != nil { applogger.L().Errorf("Failed to find Facebook channel by page_id: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Facebook page channel not found"}) return } // Verify account ownership if ch.AccountID != uint(accountID) { c.JSON(http.StatusForbidden, gin.H{"error": "Facebook page channel does not belong to this account"}) return } // Delete the channel (includes webhook unsubscribe) if err := h.fbService.DeleteFacebookChannel(ctx, ch.ID); err != nil { applogger.L().Errorf("Failed to delete Facebook channel: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete Facebook channel"}) return } // Delete the associated inbox (if exists) if ch.InboxID > 0 { if delErr := h.inboxSvc.DeleteByAccount(ctx, uint(accountID), ch.InboxID); delErr != nil { applogger.L().Warnf("Failed to delete inbox for Facebook channel: %v", delErr) } } c.JSON(http.StatusOK, gin.H{"message": "Facebook page channel deleted successfully"}) } // === Reauthorization === // ReauthorizeFacebookPageRequest is the DTO for refreshing a FB page access token. type ReauthorizeFacebookPageRequest struct { PageAccessToken string `json:"page_access_token" validate:"required"` } // ReauthorizeFacebookPage refreshes an expired Facebook Page access token. // Reference: Chatwoot prompt_reauthorization! → refreshes long-lived token before 60-day expiry // // POST /api/v1/accounts/:id/facebook_pages/reauthorize func (h *FacebookChannelHandler) ReauthorizeFacebookPage(c *gin.Context) { accountIDStr := c.Param("id") accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { applogger.L().Errorf("Invalid account_id: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return } var req ReauthorizeFacebookPageRequest if err := c.ShouldBindJSON(&req); err != nil { applogger.L().Errorf("Failed to bind reauthorize request: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() // Refresh the token via service if err := h.fbService.RefreshPageAccessToken(ctx, uint(accountID), "facebook"); err != nil { applogger.L().Errorf("Failed to refresh Facebook page access token: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "Facebook page access token refreshed successfully"}) } // === Helper === // generateFBVerifyToken creates a random verify token for webhook subscription. func generateFBVerifyToken() string { return randomHex(24) }