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, }) }