343 lines
9.4 KiB
Go
343 lines
9.4 KiB
Go
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
|
|
}
|