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 { // 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) 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) if err != nil { c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) return fmt.Errorf("failed to validate credentials: %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)) c.eventBus.Publish(events.WhatsAppPairFailedEvent(ctx, c.id, err)) return err } 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)) 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 }