444 lines
15 KiB
Go
444 lines
15 KiB
Go
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)
|
|
} |