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