Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user