feat(api): add phone number registration endpoint and update related logic
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,18 +21,20 @@ import (
|
||||
|
||||
// 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
|
||||
id string
|
||||
phoneNumber string
|
||||
config config.BusinessAPIConfig
|
||||
httpClient *http.Client
|
||||
eventBus *events.EventBus
|
||||
mediaConfig config.MediaConfig
|
||||
connected bool
|
||||
wabaID string // WhatsApp Business Account ID, resolved at connect time
|
||||
onWABAResolved func(accountID, wabaID string) // called when WABA ID is resolved for the first time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new Business API client
|
||||
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig) (*Client, error) {
|
||||
func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig config.MediaConfig, onWABAResolved func(accountID, wabaID string)) (*Client, error) {
|
||||
if cfg.Type != "business-api" {
|
||||
return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type)
|
||||
}
|
||||
@@ -60,9 +63,10 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
eventBus: eventBus,
|
||||
mediaConfig: mediaConfig,
|
||||
connected: false,
|
||||
eventBus: eventBus,
|
||||
mediaConfig: mediaConfig,
|
||||
connected: false,
|
||||
onWABAResolved: onWABAResolved,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +127,24 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
"status", phoneDetails.CodeVerificationStatus)
|
||||
}
|
||||
|
||||
// Step 3: Get business account details (if business_account_id is provided)
|
||||
// Step 3: Resolve the WhatsApp Business Account (WABA) ID from the phone number.
|
||||
// The WABA ID is required for phone number listing; it differs from the Facebook Business Manager ID.
|
||||
// Use cached value from config if already known.
|
||||
if c.config.WABAId != "" {
|
||||
c.wabaID = c.config.WABAId
|
||||
logging.Info("WhatsApp Business Account ID loaded from config", "account_id", c.id, "waba_id", c.wabaID)
|
||||
} else if wabaID, err := c.fetchWABAID(ctx); err != nil {
|
||||
logging.Warn("Failed to resolve WABA ID (non-critical)", "account_id", c.id, "error", err)
|
||||
} else {
|
||||
c.wabaID = wabaID
|
||||
c.config.WABAId = wabaID
|
||||
logging.Info("WhatsApp Business Account ID resolved", "account_id", c.id, "waba_id", wabaID)
|
||||
if c.onWABAResolved != nil {
|
||||
c.onWABAResolved(c.id, wabaID)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Get business account details (if business_account_id is provided)
|
||||
if c.config.BusinessAccountID != "" {
|
||||
businessDetails, err := c.getBusinessAccountDetails(ctx)
|
||||
if err != nil {
|
||||
@@ -532,6 +553,42 @@ func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []str
|
||||
return missing
|
||||
}
|
||||
|
||||
// fetchWABAID resolves the WhatsApp Business Account (WABA) ID by iterating the WABAs
|
||||
// owned by the configured business account and matching against our phone number ID.
|
||||
// Requires business_account_id to be set in config.
|
||||
// GET /{business-id}/owned_whatsapp_business_accounts?fields=id,phone_numbers{id}
|
||||
func (c *Client) fetchWABAID(ctx context.Context) (string, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return "", fmt.Errorf("waba_id or business_account_id must be set in config to resolve WABA")
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
PhoneNumbers struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"phone_numbers"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
params := url.Values{"fields": {"id,phone_numbers{id}"}}
|
||||
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/owned_whatsapp_business_accounts", params, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to list WABAs for business %s: %w", c.config.BusinessAccountID, err)
|
||||
}
|
||||
|
||||
for _, waba := range result.Data {
|
||||
for _, phone := range waba.PhoneNumbers.Data {
|
||||
if phone.ID == c.config.PhoneNumberID {
|
||||
return waba.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("phone number %s not found in any WABA owned by business %s", c.config.PhoneNumberID, c.config.BusinessAccountID)
|
||||
}
|
||||
|
||||
// formatExpiry formats the expiry timestamp for logging
|
||||
func (c *Client) formatExpiry(expiresAt int64) string {
|
||||
if expiresAt == 0 {
|
||||
|
||||
@@ -226,27 +226,37 @@ func (c *Client) MarkAsRead(ctx context.Context, messageID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPhoneNumbers returns all phone numbers for the business account.
|
||||
// ListPhoneNumbers returns all phone numbers for the WhatsApp Business Account.
|
||||
// The WABA ID is resolved from the phone number at connect time; it is distinct from
|
||||
// the Facebook Business Manager ID stored in business_account_id.
|
||||
func (c *Client) ListPhoneNumbers(ctx context.Context) (*PhoneNumberListResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
wabaID := c.wabaID
|
||||
if wabaID == "" {
|
||||
// Fallback: resolve on demand if Connect() was not called
|
||||
id, err := c.fetchWABAID(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve WABA ID: %w", err)
|
||||
}
|
||||
c.wabaID = id
|
||||
wabaID = id
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"fields": {"id,display_phone_number,phone_number,verified_name,code_verification_status,quality_rating,throughput"},
|
||||
"fields": {"id,display_phone_number,verified_name,code_verification_status,quality_rating,throughput"},
|
||||
}
|
||||
|
||||
var resp PhoneNumberListResponse
|
||||
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/phone_numbers", params, &resp); err != nil {
|
||||
if err := c.graphAPIGet(ctx, wabaID+"/phone_numbers", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RequestVerificationCode sends a verification code (SMS or VOICE) to the given phone number.
|
||||
func (c *Client) RequestVerificationCode(ctx context.Context, phoneNumberID string, method string) error {
|
||||
func (c *Client) RequestVerificationCode(ctx context.Context, phoneNumberID string, method string, language string) error {
|
||||
body := map[string]string{
|
||||
"verification_method": method,
|
||||
"code_method": method,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
var resp RequestCodeResponse
|
||||
@@ -261,6 +271,17 @@ func (c *Client) VerifyCode(ctx context.Context, phoneNumberID string, code stri
|
||||
return c.graphAPIPost(ctx, phoneNumberID+"/verify_code", body, &resp)
|
||||
}
|
||||
|
||||
// RegisterPhoneNumber registers a phone number for use with the WhatsApp Cloud API.
|
||||
// POST /{phone-number-id}/register
|
||||
func (c *Client) RegisterPhoneNumber(ctx context.Context, phoneNumberID string, pin string) error {
|
||||
body := RegisterPhoneNumberRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
Pin: pin,
|
||||
}
|
||||
var resp RegisterPhoneNumberResponse
|
||||
return c.graphAPIPost(ctx, phoneNumberID+"/register", body, &resp)
|
||||
}
|
||||
|
||||
// DeleteMedia deletes a previously uploaded media file.
|
||||
func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
|
||||
return c.graphAPIDelete(ctx, mediaID, nil)
|
||||
|
||||
@@ -774,6 +774,15 @@ type VerifyCodeData struct {
|
||||
MessageStatus string `json:"message_status"` // "CODE_VERIFIED"
|
||||
}
|
||||
|
||||
type RegisterPhoneNumberRequest struct {
|
||||
MessagingProduct string `json:"messaging_product"` // always "whatsapp"
|
||||
Pin string `json:"pin"` // 6-digit two-step verification PIN
|
||||
}
|
||||
|
||||
type RegisterPhoneNumberResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -51,7 +51,10 @@ func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error
|
||||
// Factory pattern based on type
|
||||
switch cfg.Type {
|
||||
case "business-api":
|
||||
client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig)
|
||||
onWABAResolved := func(accountID, wabaID string) {
|
||||
m.updateWABAInConfig(accountID, wabaID)
|
||||
}
|
||||
client, err = businessapi.NewClient(cfg, m.eventBus, m.mediaConfig, onWABAResolved)
|
||||
case "whatsmeow", "":
|
||||
// Create callback for phone number updates
|
||||
onPhoneUpdate := func(accountID, phoneNumber string) {
|
||||
@@ -187,6 +190,32 @@ func (m *Manager) GetClient(id string) (Client, bool) {
|
||||
return client, exists
|
||||
}
|
||||
|
||||
// updateWABAInConfig stores the resolved WABA ID back into the in-memory config and syncs to DB/file
|
||||
func (m *Manager) updateWABAInConfig(accountID, wabaID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for i := range m.config.WhatsApp {
|
||||
if m.config.WhatsApp[i].ID == accountID {
|
||||
if m.config.WhatsApp[i].BusinessAPI == nil {
|
||||
return
|
||||
}
|
||||
if m.config.WhatsApp[i].BusinessAPI.WABAId == wabaID {
|
||||
return // already up to date
|
||||
}
|
||||
m.config.WhatsApp[i].BusinessAPI.WABAId = wabaID
|
||||
logging.Info("Updated WABA ID in config", "account_id", accountID, "waba_id", wabaID)
|
||||
|
||||
if m.onConfigUpdate != nil {
|
||||
if err := m.onConfigUpdate(m.config); err != nil {
|
||||
logging.Error("Failed to save config after WABA ID update", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updatePhoneNumberInConfig updates the phone number for an account in config and saves it
|
||||
func (m *Manager) updatePhoneNumberInConfig(accountID, phoneNumber string) {
|
||||
m.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user