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/channels/facebook_channel → create FB inbox // - DELETE /api/v1/accounts/:id/channels/facebook_channel/:fb_id → destroy FB inbox // - POST /api/v1/accounts/:id/channels/facebook_channel/reauthorize → refresh token // - GET /api/v1/accounts/:id/channels/facebook_channel/authorization → OAuth authorize URL import ( "context" "encoding/json" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" facebookchannel "github.com/gochat/gochat/internal/channel/facebook" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" ) type FacebookCallbackProvider interface { ExchangeLongLivedUserToken(ctx context.Context, omniauthToken string) (string, error) ListFacebookPages(ctx context.Context, userAccessToken string) ([]facebookchannel.FBPageInfo, error) FetchInstagramBusinessAccountID(ctx context.Context, pageAccessToken string) (string, error) } // FacebookChannelHandler handles Facebook Messenger channel management. // Follows InstagramChannelHandler pattern: uses top-level ChannelFacebookService for CRUD, // internal FacebookProvider for OAuth/authorization. type FacebookChannelHandler struct { fbChannelSvc *service.ChannelFacebookService fbProvider *facebookchannel.FacebookProvider fbCallbacks FacebookCallbackProvider inboxSvc *service.InboxService fbRepo *repository.ChannelFacebookRepo } // NewFacebookChannelHandler creates a new Facebook channel handler. func NewFacebookChannelHandler( fbChannelSvc *service.ChannelFacebookService, fbProvider *facebookchannel.FacebookProvider, inboxSvc *service.InboxService, fbRepo *repository.ChannelFacebookRepo, callbackProviders ...FacebookCallbackProvider, ) *FacebookChannelHandler { var callbacks FacebookCallbackProvider = fbProvider if len(callbackProviders) > 0 && callbackProviders[0] != nil { callbacks = callbackProviders[0] } return &FacebookChannelHandler{ fbChannelSvc: fbChannelSvc, fbProvider: fbProvider, fbCallbacks: callbacks, inboxSvc: inboxSvc, fbRepo: fbRepo, } } // === OAuth Authorization === // FacebookAuthorizationRequest is the DTO for initiating FB OAuth flow. type FacebookAuthorizationRequest struct { RedirectURL string `json:"redirect_url" validate:"required,url"` } // Authorization generates a Facebook OAuth authorize URL. // GET /api/v1/accounts/:id/channels/facebook_channel/authorization func (h *FacebookChannelHandler) Authorization(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } var req FacebookAuthorizationRequest if err := c.ShouldBindJSON(&req); err != nil { 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(), accountID, req.RedirectURL) if err != nil { applogger.L().Errorf("Failed to build Facebook auth URL: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to generate authorization URL"}) return } c.JSON(http.StatusOK, gin.H{ "authorization_url": authURL, "account_id": accountID, }) } // FacebookOAuthCallbackRequest is the DTO for the OAuth callback after FB redirects back. type FacebookOAuthCallbackRequest struct { Code string `json:"code" validate:"required"` RedirectURL string `json:"redirect_url" validate:"required,url"` } type FacebookCallbackRegisterRequest struct { OmniauthToken string `json:"omniauth_token" form:"omniauth_token"` UserAccessToken string `json:"user_access_token" form:"user_access_token"` PageAccessToken string `json:"page_access_token" form:"page_access_token"` PageID string `json:"page_id" form:"page_id"` InboxName string `json:"inbox_name" form:"inbox_name"` PageName string `json:"page_name" form:"page_name"` EnableAutoAssignment bool `json:"enable_auto_assignment" form:"enable_auto_assignment"` } type FacebookCallbackPagesRequest struct { OmniauthToken string `json:"omniauth_token" form:"omniauth_token"` } type FacebookCallbackReauthorizeRequest struct { OmniauthToken string `json:"omniauth_token" form:"omniauth_token"` InboxID uint `json:"inbox_id" form:"inbox_id"` } // RegisterFacebookPage matches Chatwoot CallbacksController#register_facebook_page. func (h *FacebookChannelHandler) RegisterFacebookPage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } var req FacebookCallbackRegisterRequest if err := c.ShouldBind(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(req.UserAccessToken) == "" && strings.TrimSpace(req.OmniauthToken) != "" { userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } req.UserAccessToken = userToken } inboxName := strings.TrimSpace(req.InboxName) if inboxName == "" { inboxName = strings.TrimSpace(req.PageName) } if inboxName == "" || strings.TrimSpace(req.PageID) == "" || strings.TrimSpace(req.PageAccessToken) == "" { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "inbox_name, page_id and page_access_token are required"}) return } inbox, err := h.inboxSvc.CreateFacebookInbox(c.Request.Context(), accountID, service.CreateFacebookInboxRequest{ Name: inboxName, PageID: req.PageID, PageAccessToken: req.PageAccessToken, UserAccessToken: req.UserAccessToken, PageName: req.PageName, EnableAutoAssignment: req.EnableAutoAssignment, }, h.fbRepo) if err != nil { if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } h.setFacebookInstagramID(c.Request.Context(), inbox.ChannelID, req.PageAccessToken) c.JSON(http.StatusOK, h.facebookRegisterPayload(c.Request.Context(), inbox)) } // FacebookPages matches Chatwoot CallbacksController#facebook_pages. func (h *FacebookChannelHandler) FacebookPages(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } var req FacebookCallbackPagesRequest _ = c.ShouldBind(&req) userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } pages, err := h.fbCallbacks.ListFacebookPages(c.Request.Context(), userToken) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) return } existing := h.existingFacebookPages(c.Request.Context(), accountID) pageDetails := make([]gin.H, 0, len(pages)) for _, page := range pages { pageDetails = append(pageDetails, gin.H{ "id": page.ID, "name": page.Name, "access_token": page.AccessToken, "exists": existing[page.ID], }) } c.JSON(http.StatusOK, gin.H{"data": gin.H{"page_details": pageDetails, "user_access_token": userToken}}) } // ReauthorizePage matches Chatwoot CallbacksController#reauthorize_page. func (h *FacebookChannelHandler) ReauthorizePage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } var req FacebookCallbackReauthorizeRequest if err := c.ShouldBind(&req); err != nil || req.InboxID == 0 { c.Status(http.StatusUnprocessableEntity) return } inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), accountID, req.InboxID) if err != nil || inbox.ChannelType != "facebook" { c.Status(http.StatusUnprocessableEntity) return } channel, err := h.fbRepo.FindByAccountAndInboxID(c.Request.Context(), accountID, inbox.ID) if err != nil { c.Status(http.StatusUnprocessableEntity) return } userToken, err := h.fbCallbacks.ExchangeLongLivedUserToken(c.Request.Context(), req.OmniauthToken) if err != nil { c.Status(http.StatusUnprocessableEntity) return } pages, err := h.fbCallbacks.ListFacebookPages(c.Request.Context(), userToken) if err != nil { c.Status(http.StatusUnprocessableEntity) return } var matched *facebookchannel.FBPageInfo for i := range pages { if pages[i].ID == channel.PageID { matched = &pages[i] break } } if matched == nil || matched.AccessToken == "" { c.Status(http.StatusUnprocessableEntity) return } channel.UserAccessToken = userToken channel.PageAccessToken = matched.AccessToken channel.ReauthorizationRequired = false if matched.Name != "" { channel.PageName = matched.Name } h.setFacebookInstagramIDOnChannel(c.Request.Context(), channel, matched.AccessToken) if err := h.fbRepo.Update(c.Request.Context(), channel); err != nil { c.Status(http.StatusUnprocessableEntity) return } h.updateFacebookInboxConfig(c.Request.Context(), inbox, channel) c.JSON(http.StatusOK, gin.H{"data": serializeInbox(inbox)}) } // OAuthCallback exchanges the Facebook OAuth code for a long-lived access token. // POST /api/v1/accounts/:id/channels/facebook_channel/oauth_callback func (h *FacebookChannelHandler) OAuthCallback(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } var req FacebookOAuthCallbackRequest 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.StatusUnprocessableEntity, gin.H{"error": "failed to exchange token"}) return } c.JSON(http.StatusOK, gin.H{ "access_token": tokenResult.AccessToken, "expires_at": tokenResult.ExpiresAt, "account_id": accountID, "message": "token obtained; use CreateFacebookPage endpoint to create inbox with page_access_token", }) } // === Facebook Channel CRUD === // CreateFacebookPageRequest is the DTO for creating a Facebook Messenger inbox. 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. // POST /api/v1/accounts/:id/channels/facebook_channel func (h *FacebookChannelHandler) CreateFacebookPage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { 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 } ctx := c.Request.Context() // Delegate to InboxService.CreateFacebookInbox (matches Instagram pattern) inboxReq := service.CreateFacebookInboxRequest{ Name: req.Name, PageID: req.PageID, PageAccessToken: req.PageAccessToken, PageName: "", // populated after Graph API call or update WebhookVerifyToken: req.WebhookVerifyToken, EnableAutoAssignment: req.EnableAutoAssignment, } inbox, err := h.inboxSvc.CreateFacebookInbox(ctx, accountID, inboxReq, h.fbRepo) if err != nil { applogger.L().Errorf("Failed to create Facebook inbox: %v", err) if renderInboxLimitExceeded(c, err) { return } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create Facebook inbox", "details": err.Error()}) return } 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, }, }) } // GetFacebookChannel retrieves a Facebook channel by ID. // GET /api/v1/accounts/:id/channels/facebook_channel/:fb_id func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } fbIDStr := c.Param("fb_id") fbID, err := strconv.ParseUint(fbIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fb_id"}) return } ctx := c.Request.Context() ch, err := h.fbChannelSvc.GetByID(ctx, uint(fbID)) if err != nil { applogger.L().Errorf("Failed to get Facebook channel: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Facebook channel not found"}) return } // Verify account ownership if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"}) return } c.JSON(http.StatusOK, gin.H{ "id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "page_id": ch.PageID, "page_name": ch.PageName, "app_id": ch.AppID, "reauthorization_required": ch.ReauthorizationRequired, }) } // ListFacebookChannels retrieves all Facebook channels for an account. // GET /api/v1/accounts/:id/channels/facebook_channel func (h *FacebookChannelHandler) ListFacebookChannels(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } ctx := c.Request.Context() channels, err := h.fbChannelSvc.ListByAccount(ctx, accountID) if err != nil { applogger.L().Errorf("Failed to list Facebook channels: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Facebook channels"}) return } result := make([]gin.H, 0, len(channels)) for _, ch := range channels { result = append(result, gin.H{ "id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "page_id": ch.PageID, "page_name": ch.PageName, "app_id": ch.AppID, "reauthorization_required": ch.ReauthorizationRequired, }) } c.JSON(http.StatusOK, gin.H{ "channels": result, "count": len(result), }) } // DeleteFacebookPage removes a Facebook Page channel and its inbox. // DELETE /api/v1/accounts/:id/channels/facebook_channel/:fb_id func (h *FacebookChannelHandler) DeleteFacebookPage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } fbIDStr := c.Param("fb_id") fbID, err := strconv.ParseUint(fbIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fb_id"}) return } ctx := c.Request.Context() ch, err := h.fbChannelSvc.GetByID(ctx, uint(fbID)) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Facebook channel not found"}) return } // Verify account ownership if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"}) return } // Delete the channel record if err := h.fbChannelSvc.Delete(ctx, uint(fbID)); err != nil { applogger.L().Errorf("Failed to delete Facebook channel: %v", err) c.JSON(http.StatusUnprocessableEntity, 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, 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. // POST /api/v1/accounts/:id/channels/facebook_channel/reauthorize func (h *FacebookChannelHandler) ReauthorizeFacebookPage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { 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() // Find all Facebook channels for this account and update their tokens channels, listErr := h.fbChannelSvc.ListByAccount(ctx, accountID) if listErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Facebook channels"}) return } for _, ch := range channels { ch.PageAccessToken = req.PageAccessToken ch.ReauthorizationRequired = false if updateErr := h.fbRepo.Update(ctx, &ch); updateErr != nil { applogger.L().Warnf("Failed to update token for channel %d: %v", ch.ID, updateErr) } } c.JSON(http.StatusOK, gin.H{"message": "Facebook page access token refreshed successfully"}) } // GetFacebookPage retrieves a Facebook channel page by ID. // GET /api/v1/accounts/:id/channels/facebook_channel/:fb_id // Alias for GetFacebookChannel — router registers both names. func (h *FacebookChannelHandler) GetFacebookPage(c *gin.Context) { h.GetFacebookChannel(c) } // UpdateFacebookPage updates a Facebook channel page configuration. // PATCH /api/v1/accounts/:id/channels/facebook_channel/:fb_id func (h *FacebookChannelHandler) UpdateFacebookPage(c *gin.Context) { accountID, ok := h.parseFacebookAccountID(c) if !ok { return } fbIDStr := c.Param("fb_id") fbID, err := strconv.ParseUint(fbIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid fb_id"}) return } ctx := c.Request.Context() ch, err := h.fbChannelSvc.GetByID(ctx, uint(fbID)) if err != nil { applogger.L().Errorf("Failed to get Facebook channel for update: %v", err) c.JSON(http.StatusNotFound, gin.H{"error": "Facebook channel not found"}) return } // Verify account ownership if ch.AccountID != accountID { c.JSON(http.StatusForbidden, gin.H{"error": "Facebook channel does not belong to this account"}) return } var req struct { PageName *string `json:"page_name"` ReauthorizationRequired *bool `json:"reauthorization_required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()}) return } updateReq := service.UpdateFacebookChannelRequest{ PageName: req.PageName, ReauthorizationRequired: req.ReauthorizationRequired, } updated, err := h.fbChannelSvc.Update(ctx, accountID, ch.InboxID, updateReq) if err != nil { applogger.L().Errorf("Failed to update Facebook channel: %v", err) c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update Facebook channel"}) return } c.JSON(http.StatusOK, gin.H{ "id": updated.ID, "account_id": updated.AccountID, "inbox_id": updated.InboxID, "page_id": updated.PageID, "page_name": updated.PageName, "reauthorization_required": updated.ReauthorizationRequired, }) } func (h *FacebookChannelHandler) parseFacebookAccountID(c *gin.Context) (uint, bool) { accountIDStr := c.Param("account_id") if accountIDStr == "" { accountIDStr = c.Param("id") } accountID, err := strconv.ParseUint(accountIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"}) return 0, false } return uint(accountID), true } func (h *FacebookChannelHandler) facebookRegisterPayload(ctx context.Context, inbox *model.Inbox) gin.H { var channel channelmodel.ChannelFacebook if inbox.ChannelID != 0 { if ch, err := h.fbRepo.FindByID(ctx, inbox.ChannelID); err == nil { channel = *ch } } return gin.H{ "id": inbox.ID, "channel_id": inbox.ChannelID, "name": inbox.Name, "channel_type": inbox.ChannelType, "avatar_url": nil, "page_id": channel.PageID, "enable_auto_assignment": inbox.EnableAutoAssignment, } } func (h *FacebookChannelHandler) existingFacebookPages(ctx context.Context, accountID uint) map[string]bool { existing := map[string]bool{} channels, err := h.fbChannelSvc.ListByAccount(ctx, accountID) if err != nil { return existing } for _, channel := range channels { existing[channel.PageID] = true } return existing } func (h *FacebookChannelHandler) setFacebookInstagramID(ctx context.Context, channelID uint, pageAccessToken string) { channel, err := h.fbRepo.FindByID(ctx, channelID) if err != nil { return } h.setFacebookInstagramIDOnChannel(ctx, channel, pageAccessToken) _ = h.fbRepo.Update(ctx, channel) } func (h *FacebookChannelHandler) setFacebookInstagramIDOnChannel(ctx context.Context, channel *channelmodel.ChannelFacebook, pageAccessToken string) { if h.fbCallbacks == nil || channel == nil { return } instagramID, err := h.fbCallbacks.FetchInstagramBusinessAccountID(ctx, pageAccessToken) if err != nil || instagramID == "" { return } channel.InstagramBusinessAccountID = instagramID } func (h *FacebookChannelHandler) updateFacebookInboxConfig(ctx context.Context, inbox *model.Inbox, channel *channelmodel.ChannelFacebook) { config := map[string]any{} _ = json.Unmarshal([]byte(inbox.ChannelConfig), &config) config["page_id"] = channel.PageID config["page_access_token"] = channel.PageAccessToken config["page_name"] = channel.PageName config["webhook_verify_token"] = channel.WebhookVerifyToken encoded, _ := json.Marshal(config) inbox.ChannelConfig = string(encoded) updated, err := h.inboxSvc.Update(ctx, inbox.AccountID, inbox.ID, service.UpdateInboxRequest{Channel: config}) if err == nil && updated != nil { *inbox = *updated } else { inbox.ChannelConfig = string(encoded) } }