package microsoft // MicrosoftProvider implements ChannelProvider for Microsoft (Azure AD/Teams). // Reference: Chatwoot does not have a native Microsoft channel — this is a gochat addition. // // Microsoft integration uses Azure AD OAuth 2.0 for authentication // and Microsoft Graph API for Teams messaging and webhook subscriptions. // // Azure AD OAuth 2.0 flow: // - Authorization: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize // - Token exchange: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token // - Scopes: Chat.Read, Chat.ReadWrite, Team.ReadBasic.All // // Microsoft Graph API webhooks: // - Subscription creation for chat messages // - Validation URL handling for subscription verification // - Notification processing for incoming events import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // parseInboxConfig parses the JSON ChannelConfig string into a ChannelConfig map. func parseInboxConfig(configStr string) channel.ChannelConfig { config := channel.ChannelConfig{} if err := json.Unmarshal([]byte(configStr), &config); err != nil { applogger.L().Warnf("Microsoft: failed to parse channel config: %v", err) } return config } // MicrosoftOAuthConfig holds OAuth 2.0 configuration for Microsoft Azure AD. type MicrosoftOAuthConfig struct { ClientID string ClientSecret string TenantID string // Azure AD tenant ID (common for multi-tenant) RedirectURL string Scopes string // e.g. "Chat.Read Chat.ReadWrite offline_access" } // MicrosoftProvider implements ChannelProvider for Microsoft/Azure AD. type MicrosoftProvider struct { client *resty.Client graphBase string // e.g. "https://graph.microsoft.com/v1.0" oauthConfig MicrosoftOAuthConfig storagePath string } // NewMicrosoftProvider creates a new Microsoft provider. func NewMicrosoftProvider(cfg MicrosoftOAuthConfig) *MicrosoftProvider { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second) graphBase := os.Getenv("MS_GRAPH_API_BASE") if graphBase == "" { graphBase = "https://graph.microsoft.com/v1.0" } storagePath := os.Getenv("GOCHAT_ATTACHMENT_PATH") if storagePath == "" { storagePath = "/tmp/gochat/microsoft_attachments" } return &MicrosoftProvider{ client: client, graphBase: graphBase, oauthConfig: cfg, storagePath: storagePath, } } // --- Azure AD OAuth 2.0 Flow --- // BuildAuthURL generates the Microsoft Azure AD OAuth 2.0 authorization URL. func (p *MicrosoftProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) { state := fmt.Sprintf("ms_%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) // Use the configured tenant ID for the authorization endpoint authURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize?%s", p.oauthConfig.TenantID, params.Encode()) applogger.L().Infof("Microsoft OAuth 2.0 auth URL generated for account %d", accountID) return authURL, nil } // TokenExchangeResult holds the result of a Microsoft 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"` IDToken string `json:"id_token,omitempty"` } // ExchangeToken exchanges a Microsoft OAuth 2.0 code for access and refresh tokens. func (p *MicrosoftProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*TokenExchangeResult, error) { tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", p.oauthConfig.TenantID) 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(tokenURL) if err != nil { return nil, fmt.Errorf("Microsoft token exchange request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Microsoft token exchange failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Microsoft OAuth 2.0 token exchange successful") return result, nil } // RefreshAccessToken refreshes a Microsoft OAuth 2.0 access token using a refresh token. func (p *MicrosoftProvider) RefreshAccessToken(ctx context.Context, refreshToken string) (*TokenExchangeResult, error) { tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", p.oauthConfig.TenantID) 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", "scope": p.oauthConfig.Scopes, }). SetResult(&TokenExchangeResult{}). Post(tokenURL) if err != nil { return nil, fmt.Errorf("Microsoft token refresh request failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Microsoft token refresh failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(*TokenExchangeResult) applogger.L().Infof("Microsoft OAuth 2.0 token refresh successful") return result, nil } // --- Microsoft Graph API Webhook Subscriptions --- // GraphSubscriptionRequest represents a Microsoft Graph API subscription request. type GraphSubscriptionRequest struct { ChangeType string `json:"changeType"` NotificationURL string `json:"notificationUrl"` Resource string `json:"resource"` ExpirationDateTime string `json:"expirationDateTime"` ClientState string `json:"clientState"` LifecycleNotificationURL string `json:"lifecycleNotificationUrl,omitempty"` } // CreateSubscription creates a Microsoft Graph API webhook subscription. func (p *MicrosoftProvider) CreateSubscription(ctx context.Context, accessToken, resource, notificationURL, clientState string) (string, error) { // Microsoft Graph subscriptions expire in max 3 days for chat resources expiration := time.Now().Add(3 * 24 * time.Hour).UTC().Format(time.RFC3339) body := GraphSubscriptionRequest{ ChangeType: "created,updated", NotificationURL: notificationURL, Resource: resource, ExpirationDateTime: expiration, ClientState: clientState, } resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). SetHeader("Content-Type", "application/json"). SetBody(body). SetResult(map[string]interface{}{}). Post(p.graphBase + "/subscriptions") if err != nil { return "", fmt.Errorf("Microsoft Graph subscription creation failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { return "", fmt.Errorf("Microsoft Graph subscription creation failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(map[string]interface{}) subID, ok := result["id"].(string) if !ok { return "", fmt.Errorf("unexpected subscription ID format in response") } applogger.L().Infof("Microsoft Graph subscription created: %s", subID) return subID, nil } // ListSubscriptions lists all Microsoft Graph API webhook subscriptions for the authenticated user. // GET /subscriptions func (p *MicrosoftProvider) ListSubscriptions(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.graphBase + "/subscriptions") if err != nil { return nil, fmt.Errorf("Microsoft Graph subscription listing failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Microsoft Graph subscription listing failed: status %d, body: %s", resp.StatusCode(), resp.String()) } result := resp.Result().(map[string]interface{}) value, ok := result["value"].([]interface{}) if !ok { return []map[string]interface{}{}, nil } subscriptions := make([]map[string]interface{}, 0, len(value)) for _, v := range value { if sub, ok := v.(map[string]interface{}); ok { subscriptions = append(subscriptions, sub) } } return subscriptions, nil } // DeleteSubscription removes a Microsoft Graph API webhook subscription. func (p *MicrosoftProvider) DeleteSubscription(ctx context.Context, accessToken, subscriptionID string) error { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Delete(p.graphBase + "/subscriptions/" + subscriptionID) if err != nil { return fmt.Errorf("Microsoft Graph subscription deletion failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return fmt.Errorf("Microsoft Graph subscription deletion failed: status %d", resp.StatusCode()) } applogger.L().Infof("Microsoft Graph subscription deleted: %s", subscriptionID) return nil } // --- ChannelProvider interface implementation --- // ConfigSchema returns the configuration schema for Microsoft channels. func (p *MicrosoftProvider) ConfigSchema() *channel.ConfigSchemaDefinition { return &channel.ConfigSchemaDefinition{ Required: []string{"tenant_id", "client_id"}, Properties: map[string]channel.ConfigProperty{ "tenant_id": {Type: "string", Description: "Azure AD tenant ID"}, "client_id": {Type: "string", Description: "Azure AD client/object ID"}, "access_token": {Type: "string", Description: "OAuth 2.0 access token"}, "refresh_token": {Type: "string", Description: "OAuth 2.0 refresh token"}, "team_id": {Type: "string", Description: "Microsoft Teams team ID"}, "channel_id": {Type: "string", Description: "Microsoft Teams channel ID"}, }, } } // ValidateConfig validates a Microsoft channel configuration. func (p *MicrosoftProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error { tenantID, _ := config["tenant_id"].(string) if strings.TrimSpace(tenantID) == "" { return fmt.Errorf("tenant_id is required") } accessToken, _ := config["access_token"].(string) if strings.TrimSpace(accessToken) == "" { return fmt.Errorf("access_token is required") } return nil } // NormalizeConfig normalizes a Microsoft channel configuration. func (p *MicrosoftProvider) NormalizeConfig(ctx context.Context, config channel.ChannelConfig) (channel.ChannelConfig, error) { return config, nil } // VerifyWebhookToken checks if the webhook token is valid for this provider. // GetOAuthRedirectURL returns the configured OAuth redirect URL for Microsoft OAuth flow. func (p *MicrosoftProvider) GetOAuthRedirectURL() string { return p.oauthConfig.RedirectURL } // GetTenantID returns the configured Azure AD tenant ID. func (p *MicrosoftProvider) GetTenantID() string { return p.oauthConfig.TenantID } func (p *MicrosoftProvider) VerifyWebhookToken(token string) bool { return token != "" } // ValidateAccessToken validates a Microsoft OAuth 2.0 access token. func (p *MicrosoftProvider) ValidateAccessToken(ctx context.Context, accessToken string) (bool, error) { resp, err := p.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+accessToken). Get(p.graphBase + "/me") if err != nil { return false, fmt.Errorf("Microsoft token validation request failed: %w", err) } return resp.StatusCode() == http.StatusOK, nil } func generateRandomState() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } // Capabilities returns the set of features this channel supports. func (p *MicrosoftProvider) Capabilities() channel.ChannelCapabilities { return channel.ChannelCapabilities{ SupportsAttachments: true, SupportsReplies: true, SupportsDeliveryStatus: false, SupportsTypingIndicator: false, SupportsEmojiReactions: false, SupportsVoiceMessages: false, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: false, SupportsEmailHeaders: false, MaxTextLength: 4000, } } // Type returns the channel type identifier. func (p *MicrosoftProvider) Type() channel.ChannelType { return channel.ChannelMicrosoft } // Name returns the human-readable channel name. func (p *MicrosoftProvider) Name() string { return "Microsoft Teams" } // Description returns a short description of the channel. func (p *MicrosoftProvider) Description() string { return "Microsoft Teams / Outlook channel via Graph API" } // DefaultConfig returns default configuration with preset values. func (p *MicrosoftProvider) DefaultConfig() channel.ChannelConfig { return channel.ChannelConfig{ "tenant_id": p.oauthConfig.TenantID, "client_id": p.oauthConfig.ClientID, "client_secret": p.oauthConfig.ClientSecret, } } // OnCreate callback after channel creation. func (p *MicrosoftProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) { return config, nil } // OnDestroy callback before channel destruction. func (p *MicrosoftProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error { return nil } // ProcessIncoming transforms raw external payload into IncomingMessage. func (p *MicrosoftProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) { return nil, fmt.Errorf("Microsoft: ProcessIncoming not yet implemented") } // ValidateWebhookRequest verifies webhook callback authenticity. func (p *MicrosoftProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error { // Microsoft Graph uses clientState for webhook validation config := parseInboxConfig(inbox.ChannelConfig) clientState := config["client_state"] if clientState == nil { return fmt.Errorf("Microsoft: missing client_state in config") } // Check validationToken in query params or body if token := request.QueryParams["validationToken"]; token != "" { return nil } return nil } // SendMessage sends a message through the Microsoft channel. func (p *MicrosoftProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) { return nil, fmt.Errorf("Microsoft: SendMessage not yet implemented with new interface signature") } // GetContactProfile fetches external contact profile information. func (p *MicrosoftProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) { return nil, fmt.Errorf("Microsoft: GetContactProfile not yet implemented") }