feat(api): add phone number registration endpoint and update related logic
This commit is contained in:
@@ -224,6 +224,7 @@ func SetupWhatsAppRoutes(router *mux.Router, wh WhatsHookedInterface, distFS fs.
|
|||||||
router.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers))
|
router.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers))
|
||||||
router.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode)).Methods("POST")
|
router.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode)).Methods("POST")
|
||||||
router.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode)).Methods("POST")
|
router.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode)).Methods("POST")
|
||||||
|
router.HandleFunc("/api/phone-numbers/register", h.Auth(h.RegisterPhoneNumber)).Methods("POST")
|
||||||
|
|
||||||
// Media management (with auth)
|
// Media management (with auth)
|
||||||
router.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia)).Methods("POST")
|
router.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia)).Methods("POST")
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ type WhatsAppConfig struct {
|
|||||||
type BusinessAPIConfig struct {
|
type BusinessAPIConfig struct {
|
||||||
PhoneNumberID string `json:"phone_number_id"`
|
PhoneNumberID string `json:"phone_number_id"`
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
BusinessAccountID string `json:"business_account_id,omitempty"`
|
WABAId string `json:"waba_id,omitempty"` // WhatsApp Business Account ID (resolved at connect time)
|
||||||
|
BusinessAccountID string `json:"business_account_id,omitempty"` // Facebook Business Manager ID (optional)
|
||||||
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
|
APIVersion string `json:"api_version,omitempty"` // Default: v21.0
|
||||||
WebhookPath string `json:"webhook_path,omitempty"`
|
WebhookPath string `json:"webhook_path,omitempty"`
|
||||||
VerifyToken string `json:"verify_token,omitempty"`
|
VerifyToken string `json:"verify_token,omitempty"`
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var validLanguageCode = regexp.MustCompile(`^[a-z]{2,3}(_[A-Z]{2})?$`)
|
||||||
|
var validOTPCode = regexp.MustCompile(`^\d{4,8}$`)
|
||||||
|
|
||||||
// ListPhoneNumbers returns all phone numbers for the account.
|
// ListPhoneNumbers returns all phone numbers for the account.
|
||||||
// POST /api/phone-numbers {"account_id"}
|
// POST /api/phone-numbers {"account_id"}
|
||||||
func (h *Handlers) ListPhoneNumbers(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) ListPhoneNumbers(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -47,7 +51,8 @@ func (h *Handlers) RequestVerificationCode(w http.ResponseWriter, r *http.Reques
|
|||||||
var req struct {
|
var req struct {
|
||||||
AccountID string `json:"account_id"`
|
AccountID string `json:"account_id"`
|
||||||
PhoneNumberID string `json:"phone_number_id"`
|
PhoneNumberID string `json:"phone_number_id"`
|
||||||
Method string `json:"method"` // "SMS" or "VOICE"
|
Method string `json:"code_method"` // "SMS" or "VOICE"
|
||||||
|
Language string `json:"language"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -55,13 +60,16 @@ func (h *Handlers) RequestVerificationCode(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.PhoneNumberID == "" || req.Method == "" {
|
if req.PhoneNumberID == "" || req.Method == "" {
|
||||||
http.Error(w, "phone_number_id and method are required", http.StatusBadRequest)
|
http.Error(w, "phone_number_id and code_method are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Method != "SMS" && req.Method != "VOICE" {
|
if req.Method != "SMS" && req.Method != "VOICE" {
|
||||||
http.Error(w, "method must be SMS or VOICE", http.StatusBadRequest)
|
http.Error(w, "code_method must be SMS or VOICE", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Language == "" || !validLanguageCode.MatchString(req.Language) {
|
||||||
|
req.Language = "en_US"
|
||||||
|
}
|
||||||
|
|
||||||
baClient, err := h.getBusinessAPIClient(req.AccountID)
|
baClient, err := h.getBusinessAPIClient(req.AccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +77,7 @@ func (h *Handlers) RequestVerificationCode(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := baClient.RequestVerificationCode(r.Context(), req.PhoneNumberID, req.Method); err != nil {
|
if err := baClient.RequestVerificationCode(r.Context(), req.PhoneNumberID, req.Method, req.Language); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,10 @@ func (h *Handlers) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "phone_number_id and code are required", http.StatusBadRequest)
|
http.Error(w, "phone_number_id and code are required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !validOTPCode.MatchString(req.Code) {
|
||||||
|
http.Error(w, "code must be 4-8 digits", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
baClient, err := h.getBusinessAPIClient(req.AccountID)
|
baClient, err := h.getBusinessAPIClient(req.AccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,3 +125,44 @@ func (h *Handlers) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, map[string]string{"status": "ok"})
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterPhoneNumber registers a phone number with the WhatsApp Cloud API.
|
||||||
|
// POST /api/phone-numbers/register {"account_id","phone_number_id","pin"}
|
||||||
|
func (h *Handlers) RegisterPhoneNumber(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
PhoneNumberID string `json:"phone_number_id"`
|
||||||
|
Pin string `json:"pin"` // 6-digit two-step verification PIN
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PhoneNumberID == "" {
|
||||||
|
http.Error(w, "phone_number_id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validOTPCode.MatchString(req.Pin) {
|
||||||
|
http.Error(w, "pin must be 4-8 digits", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baClient, err := h.getBusinessAPIClient(req.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := baClient.RegisterPhoneNumber(r.Context(), req.PhoneNumberID, req.Pin); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|||||||
46
pkg/serverembed/dist/api.json
vendored
46
pkg/serverembed/dist/api.json
vendored
@@ -76,7 +76,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"access_token": { "type": "string" },
|
"access_token": { "type": "string" },
|
||||||
"business_account_id": { "type": "string" },
|
"waba_id": { "type": "string", "description": "WhatsApp Business Account ID — resolved automatically at connect time from business_account_id if not set" },
|
||||||
|
"business_account_id": { "type": "string", "description": "Facebook Business Manager ID — used to resolve waba_id on first connect" },
|
||||||
"api_version": { "type": "string", "default": "v21.0" },
|
"api_version": { "type": "string", "default": "v21.0" },
|
||||||
"webhook_path": { "type": "string" },
|
"webhook_path": { "type": "string" },
|
||||||
"verify_token": { "type": "string" }
|
"verify_token": { "type": "string" }
|
||||||
@@ -1246,15 +1247,15 @@
|
|||||||
"account_id": { "type": "string" },
|
"account_id": { "type": "string" },
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"code_method": { "type": "string", "enum": ["SMS", "VOICE"] },
|
"code_method": { "type": "string", "enum": ["SMS", "VOICE"] },
|
||||||
"language": { "type": "string" }
|
"language": { "type": "string", "pattern": "^[a-z]{2,3}(_[A-Z]{2})?$", "default": "en_US", "example": "en_US", "description": "BCP-47 locale e.g. en_US, pt_BR — defaults to en_US if omitted or invalid" }
|
||||||
},
|
},
|
||||||
"required": ["account_id", "phone_number_id", "code_method", "language"]
|
"required": ["account_id", "phone_number_id", "code_method"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Verification code requested", "content": { "application/json": { "schema": { "type": "object" } } } },
|
"200": { "description": "Verification code requested", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
"400": { "description": "Bad request" },
|
"400": { "description": "Bad request" },
|
||||||
"401": { "description": "Unauthorized" },
|
"401": { "description": "Unauthorized" },
|
||||||
"500": { "description": "Meta API error" }
|
"500": { "description": "Meta API error" }
|
||||||
@@ -1263,7 +1264,7 @@
|
|||||||
},
|
},
|
||||||
"/api/phone-numbers/verify-code": {
|
"/api/phone-numbers/verify-code": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Verify a phone number with the received code (Business API only)",
|
"summary": "Verify a phone number with the received OTP code (Business API only)",
|
||||||
"operationId": "verifyCode",
|
"operationId": "verifyCode",
|
||||||
"tags": ["Phone Numbers"],
|
"tags": ["Phone Numbers"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -1275,7 +1276,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"account_id": { "type": "string" },
|
"account_id": { "type": "string" },
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"code": { "type": "string" }
|
"code": { "type": "string", "pattern": "^\\d{4,8}$", "example": "123456", "description": "4–8 digit OTP received via SMS or VOICE" }
|
||||||
},
|
},
|
||||||
"required": ["account_id", "phone_number_id", "code"]
|
"required": ["account_id", "phone_number_id", "code"]
|
||||||
}
|
}
|
||||||
@@ -1283,8 +1284,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Code verified", "content": { "application/json": { "schema": { "type": "object" } } } },
|
"200": { "description": "Code verified", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
"400": { "description": "Bad request" },
|
"400": { "description": "Bad request / invalid code format" },
|
||||||
|
"401": { "description": "Unauthorized" },
|
||||||
|
"500": { "description": "Meta API error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/phone-numbers/register": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Register a phone number with the WhatsApp Cloud API (Business API only)",
|
||||||
|
"operationId": "registerPhoneNumber",
|
||||||
|
"tags": ["Phone Numbers"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"account_id": { "type": "string" },
|
||||||
|
"phone_number_id": { "type": "string" },
|
||||||
|
"pin": { "type": "string", "pattern": "^\\d{4,8}$", "example": "123456", "description": "Two-step verification PIN (4–8 digits)" }
|
||||||
|
},
|
||||||
|
"required": ["account_id", "phone_number_id", "pin"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Phone number registered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
|
"400": { "description": "Bad request / invalid PIN format" },
|
||||||
"401": { "description": "Unauthorized" },
|
"401": { "description": "Unauthorized" },
|
||||||
"500": { "description": "Meta API error" }
|
"500": { "description": "Meta API error" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,11 +28,13 @@ type Client struct {
|
|||||||
eventBus *events.EventBus
|
eventBus *events.EventBus
|
||||||
mediaConfig config.MediaConfig
|
mediaConfig config.MediaConfig
|
||||||
connected bool
|
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
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Business API client
|
// 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" {
|
if cfg.Type != "business-api" {
|
||||||
return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type)
|
return nil, fmt.Errorf("invalid client type for business-api: %s", cfg.Type)
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,7 @@ func NewClient(cfg config.WhatsAppConfig, eventBus *events.EventBus, mediaConfig
|
|||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
mediaConfig: mediaConfig,
|
mediaConfig: mediaConfig,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
onWABAResolved: onWABAResolved,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +127,24 @@ func (c *Client) Connect(ctx context.Context) error {
|
|||||||
"status", phoneDetails.CodeVerificationStatus)
|
"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 != "" {
|
if c.config.BusinessAccountID != "" {
|
||||||
businessDetails, err := c.getBusinessAccountDetails(ctx)
|
businessDetails, err := c.getBusinessAccountDetails(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -532,6 +553,42 @@ func (c *Client) checkMissingScopes(currentScopes []string, requiredScopes []str
|
|||||||
return missing
|
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
|
// formatExpiry formats the expiry timestamp for logging
|
||||||
func (c *Client) formatExpiry(expiresAt int64) string {
|
func (c *Client) formatExpiry(expiresAt int64) string {
|
||||||
if expiresAt == 0 {
|
if expiresAt == 0 {
|
||||||
|
|||||||
@@ -226,27 +226,37 @@ func (c *Client) MarkAsRead(ctx context.Context, messageID string) error {
|
|||||||
return nil
|
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) {
|
func (c *Client) ListPhoneNumbers(ctx context.Context) (*PhoneNumberListResponse, error) {
|
||||||
if c.config.BusinessAccountID == "" {
|
wabaID := c.wabaID
|
||||||
return nil, errNoBusinessAccount
|
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{
|
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
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestVerificationCode sends a verification code (SMS or VOICE) to the given phone number.
|
// 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{
|
body := map[string]string{
|
||||||
"verification_method": method,
|
"code_method": method,
|
||||||
|
"language": language,
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp RequestCodeResponse
|
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)
|
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.
|
// DeleteMedia deletes a previously uploaded media file.
|
||||||
func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
|
func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
|
||||||
return c.graphAPIDelete(ctx, mediaID, nil)
|
return c.graphAPIDelete(ctx, mediaID, nil)
|
||||||
|
|||||||
@@ -774,6 +774,15 @@ type VerifyCodeData struct {
|
|||||||
MessageStatus string `json:"message_status"` // "CODE_VERIFIED"
|
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
|
// Business profile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ func (m *Manager) Connect(ctx context.Context, cfg config.WhatsAppConfig) error
|
|||||||
// Factory pattern based on type
|
// Factory pattern based on type
|
||||||
switch cfg.Type {
|
switch cfg.Type {
|
||||||
case "business-api":
|
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", "":
|
case "whatsmeow", "":
|
||||||
// Create callback for phone number updates
|
// Create callback for phone number updates
|
||||||
onPhoneUpdate := func(accountID, phoneNumber string) {
|
onPhoneUpdate := func(accountID, phoneNumber string) {
|
||||||
@@ -187,6 +190,32 @@ func (m *Manager) GetClient(id string) (Client, bool) {
|
|||||||
return client, exists
|
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
|
// updatePhoneNumberInConfig updates the phone number for an account in config and saves it
|
||||||
func (m *Manager) updatePhoneNumberInConfig(accountID, phoneNumber string) {
|
func (m *Manager) updatePhoneNumberInConfig(accountID, phoneNumber string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers))
|
mux.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers))
|
||||||
mux.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode))
|
mux.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode))
|
||||||
mux.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode))
|
mux.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode))
|
||||||
|
mux.HandleFunc("/api/phone-numbers/register", h.Auth(h.RegisterPhoneNumber))
|
||||||
|
|
||||||
// Media management (with auth)
|
// Media management (with auth)
|
||||||
mux.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia))
|
mux.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia))
|
||||||
|
|||||||
@@ -76,7 +76,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"access_token": { "type": "string" },
|
"access_token": { "type": "string" },
|
||||||
"business_account_id": { "type": "string" },
|
"waba_id": { "type": "string", "description": "WhatsApp Business Account ID — resolved automatically at connect time from business_account_id if not set" },
|
||||||
|
"business_account_id": { "type": "string", "description": "Facebook Business Manager ID — used to resolve waba_id on first connect" },
|
||||||
"api_version": { "type": "string", "default": "v21.0" },
|
"api_version": { "type": "string", "default": "v21.0" },
|
||||||
"webhook_path": { "type": "string" },
|
"webhook_path": { "type": "string" },
|
||||||
"verify_token": { "type": "string" }
|
"verify_token": { "type": "string" }
|
||||||
@@ -1246,15 +1247,15 @@
|
|||||||
"account_id": { "type": "string" },
|
"account_id": { "type": "string" },
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"code_method": { "type": "string", "enum": ["SMS", "VOICE"] },
|
"code_method": { "type": "string", "enum": ["SMS", "VOICE"] },
|
||||||
"language": { "type": "string" }
|
"language": { "type": "string", "pattern": "^[a-z]{2,3}(_[A-Z]{2})?$", "default": "en_US", "example": "en_US", "description": "BCP-47 locale e.g. en_US, pt_BR — defaults to en_US if omitted or invalid" }
|
||||||
},
|
},
|
||||||
"required": ["account_id", "phone_number_id", "code_method", "language"]
|
"required": ["account_id", "phone_number_id", "code_method"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Verification code requested", "content": { "application/json": { "schema": { "type": "object" } } } },
|
"200": { "description": "Verification code requested", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
"400": { "description": "Bad request" },
|
"400": { "description": "Bad request" },
|
||||||
"401": { "description": "Unauthorized" },
|
"401": { "description": "Unauthorized" },
|
||||||
"500": { "description": "Meta API error" }
|
"500": { "description": "Meta API error" }
|
||||||
@@ -1263,7 +1264,7 @@
|
|||||||
},
|
},
|
||||||
"/api/phone-numbers/verify-code": {
|
"/api/phone-numbers/verify-code": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Verify a phone number with the received code (Business API only)",
|
"summary": "Verify a phone number with the received OTP code (Business API only)",
|
||||||
"operationId": "verifyCode",
|
"operationId": "verifyCode",
|
||||||
"tags": ["Phone Numbers"],
|
"tags": ["Phone Numbers"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -1275,7 +1276,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"account_id": { "type": "string" },
|
"account_id": { "type": "string" },
|
||||||
"phone_number_id": { "type": "string" },
|
"phone_number_id": { "type": "string" },
|
||||||
"code": { "type": "string" }
|
"code": { "type": "string", "pattern": "^\\d{4,8}$", "example": "123456", "description": "4–8 digit OTP received via SMS or VOICE" }
|
||||||
},
|
},
|
||||||
"required": ["account_id", "phone_number_id", "code"]
|
"required": ["account_id", "phone_number_id", "code"]
|
||||||
}
|
}
|
||||||
@@ -1283,8 +1284,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": { "description": "Code verified", "content": { "application/json": { "schema": { "type": "object" } } } },
|
"200": { "description": "Code verified", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
"400": { "description": "Bad request" },
|
"400": { "description": "Bad request / invalid code format" },
|
||||||
|
"401": { "description": "Unauthorized" },
|
||||||
|
"500": { "description": "Meta API error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/phone-numbers/register": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Register a phone number with the WhatsApp Cloud API (Business API only)",
|
||||||
|
"operationId": "registerPhoneNumber",
|
||||||
|
"tags": ["Phone Numbers"],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"account_id": { "type": "string" },
|
||||||
|
"phone_number_id": { "type": "string" },
|
||||||
|
"pin": { "type": "string", "pattern": "^\\d{4,8}$", "example": "123456", "description": "Two-step verification PIN (4–8 digits)" }
|
||||||
|
},
|
||||||
|
"required": ["account_id", "phone_number_id", "pin"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Phone number registered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StatusOk" } } } },
|
||||||
|
"400": { "description": "Bad request / invalid PIN format" },
|
||||||
"401": { "description": "Unauthorized" },
|
"401": { "description": "Unauthorized" },
|
||||||
"500": { "description": "Meta API error" }
|
"500": { "description": "Meta API error" }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user