Files
gochat/internal/channel/google/provider.go
T
2026-06-04 15:44:48 +08:00

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