Whatsapp Business enhancements
This commit is contained in:
@@ -68,38 +68,87 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
|
||||
|
||||
// Connect validates the Business API credentials
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
// Validate credentials by making a test request to get phone number details
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||
c.config.APIVersion,
|
||||
c.config.PhoneNumberID)
|
||||
logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
// Step 1: Validate token and check permissions
|
||||
tokenInfo, err := c.validateToken(ctx)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return fmt.Errorf("failed to validate credentials: %w", err)
|
||||
return fmt.Errorf("token validation failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
// Log token information
|
||||
logging.Info("Access token validated",
|
||||
"account_id", c.id,
|
||||
"token_type", tokenInfo.Type,
|
||||
"app", tokenInfo.Application,
|
||||
"app_id", tokenInfo.AppID,
|
||||
"expires", c.formatExpiry(tokenInfo.ExpiresAt),
|
||||
"scopes", strings.Join(tokenInfo.Scopes, ", "))
|
||||
|
||||
// Check for required permissions
|
||||
requiredScopes := []string{"whatsapp_business_management", "whatsapp_business_messaging"}
|
||||
missingScopes := c.checkMissingScopes(tokenInfo.Scopes, requiredScopes)
|
||||
if len(missingScopes) > 0 {
|
||||
err := fmt.Errorf("token missing required permissions: %s", strings.Join(missingScopes, ", "))
|
||||
logging.Error("Insufficient token permissions",
|
||||
"account_id", c.id,
|
||||
"missing_scopes", strings.Join(missingScopes, ", "),
|
||||
"current_scopes", strings.Join(tokenInfo.Scopes, ", "))
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: Get phone number details
|
||||
phoneDetails, err := c.getPhoneNumberDetails(ctx)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err))
|
||||
return fmt.Errorf("failed to get phone number details: %w", err)
|
||||
}
|
||||
|
||||
// Log phone number information
|
||||
logging.Info("Phone number details retrieved",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", phoneDetails.ID,
|
||||
"display_number", phoneDetails.DisplayPhoneNumber,
|
||||
"verified_name", phoneDetails.VerifiedName,
|
||||
"verification_status", phoneDetails.CodeVerificationStatus,
|
||||
"quality_rating", phoneDetails.QualityRating,
|
||||
"throughput_level", phoneDetails.Throughput.Level)
|
||||
|
||||
// Warn if phone number is not verified
|
||||
if phoneDetails.CodeVerificationStatus != "VERIFIED" {
|
||||
logging.Warn("Phone number is not verified - messaging capabilities may be limited",
|
||||
"account_id", c.id,
|
||||
"status", phoneDetails.CodeVerificationStatus)
|
||||
}
|
||||
|
||||
// Step 3: Get business account details (if business_account_id is provided)
|
||||
if c.config.BusinessAccountID != "" {
|
||||
businessDetails, err := c.getBusinessAccountDetails(ctx)
|
||||
if err != nil {
|
||||
logging.Warn("Failed to get business account details (non-critical)",
|
||||
"account_id", c.id,
|
||||
"business_account_id", c.config.BusinessAccountID,
|
||||
"error", err)
|
||||
} else {
|
||||
logging.Info("Business account details retrieved",
|
||||
"account_id", c.id,
|
||||
"business_account_id", businessDetails.ID,
|
||||
"business_name", businessDetails.Name,
|
||||
"timezone_id", businessDetails.TimezoneID)
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
logging.Info("Business API client connected", "account_id", c.id, "phone", c.phoneNumber)
|
||||
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, c.phoneNumber))
|
||||
logging.Info("Business API client connected successfully",
|
||||
"account_id", c.id,
|
||||
"phone", phoneDetails.DisplayPhoneNumber,
|
||||
"verified_name", phoneDetails.VerifiedName)
|
||||
c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -340,3 +389,154 @@ func jidToPhoneNumber(jid types.JID) string {
|
||||
|
||||
return phone
|
||||
}
|
||||
|
||||
// validateToken validates the access token and returns token information
|
||||
func (c *Client) validateToken(ctx context.Context) (*TokenDebugData, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/debug_token?input_token=%s",
|
||||
c.config.APIVersion,
|
||||
c.config.AccessToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token validation request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token validation response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return nil, fmt.Errorf("token validation failed: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("token validation returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp TokenDebugResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token validation response: %w", err)
|
||||
}
|
||||
|
||||
if !tokenResp.Data.IsValid {
|
||||
return nil, fmt.Errorf("access token is invalid or expired")
|
||||
}
|
||||
|
||||
return &tokenResp.Data, nil
|
||||
}
|
||||
|
||||
// getPhoneNumberDetails retrieves details about the phone number
|
||||
func (c *Client) getPhoneNumberDetails(ctx context.Context) (*PhoneNumberDetails, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||
c.config.APIVersion,
|
||||
c.config.PhoneNumberID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create phone number details request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get phone number details: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read phone number details response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return nil, fmt.Errorf("API error: %s (code: %d, subcode: %d)",
|
||||
errResp.Error.Message, errResp.Error.Code, errResp.Error.ErrorSubcode)
|
||||
}
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var phoneDetails PhoneNumberDetails
|
||||
if err := json.Unmarshal(body, &phoneDetails); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse phone number details: %w", err)
|
||||
}
|
||||
|
||||
return &phoneDetails, nil
|
||||
}
|
||||
|
||||
// getBusinessAccountDetails retrieves details about the business account
|
||||
func (c *Client) getBusinessAccountDetails(ctx context.Context) (*BusinessAccountDetails, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s",
|
||||
c.config.APIVersion,
|
||||
c.config.BusinessAccountID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create business account details request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get business account details: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read business account details response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return nil, fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var businessDetails BusinessAccountDetails
|
||||
if err := json.Unmarshal(body, &businessDetails); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse business account details: %w", err)
|
||||
}
|
||||
|
||||
return &businessDetails, nil
|
||||
}
|
||||
|
||||
// checkMissingScopes checks which required scopes are missing from the token
|
||||
func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []string) []string {
|
||||
scopeMap := make(map[string]bool)
|
||||
for _, scope := range currentScopes {
|
||||
scopeMap[scope] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, required := range requiredScopes {
|
||||
if !scopeMap[required] {
|
||||
missing = append(missing, required)
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
// formatExpiry formats the expiry timestamp for logging
|
||||
func (c *Client) formatExpiry(expiresAt int64) string {
|
||||
if expiresAt == 0 {
|
||||
return "never"
|
||||
}
|
||||
expiryTime := time.Unix(expiresAt, 0)
|
||||
return expiryTime.Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
|
||||
@@ -191,3 +191,45 @@ type WebhookError struct {
|
||||
Code int `json:"code"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// TokenDebugResponse represents the response from debug_token endpoint
|
||||
type TokenDebugResponse struct {
|
||||
Data TokenDebugData `json:"data"`
|
||||
}
|
||||
|
||||
// TokenDebugData contains token validation information
|
||||
type TokenDebugData struct {
|
||||
AppID string `json:"app_id"`
|
||||
Type string `json:"type"`
|
||||
Application string `json:"application"`
|
||||
DataAccessExpiresAt int64 `json:"data_access_expires_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
IssuedAt int64 `json:"issued_at,omitempty"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// PhoneNumberDetails represents phone number information from the API
|
||||
type PhoneNumberDetails struct {
|
||||
ID string `json:"id"`
|
||||
VerifiedName string `json:"verified_name"`
|
||||
CodeVerificationStatus string `json:"code_verification_status"`
|
||||
DisplayPhoneNumber string `json:"display_phone_number"`
|
||||
QualityRating string `json:"quality_rating"`
|
||||
PlatformType string `json:"platform_type"`
|
||||
Throughput ThroughputInfo `json:"throughput"`
|
||||
}
|
||||
|
||||
// ThroughputInfo contains throughput information
|
||||
type ThroughputInfo struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
// BusinessAccountDetails represents business account information
|
||||
type BusinessAccountDetails struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TimezoneID string `json:"timezone_id"`
|
||||
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user