package google // GoogleProvider implements ChannelProvider for Google Chat/Gmail. // Reference: Chatwoot does not have a native Google Chat channel — this is a gochat addition. // // Google integration uses Google OAuth 2.0 for authentication // and Google Chat API for messaging. // // Google OAuth 2.0 flow: // - Authorization: https://accounts.google.com/o/oauth2/v2/auth // - Token exchange: https://oauth2.googleapis.com/token // - Scopes: https://www.googleapis.com/auth/chat.spaces, chat.messages // // Google Chat API webhooks: // - Event subscriptions via Google Chat API // - Real-time notification processing import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/channel" channelmodel "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // GoogleOAuthConfig holds OAuth 2.0 configuration for Google. type GoogleOAuthConfig struct { ClientID string ClientSecret string RedirectURL string Scopes string // e.g. "https://www.googleapis.com/auth/chat.spaces https://www.googleapis.com/auth/chat.messages" } // GoogleProvider implements ChannelProvider for Google Chat. type GoogleProvider struct { client *resty.Client chatBase string // e.g. "https://chat.googleapis.com/v1" oauthConfig GoogleOAuthConfig storagePath string } // NewGoogleProvider creates a new Google provider. func NewGoogleProvider(cfg GoogleOAuthConfig) *GoogleProvider { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second) chatBase := os.Getenv("GOOGLE_CHAT_API_BASE") if chatBase == "" { chatBase = "https://chat.googleapis.com/v1" } storagePath := os.Getenv("GOCHAT_ATTACHMENT_PATH") if storagePath == "" { storagePath = "/tmp/gochat/google_attachments" } return &GoogleProvider{ client: client, chatBase: chatBase, oauthConfig: cfg, storagePath: storagePath, } } // --- Google OAuth 2.0 Flow --- // BuildAuthURL generates the Google OAuth 2.0 authorization URL. func (p *GoogleProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) { state := fmt.Sprintf("google_%d_%s", accountID, generateRandomState()) params := url.Values{} params.Set("client_id", p.oauthConfig.ClientID) params.Set("redirect_uri", redirectURL) params.Set("response_type", "code") params.Set("scope", p.oauthConfig.Scopes) params.Set("state", state) params.Set("access_type", "offline") // Request refresh token params.Set("prompt", "consent") // Force consent to get refresh token authURL := "https://accounts.google.com/o/oauth2/v2/auth?" + params.Encode() applogger.L().Infof("Google OAuth 2.0 auth URL generated for account %d", accountID) return authURL, nil } // TokenExchangeResult holds the result of a Google OAuth 2.0 token exchange. type TokenExchangeResult struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token,omitempty"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` } // ExchangeToken exchanges a Google OAuth 2.0 code for access and refresh tokens. func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*channel.OAuthTokenResult, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Content-Type", "application/x-www-form-urlencoded"). SetFormData(map[string]string{ "client_id": p.oauthConfig.ClientID, "client_secret": p.oauthConfig.ClientSecret, "code": code, "redirect_uri": redirectURL, "grant_type": "authorization_code", }). SetResult(&TokenExchangeResult{}). Post("https://oauth2.googleapis.com/token") if err != nil { return nil, fmt.Errorf("Google token exchange request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Google token exchange failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Google OAuth 2.0 token exchange successful") oauthResult := &channel.OAuthTokenResult{ AccessToken: result.AccessToken, RefreshToken: result.RefreshToken, ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second), Scope: result.Scope, } return oauthResult, nil } // RefreshAccessToken refreshes a Google OAuth 2.0 access token using a refresh token. func (p *GoogleProvider) RefreshAccessToken(ctx context.Context, refreshToken string) (*TokenExchangeResult, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Content-Type", "application/x-www-form-urlencoded"). SetFormData(map[string]string{ "client_id": p.oauthConfig.ClientID, "client_secret": p.oauthConfig.ClientSecret, "refresh_token": refreshToken, "grant_type": "refresh_token", }). SetResult(&TokenExchangeResult{}). Post("https://oauth2.googleapis.com/token") if err != nil { return nil, fmt.Errorf("Google token refresh request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Google token refresh failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Google OAuth 2.0 token refresh successful") return result, nil } // --- ChannelProvider interface implementation --- // Type returns the channel type identifier. func (p *GoogleProvider) Type() channel.ChannelType { return channel.ChannelType("Channel::Google") } // Name returns the human-readable channel name. func (p *GoogleProvider) Name() string { return "Google Chat" } // Description returns a short description of the channel. func (p *GoogleProvider) Description() string { return "Google Chat and Gmail integration using OAuth 2.0" } // ConfigSchema returns the configuration schema for Google channels. func (p *GoogleProvider) ConfigSchema() *channel.ConfigSchemaDefinition { return &channel.ConfigSchemaDefinition{ Required: []string{"google_user_id"}, Properties: map[string]channel.ConfigProperty{ "google_user_id": {Type: "string", Description: "Google user email or ID"}, "access_token": {Type: "string", Description: "OAuth 2.0 access token", Secret: true}, "refresh_token": {Type: "string", Description: "OAuth 2.0 refresh token", Secret: true}, "space_id": {Type: "string", Description: "Google Chat space ID"}, }, } } // ValidateConfig validates a Google channel configuration. func (p *GoogleProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error { googleUserID, _ := config["google_user_id"].(string) if googleUserID == "" { return fmt.Errorf("google_user_id is required") } accessToken, _ := config["access_token"].(string) if accessToken == "" { return fmt.Errorf("access_token is required") } return nil } // DefaultConfig returns default configuration with preset values. func (p *GoogleProvider) DefaultConfig() channel.ChannelConfig { return channel.ChannelConfig{ "google_user_id": "", "access_token": "", "refresh_token": "", "space_id": "", } } // Capabilities returns the set of features this channel supports. func (p *GoogleProvider) Capabilities() channel.ChannelCapabilities { return channel.ChannelCapabilities{ SupportsAttachments: true, SupportsLocation: true, SupportsTypingIndicator: false, SupportsDeliveryStatus: false, SupportsReplies: false, SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: true, SupportsTemplates: false, SupportsEmailHeaders: false, MaxAttachmentSize: 25 * 1024 * 1024, // 25MB MaxTextLength: 40000, } } // OnCreate callback after channel creation. func (p *GoogleProvider) OnCreate(ctx context.Context, inbox *channelmodel.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) { return config, nil } // OnDestroy callback before channel destruction. func (p *GoogleProvider) OnDestroy(ctx context.Context, inbox *channelmodel.Inbox, config channel.ChannelConfig) error { return nil } // ProcessIncoming transforms raw external payload into IncomingMessage. func (p *GoogleProvider) ProcessIncoming(ctx context.Context, inbox *channelmodel.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) { return nil, fmt.Errorf("Google ProcessIncoming: not implemented") } // ValidateWebhookRequest verifies webhook callback authenticity. func (p *GoogleProvider) ValidateWebhookRequest(ctx context.Context, inbox *channelmodel.Inbox, request *channel.WebhookRequest) error { return nil } // SendMessage sends a message through Google Chat. func (p *GoogleProvider) SendMessage(ctx context.Context, inbox *channelmodel.Inbox, message *channelmodel.Message, contact *channelmodel.Contact) (*channel.SendResult, error) { // TODO: parse inbox.ChannelConfig (JSON string) to extract space_id and access_token return nil, fmt.Errorf("Google Chat SendMessage: not yet fully implemented") } // GetContactProfile fetches contact profile from external channel. func (p *GoogleProvider) GetContactProfile(ctx context.Context, inbox *channelmodel.Inbox, contactSource string) (*channel.ContactProfile, error) { return nil, fmt.Errorf("Google GetContactProfile: not implemented") } // RefreshToken refreshes an expired access_token. func (p *GoogleProvider) RefreshToken(ctx context.Context, inbox *channelmodel.Inbox, config channel.ChannelConfig) (*channel.OAuthTokenResult, error) { refreshToken, _ := config["refresh_token"].(string) if refreshToken == "" { return nil, fmt.Errorf("Google RefreshToken: no refresh_token available") } result, err := p.RefreshAccessToken(ctx, refreshToken) if err != nil { return nil, err } return &channel.OAuthTokenResult{ AccessToken: result.AccessToken, RefreshToken: result.RefreshToken, ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second), Scope: result.Scope, }, nil } // CheckAuthorizationError checks if an API call returned authorization error. func (p *GoogleProvider) CheckAuthorizationError(ctx context.Context, apiError error) bool { if apiError == nil { return false } errMsg := apiError.Error() return strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Unauthorized") || strings.Contains(errMsg, "invalid_grant") } // OnReauthorization callback when re-authorization is needed. func (p *GoogleProvider) OnReauthorization(ctx context.Context, inbox *channelmodel.Inbox) error { applogger.L().Warnf("Google channel reauthorization required for inbox %d", inbox.ID) return nil } // VerifyWebhookToken checks if the webhook token is valid for this provider. func (p *GoogleProvider) VerifyWebhookToken(token string) bool { return token != "" } // ValidateAccessToken validates a Google OAuth 2.0 access token. func (p *GoogleProvider) ValidateAccessToken(ctx context.Context, accessToken string) (bool, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Get("https://www.googleapis.com/oauth2/v1/userinfo") if err != nil { return false, fmt.Errorf("Google token validation request failed: %w", err) } return resp.StatusCode() == http.StatusOK, nil } // === Webhook Registration (G10) === // Google Chat uses the Chat API spaces endpoint for webhook/event subscription. // Reference: https://developers.google.com/chat/api/reference/rest/v1/spaces // RegisterWebhook registers a webhook URL with the Google Chat API. // This creates an event subscription via the Google Chat API spaces.watch endpoint. func (p *GoogleProvider) RegisterWebhook(ctx context.Context, accessToken, webhookURL string) (string, error) { body := map[string]interface{}{ "eventFilters": []string{ "google.chat.card.event.type.MESSAGE", "google.chat.card.event.type.ADDED_TO_SPACE", "google.chat.card.event.type.REMOVED_FROM_SPACE", }, "endpointUrl": webhookURL, } resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetHeader("Content-Type", "application/json"). SetBody(body). SetResult(map[string]interface{}{}). Post(p.chatBase + "/spaces/-/events") if err != nil { return "", fmt.Errorf("Google webhook registration failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { return "", fmt.Errorf("Google webhook registration failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(map[string]interface{}) subID, ok := result["id"].(string) if !ok { // Generate a local ID if the API doesn't return one b := make([]byte, 16) _, _ = rand.Read(b) subID = "google_webhook_" + hex.EncodeToString(b) } applogger.L().Infof("Google webhook registered: %s", subID) return subID, nil } // ListWebhooks lists all registered webhooks/event subscriptions for Google Chat. // GET /spaces/-/events func (p *GoogleProvider) ListWebhooks(ctx context.Context, accessToken string) ([]map[string]interface{}, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetResult(map[string]interface{}{}). Get(p.chatBase + "/spaces/-/events") if err != nil { return nil, fmt.Errorf("Google webhook listing failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Google webhook listing failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(map[string]interface{}) value, ok := result["events"].([]interface{}) if !ok { return []map[string]interface{}{}, nil } webhooks := make([]map[string]interface{}, 0, len(value)) for _, v := range value { if w, ok := v.(map[string]interface{}); ok { webhooks = append(webhooks, w) } } return webhooks, nil } // DeleteWebhook removes a registered webhook/event subscription from Google Chat. // DELETE /spaces/-/events/{event_id} func (p *GoogleProvider) DeleteWebhook(ctx context.Context, accessToken, webhookID string) error { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Delete(p.chatBase + "/spaces/-/events/" + webhookID) if err != nil { return fmt.Errorf("Google webhook deletion failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return fmt.Errorf("Google webhook deletion failed: status %d, body: %s", resp.StatusCode(), resp.String()) } applogger.L().Infof("Google webhook deleted: %s", webhookID) return nil } // GetOAuthRedirectURL returns the configured OAuth redirect URL for Google OAuth flow. func (p *GoogleProvider) GetOAuthRedirectURL() string { return p.oauthConfig.RedirectURL } // OAuthConfig returns OAuth configuration requirements definition. func (p *GoogleProvider) OAuthConfig() *channel.OAuthConfigDefinition { return &channel.OAuthConfigDefinition{ Provider: "google", Scopes: []string{"https://www.googleapis.com/auth/chat.spaces", "https://www.googleapis.com/auth/chat.messages"}, AuthorizeURL: "https://accounts.google.com/o/oauth2/v2/auth", TokenURL: "https://oauth2.googleapis.com/token", RefreshURL: "https://oauth2.googleapis.com/token", RequiresRefresh: true, TokenExpiry: 3600, } } func generateRandomState() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) }