Files
gochat/backend/internal/handler/api/v1/facebook_channel_handler.go_BAK2
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

421 lines
14 KiB
Plaintext

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 (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
facebookchannel "github.com/gochat/gochat/internal/channel/facebook"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
channelmodel "github.com/gochat/gochat/internal/model/channel"
applogger "github.com/gochat/gochat/pkg/logger"
)
// 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
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,
) *FacebookChannelHandler {
return &FacebookChannelHandler{
fbChannelSvc: fbChannelSvc,
fbProvider: fbProvider,
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) {
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 {
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.
// POST /api/v1/accounts/:id/channels/facebook_channel/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
}
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) {
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()
}
ctx := c.Request.Context()
// Step 1: Create ChannelFacebook record via top-level service
ch := &channelmodel.ChannelFacebook{
AccountID: uint(accountID),
PageID: req.PageID,
PageAccessToken: req.PageAccessToken,
PageName: "", // populated after Graph API call or update
WebhookVerifyToken: verifyToken,
}
if err := h.fbRepo.Create(ctx, ch); 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,
}
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.fbRepo.Update(ctx, ch); 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,
},
})
}
// GetFacebookChannel retrieves a Facebook channel by ID.
// GET /api/v1/accounts/:id/channels/facebook_channel/:fb_id
func (h *FacebookChannelHandler) GetFacebookChannel(c *gin.Context) {
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
}
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 != uint(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) {
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
}
ctx := c.Request.Context()
channels, err := h.fbChannelSvc.ListByAccount(ctx, uint(accountID))
if err != nil {
applogger.L().Errorf("Failed to list Facebook channels: %v", err)
c.JSON(http.StatusInternalServerError, 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) {
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
}
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 != uint(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.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.
// POST /api/v1/accounts/:id/channels/facebook_channel/reauthorize
func (h *FacebookChannelHandler) ReauthorizeFacebookPage(c *gin.Context) {
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
}
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, uint(accountID))
if listErr != nil {
c.JSON(http.StatusInternalServerError, 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"})
}
// === Helper ===
// generateFBVerifyToken creates a random verify token for webhook subscription.
func generateFBVerifyToken() string {
return randomHex(24)
}