Files
whatshooked/internal/whatsapp/businessapi/client.go
2025-12-29 06:23:16 +02:00

355 lines
9.7 KiB
Go

package businessapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.warky.dev/wdevs/whatshooked/internal/config"
"git.warky.dev/wdevs/whatshooked/internal/events"
"git.warky.dev/wdevs/whatshooked/internal/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
}
// phoneNumberToJID converts an E.164 phone number to WhatsApp JID
func phoneNumberToJID(phoneNumber string) types.JID {
// Remove + if present
phone := strings.TrimPrefix(phoneNumber, "+")
// Create JID
return types.JID{
User: phone,
Server: types.DefaultUserServer, // "s.whatsapp.net"
}
}