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

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