package businessapi import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/logging" "go.mau.fi/whatsmeow/types" ) // Client represents a WhatsApp Business API client type Client struct { id string phoneNumber string config config.BusinessAPIConfig httpClient *http.Client eventBus *events.EventBus mediaConfig config.MediaConfig connected bool mu sync.RWMutex } // NewClient creates a new Business API client func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) { if cfg.Type != "business-api" { return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type) } if cfg.BusinessAPI == nil { return nil, fmt.Errorf("business_api configuration is required for business-api type") } // Validate required fields if cfg.BusinessAPI.PhoneNumberID == "" { return nil, fmt.Errorf("phone_number_id is required") } if cfg.BusinessAPI.AccessToken == "" { return nil, fmt.Errorf("access_token is required") } // Set default API version if cfg.BusinessAPI.APIVersion == "" { cfg.BusinessAPI.APIVersion = "v21.0" } return &Client{ id: cfg.ID, phoneNumber: cfg.PhoneNumber, config: *cfg.BusinessAPI, httpClient: &http.Client{ Timeout: 30 * time.Second, }, eventBus: eventBus, mediaConfig: mediaConfig, connected: false, }, nil } // Connect validates the Business API credentials func (c *Client) Connect(ctx context.Context) error { logging.Info("Validating WhatsApp Business API credentials", "account_id", c.id) // 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("token validation failed: %w", err) } // 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 successfully", "account_id", c.id, "phone", phoneDetails.DisplayPhoneNumber, "verified_name", phoneDetails.VerifiedName) c.eventBus.Publish(events.WhatsAppConnectedEvent(ctx, c.id, phoneDetails.DisplayPhoneNumber)) return nil } // Disconnect closes the Business API client func (c *Client) Disconnect() error { c.mu.Lock() c.connected = false c.mu.Unlock() logging.Info("Business API client disconnected", "account_id", c.id) return nil } // IsConnected returns whether the client is connected func (c *Client) IsConnected() bool { c.mu.RLock() defer c.mu.RUnlock() return c.connected } // GetID returns the client ID func (c *Client) GetID() string { return c.id } // GetPhoneNumber returns the phone number func (c *Client) GetPhoneNumber() string { return c.phoneNumber } // GetType returns the client type func (c *Client) GetType() string { return "business-api" } // SendTextMessage sends a text message via Business API func (c *Client) SendTextMessage(ctx context.Context, jid types.JID, text string) (string, error) { if ctx == nil { ctx = context.Background() } // Convert JID to phone number phoneNumber := jidToPhoneNumber(jid) // Create request reqBody := SendMessageRequest{ MessagingProduct: "whatsapp", To: phoneNumber, Type: "text", Text: &TextObject{ Body: text, }, } messageID, err := c.sendMessage(ctx, reqBody) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, text, err)) return "", err } logging.Debug("Message sent via Business API", "account_id", c.id, "to", phoneNumber) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, text)) return messageID, nil } // SendImage sends an image message via Business API func (c *Client) SendImage(ctx context.Context, jid types.JID, imageData []byte, mimeType string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } phoneNumber := jidToPhoneNumber(jid) // Upload media first mediaID, err := c.uploadMedia(ctx, imageData, mimeType) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", fmt.Errorf("failed to upload image: %w", err) } // Send message with media ID reqBody := SendMessageRequest{ MessagingProduct: "whatsapp", To: phoneNumber, Type: "image", Image: &MediaObject{ ID: mediaID, Caption: caption, }, } messageID, err := c.sendMessage(ctx, reqBody) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", err } logging.Debug("Image sent via Business API", "account_id", c.id, "to", phoneNumber) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) return messageID, nil } // SendVideo sends a video message via Business API func (c *Client) SendVideo(ctx context.Context, jid types.JID, videoData []byte, mimeType string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } phoneNumber := jidToPhoneNumber(jid) // Upload media first mediaID, err := c.uploadMedia(ctx, videoData, mimeType) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", fmt.Errorf("failed to upload video: %w", err) } // Send message with media ID reqBody := SendMessageRequest{ MessagingProduct: "whatsapp", To: phoneNumber, Type: "video", Video: &MediaObject{ ID: mediaID, Caption: caption, }, } messageID, err := c.sendMessage(ctx, reqBody) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", err } logging.Debug("Video sent via Business API", "account_id", c.id, "to", phoneNumber) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) return messageID, nil } // SendDocument sends a document message via Business API func (c *Client) SendDocument(ctx context.Context, jid types.JID, documentData []byte, mimeType string, filename string, caption string) (string, error) { if ctx == nil { ctx = context.Background() } phoneNumber := jidToPhoneNumber(jid) // Upload media first mediaID, err := c.uploadMedia(ctx, documentData, mimeType) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", fmt.Errorf("failed to upload document: %w", err) } // Send message with media ID reqBody := SendMessageRequest{ MessagingProduct: "whatsapp", To: phoneNumber, Type: "document", Document: &DocumentObject{ ID: mediaID, Caption: caption, Filename: filename, }, } messageID, err := c.sendMessage(ctx, reqBody) if err != nil { c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, caption, err)) return "", err } logging.Debug("Document sent via Business API", "account_id", c.id, "to", phoneNumber, "filename", filename) c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, caption)) return messageID, nil } // sendMessage sends a message request to the Business API func (c *Client) sendMessage(ctx context.Context, reqBody SendMessageRequest) (string, error) { url := fmt.Sprintf("https://graph.facebook.com/%s/%s/messages", c.config.APIVersion, c.config.PhoneNumberID) jsonData, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { var errResp ErrorResponse if err := json.Unmarshal(body, &errResp); err == nil { return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code) } return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) } var sendResp SendMessageResponse if err := json.Unmarshal(body, &sendResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if len(sendResp.Messages) == 0 { return "", fmt.Errorf("no message ID in response") } return sendResp.Messages[0].ID, nil } // jidToPhoneNumber converts a WhatsApp JID to E.164 phone number format func jidToPhoneNumber(jid types.JID) string { // JID format is like "27123456789@s.whatsapp.net" // Extract the phone number part before @ phone := jid.User // Ensure it starts with + for E.164 if !strings.HasPrefix(phone, "+") { phone = "+" + phone } 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") }