Files
gochat/internal/handler/api/v1/google_channel_handler.go
T

335 lines
11 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"
)
// 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})
}
// === 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,
})
}