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.
364 lines
12 KiB
Go
364 lines
12 KiB
Go
package v1
|
|
|
|
// GoogleChannelHandler handles Google Chat channel CRUD and OAuth flow.
|
|
// This is a gochat addition — Chatwoot does not have a native Google Chat channel.
|
|
//
|
|
// gochat maps these to:
|
|
// - GET /api/v1/accounts/:id/google_channels/authorization → OAuth authorize URL
|
|
// - POST /api/v1/accounts/:id/google_channels/oauth_callback → OAuth token exchange + inbox creation
|
|
// - DELETE /api/v1/accounts/:id/google_channels/:google_id → destroy Google inbox
|
|
// - POST /api/v1/accounts/:id/google_channels/reauthorize → refresh token
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
googlechannel "github.com/gochat/gochat/internal/channel/google"
|
|
"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"
|
|
"github.com/gochat/gochat/pkg/response"
|
|
)
|
|
|
|
// GoogleChannelHandler handles Google Chat channel management.
|
|
type GoogleChannelHandler struct {
|
|
goService *service.ChannelGoogleService
|
|
goProvider *googlechannel.GoogleProvider
|
|
inboxSvc *service.InboxService
|
|
goRepo *repository.ChannelGoogleRepo
|
|
}
|
|
|
|
// NewGoogleChannelHandler creates a new Google channel handler.
|
|
func NewGoogleChannelHandler(
|
|
goService *service.ChannelGoogleService,
|
|
goProvider *googlechannel.GoogleProvider,
|
|
inboxSvc *service.InboxService,
|
|
goRepo *repository.ChannelGoogleRepo,
|
|
) *GoogleChannelHandler {
|
|
return &GoogleChannelHandler{
|
|
goService: goService,
|
|
goProvider: goProvider,
|
|
inboxSvc: inboxSvc,
|
|
goRepo: goRepo,
|
|
}
|
|
}
|
|
|
|
// === OAuth Authorization ===
|
|
|
|
// GoogleAuthorizationRequest is the DTO for initiating Google OAuth flow.
|
|
type GoogleAuthorizationRequest struct {
|
|
RedirectURL string `json:"redirect_url" validate:"required,url"`
|
|
}
|
|
|
|
// Authorization generates a Google OAuth 2.0 authorize URL.
|
|
// GET /api/v1/accounts/:id/google_channels/authorization
|
|
func (h *GoogleChannelHandler) 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 GoogleAuthorizationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
authURL, err := h.goProvider.BuildAuthURL(c.Request.Context(), uint(accountID), req.RedirectURL)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to build Google 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})
|
|
}
|
|
|
|
// ChatwootAuthorization creates a Google email OAuth authorization URL.
|
|
// POST /api/v1/accounts/:account_id/google/authorization
|
|
func (h *GoogleChannelHandler) ChatwootAuthorization(c *gin.Context) {
|
|
accountID, err := parseUintParam(c, "account_id")
|
|
if err != nil {
|
|
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
|
return
|
|
}
|
|
|
|
redirectURL, err := buildChatwootEmailOAuthAuthorizationURL(accountID, chatwootEmailOAuthAuthorizationConfig{
|
|
ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID",
|
|
ClientSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET",
|
|
AuthorizeURL: "https://accounts.google.com/o/oauth2/auth",
|
|
RedirectPath: "/google/callback",
|
|
Scope: "email profile https://mail.google.com/",
|
|
ExtraParams: map[string]string{
|
|
"prompt": "consent",
|
|
"access_type": "offline",
|
|
},
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to build Google authorization URL: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"success": false})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "url": redirectURL})
|
|
}
|
|
|
|
// === OAuth Callback ===
|
|
|
|
// GoogleOAuthCallbackRequest is the DTO for Google OAuth callback.
|
|
type GoogleOAuthCallbackRequest struct {
|
|
Code string `json:"code" validate:"required"`
|
|
RedirectURL string `json:"redirect_url" validate:"required,url"`
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
}
|
|
|
|
// OAuthCallback exchanges the Google OAuth 2.0 code for tokens and creates a channel.
|
|
// POST /api/v1/accounts/:id/google_channels/oauth_callback
|
|
func (h *GoogleChannelHandler) 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 GoogleOAuthCallbackRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
if err := h.inboxSvc.EnsureCanCreateInbox(ctx, uint(accountID)); err != nil {
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google inbox"})
|
|
return
|
|
}
|
|
|
|
// Exchange the code for tokens
|
|
tokenResult, err := h.goProvider.ExchangeToken(ctx, req.Code, req.RedirectURL)
|
|
if err != nil {
|
|
applogger.L().Errorf("Google OAuth token exchange failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"})
|
|
return
|
|
}
|
|
|
|
// Validate the access token
|
|
valid, err := h.goProvider.ValidateAccessToken(ctx, tokenResult.AccessToken)
|
|
if err != nil || !valid {
|
|
applogger.L().Errorf("Google token validation failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Google token"})
|
|
return
|
|
}
|
|
|
|
// Create the Google channel record
|
|
goChannel := &channelmodel.ChannelGoogle{
|
|
AccountID: uint(accountID),
|
|
AccessToken: tokenResult.AccessToken,
|
|
RefreshToken: tokenResult.RefreshToken,
|
|
}
|
|
|
|
if err := h.goService.Create(ctx, goChannel); err != nil {
|
|
applogger.L().Errorf("Failed to create Google channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google channel"})
|
|
return
|
|
}
|
|
|
|
// Create the inbox
|
|
inboxReq := service.CreateInboxRequest{
|
|
Name: req.Name,
|
|
ChannelType: string(model.InboxChannelTypeGoogle),
|
|
}
|
|
|
|
inbox, err := h.inboxSvc.Create(ctx, uint(accountID), inboxReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("Failed to create Google inbox: %v", err)
|
|
if delErr := h.goService.Delete(ctx, goChannel.ID); delErr != nil {
|
|
applogger.L().Warnf("Failed to rollback Google channel after inbox creation failure: %v", delErr)
|
|
}
|
|
if renderInboxLimitExceeded(c, err) {
|
|
return
|
|
}
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google inbox"})
|
|
return
|
|
}
|
|
|
|
// Link the channel to the inbox
|
|
goChannel.InboxID = inbox.ID
|
|
if err := h.goService.Update(ctx, goChannel); err != nil {
|
|
applogger.L().Warnf("Failed to link Google channel to inbox: %v", err)
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"inbox": gin.H{
|
|
"id": inbox.ID,
|
|
"name": inbox.Name,
|
|
"channel_type": inbox.ChannelType,
|
|
},
|
|
"google_channel": gin.H{
|
|
"id": goChannel.ID,
|
|
},
|
|
})
|
|
}
|
|
|
|
// === Chatwoot-style GET OAuth callback (G10) ===
|
|
|
|
// OAuthCallbackGET handles the OAuth redirect callback from Google.
|
|
// GET /api/v1/accounts/:id/google/callback?code=...&state=...
|
|
// This is the redirect endpoint that the Google OAuth provider calls back to.
|
|
func (h *GoogleChannelHandler) OAuthCallbackGET(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
|
|
}
|
|
|
|
code := c.Query("code")
|
|
state := c.Query("state")
|
|
if code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code parameter"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Use the configured redirect URL from the Google provider
|
|
redirectURL := h.goProvider.GetOAuthRedirectURL()
|
|
|
|
tokenResult, err := h.goProvider.ExchangeToken(ctx, code, redirectURL)
|
|
if err != nil {
|
|
applogger.L().Errorf("Google OAuth GET callback token exchange failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to exchange OAuth token"})
|
|
return
|
|
}
|
|
|
|
// Validate the access token
|
|
valid, err := h.goProvider.ValidateAccessToken(ctx, tokenResult.AccessToken)
|
|
if err != nil || !valid {
|
|
applogger.L().Errorf("Google token validation failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to validate Google token"})
|
|
return
|
|
}
|
|
|
|
// Create the Google channel record
|
|
goChannel := &channelmodel.ChannelGoogle{
|
|
AccountID: uint(accountID),
|
|
AccessToken: tokenResult.AccessToken,
|
|
RefreshToken: tokenResult.RefreshToken,
|
|
}
|
|
|
|
if err := h.goService.Create(ctx, goChannel); err != nil {
|
|
applogger.L().Errorf("Failed to create Google channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create google channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"channel_id": goChannel.ID,
|
|
"access_token": goChannel.AccessToken,
|
|
"refresh_token": goChannel.RefreshToken,
|
|
"state": state,
|
|
})
|
|
}
|
|
|
|
// === Delete ===
|
|
|
|
// Delete removes a Google channel and its associated inbox.
|
|
// DELETE /api/v1/accounts/:id/google_channels/:google_id
|
|
func (h *GoogleChannelHandler) Delete(c *gin.Context) {
|
|
googleIDStr := c.Param("google_id")
|
|
googleID, err := strconv.ParseUint(googleIDStr, 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid google_id"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Delete the Google channel record
|
|
if err := h.goService.Delete(ctx, uint(googleID)); err != nil {
|
|
applogger.L().Errorf("Failed to delete Google channel: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to delete google channel"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "google channel deleted"})
|
|
}
|
|
|
|
// === Webhook Registration (G10) ===
|
|
|
|
// RegisterWebhook registers a webhook URL with the Google Chat API.
|
|
// POST /api/v1/accounts/:id/google/webhooks
|
|
func (h *GoogleChannelHandler) RegisterWebhook(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 struct {
|
|
AccessToken string `json:"access_token" binding:"required"`
|
|
WebhookURL string `json:"webhook_url" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
webhookID, err := h.goProvider.RegisterWebhook(ctx, req.AccessToken, req.WebhookURL)
|
|
if err != nil {
|
|
applogger.L().Errorf("Google webhook registration failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to register Google webhook"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"webhook_id": webhookID,
|
|
"account_id": accountID,
|
|
})
|
|
}
|
|
|
|
// ListWebhooks lists all registered webhooks for Google Chat.
|
|
// GET /api/v1/accounts/:id/google/webhooks
|
|
func (h *GoogleChannelHandler) ListWebhooks(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
|
|
}
|
|
|
|
accessToken := c.Query("access_token")
|
|
if accessToken == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "access_token query parameter is required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
webhooks, err := h.goProvider.ListWebhooks(ctx, accessToken)
|
|
if err != nil {
|
|
applogger.L().Errorf("Google webhook listing failed: %v", err)
|
|
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list Google webhooks"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"account_id": accountID,
|
|
"webhooks": webhooks,
|
|
})
|
|
}
|