feat(whatsapp): 🎉 Add extended sending and template management
* Implemented new endpoints for sending various message types: - Audio - Sticker - Location - Contacts - Interactive messages - Template messages - Flow messages - Reactions - Marking messages as read * Added template management endpoints: - List templates - Upload templates - Delete templates * Introduced flow management endpoints: - List flows - Create flows - Get flow details - Upload flow assets - Publish flows - Delete flows * Added phone number management endpoints: - List phone numbers - Request verification code - Verify code * Enhanced media management with delete media endpoint. * Updated dependencies.
This commit is contained in:
@@ -52,58 +52,78 @@ func (c *Client) HandleWebhook(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// processChange processes a webhook change
|
||||
// processChange processes a webhook change.
|
||||
// change.Value is json.RawMessage because Meta uses different payload shapes per field type.
|
||||
// Each case unmarshals into the appropriate struct.
|
||||
func (c *Client) processChange(change WebhookChange) {
|
||||
ctx := context.Background()
|
||||
|
||||
logging.Info("Processing webhook change",
|
||||
"account_id", c.id,
|
||||
"field", change.Field,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
|
||||
// Handle different field types
|
||||
switch change.Field {
|
||||
case "messages":
|
||||
// Process messages
|
||||
for i := range change.Value.Messages {
|
||||
msg := change.Value.Messages[i]
|
||||
c.processMessage(ctx, msg, change.Value.Contacts)
|
||||
var value WebhookValue
|
||||
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||
logging.Error("Failed to parse messages webhook value",
|
||||
"account_id", c.id, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Process statuses
|
||||
for _, status := range change.Value.Statuses {
|
||||
logging.Info("Processing webhook change",
|
||||
"account_id", c.id,
|
||||
"field", change.Field,
|
||||
"phone_number_id", value.Metadata.PhoneNumberID)
|
||||
|
||||
for i := range value.Messages {
|
||||
c.processMessage(ctx, value.Messages[i], value.Contacts)
|
||||
}
|
||||
for _, status := range value.Statuses {
|
||||
c.processStatus(ctx, status)
|
||||
}
|
||||
|
||||
case "message_template_status_update":
|
||||
// Log template status updates for visibility
|
||||
logging.Info("Message template status update received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
var value TemplateStatusValue
|
||||
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||
logging.Error("Failed to parse template status update",
|
||||
"account_id", c.id, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
case "account_update":
|
||||
// Log account updates
|
||||
logging.Info("Account update received",
|
||||
logging.Info("Template status update received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
"template_name", value.TemplateName,
|
||||
"template_id", value.TemplateID,
|
||||
"language", value.Language,
|
||||
"status", value.Status,
|
||||
"rejection_reasons", value.RejectionReasons)
|
||||
|
||||
case "phone_number_quality_update":
|
||||
// Log quality updates
|
||||
logging.Info("Phone number quality update received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
c.eventBus.Publish(events.TemplateStatusUpdateEvent(
|
||||
ctx,
|
||||
c.id,
|
||||
value.TemplateName,
|
||||
value.TemplateID,
|
||||
value.Language,
|
||||
value.Status,
|
||||
value.RejectionReasons,
|
||||
))
|
||||
|
||||
case "phone_number_name_update":
|
||||
// Log name updates
|
||||
logging.Info("Phone number name update received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
case "account_update", "phone_number_quality_update", "phone_number_name_update", "account_alerts":
|
||||
// These all carry the standard WebhookValue with metadata
|
||||
var value WebhookValue
|
||||
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||
logging.Error("Failed to parse webhook value",
|
||||
"account_id", c.id, "field", change.Field, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
case "account_alerts":
|
||||
// Log account alerts
|
||||
logging.Warn("Account alert received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
||||
if change.Field == "account_alerts" {
|
||||
logging.Warn("Account alert received",
|
||||
"account_id", c.id,
|
||||
"phone_number_id", value.Metadata.PhoneNumberID)
|
||||
} else {
|
||||
logging.Info("Webhook notification received",
|
||||
"account_id", c.id,
|
||||
"field", change.Field,
|
||||
"phone_number_id", value.Metadata.PhoneNumberID)
|
||||
}
|
||||
|
||||
default:
|
||||
logging.Debug("Unknown webhook field type",
|
||||
|
||||
71
pkg/whatsapp/businessapi/flows.go
Normal file
71
pkg/whatsapp/businessapi/flows.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ListFlows returns all flows for the business account.
|
||||
func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"},
|
||||
}
|
||||
|
||||
var resp FlowListResponse
|
||||
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/flows", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateFlow creates a new flow and returns its ID.
|
||||
func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
}
|
||||
|
||||
var resp FlowCreateResponse
|
||||
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/flows", flow, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetFlow returns details for a single flow.
|
||||
func (c *Client) GetFlow(ctx context.Context, flowID string) (*FlowInfo, error) {
|
||||
params := url.Values{
|
||||
"fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"},
|
||||
}
|
||||
|
||||
var resp FlowInfo
|
||||
if err := c.graphAPIGet(ctx, flowID, params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateFlowAssets uploads a screens JSON definition to an existing flow (must be in DRAFT).
|
||||
func (c *Client) UpdateFlowAssets(ctx context.Context, flowID string, screensJSON string) error {
|
||||
fields := map[string]string{
|
||||
"asset_type": "SCREENS_JSON",
|
||||
"asset_data": screensJSON,
|
||||
}
|
||||
|
||||
var resp FlowActionResponse
|
||||
return c.graphAPIPostForm(ctx, flowID+"/assets", fields, &resp)
|
||||
}
|
||||
|
||||
// PublishFlow transitions a DRAFT flow to PUBLISHED.
|
||||
func (c *Client) PublishFlow(ctx context.Context, flowID string) error {
|
||||
var resp FlowActionResponse
|
||||
return c.graphAPIPost(ctx, flowID+"?action=PUBLISH", nil, &resp)
|
||||
}
|
||||
|
||||
// DeleteFlow permanently removes a flow.
|
||||
func (c *Client) DeleteFlow(ctx context.Context, flowID string) error {
|
||||
return c.graphAPIDelete(ctx, flowID, nil)
|
||||
}
|
||||
171
pkg/whatsapp/businessapi/helpers.go
Normal file
171
pkg/whatsapp/businessapi/helpers.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// graphAPIGet performs an authenticated GET to the Graph API and unmarshals the response.
|
||||
func (c *Client) graphAPIGet(ctx context.Context, path string, params url.Values, result any) error {
|
||||
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
return c.executeRequest(req, result)
|
||||
}
|
||||
|
||||
// graphAPIPost performs an authenticated POST with a JSON body.
|
||||
// body may be nil for action-only endpoints (e.g. publish a flow).
|
||||
func (c *Client) graphAPIPost(ctx context.Context, path string, body any, result any) error {
|
||||
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
return c.executeRequest(req, result)
|
||||
}
|
||||
|
||||
// graphAPIPostForm performs an authenticated POST with multipart form fields.
|
||||
func (c *Client) graphAPIPostForm(ctx context.Context, path string, fields map[string]string, result any) error {
|
||||
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return fmt.Errorf("failed to write form field %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, &buf)
|
||||
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", writer.FormDataContentType())
|
||||
|
||||
return c.executeRequest(req, result)
|
||||
}
|
||||
|
||||
// graphAPIDelete performs an authenticated DELETE request.
|
||||
func (c *Client) graphAPIDelete(ctx context.Context, path string, params url.Values) error {
|
||||
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", u, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
|
||||
|
||||
return c.executeRequest(req, nil)
|
||||
}
|
||||
|
||||
// executeRequest runs an HTTP request, handles error responses, and optionally unmarshals the body.
|
||||
func (c *Client) executeRequest(req *http.Request, result any) error {
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %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 < 200 || resp.StatusCode >= 300 {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
|
||||
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))
|
||||
}
|
||||
|
||||
if result != nil && len(body) > 0 {
|
||||
if err := json.Unmarshal(body, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// postToMessagesEndpoint POSTs an arbitrary body to the phone number's /messages endpoint.
|
||||
// Used by sendMessage (typed) and by reaction/read-receipt (different top-level shape).
|
||||
func (c *Client) postToMessagesEndpoint(ctx context.Context, body any) (string, error) {
|
||||
path := c.config.PhoneNumberID + "/messages"
|
||||
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, 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()
|
||||
|
||||
respBody, 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(respBody, &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(respBody))
|
||||
}
|
||||
|
||||
var sendResp SendMessageResponse
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return "", nil // some endpoints (read receipt) return non-message JSON
|
||||
}
|
||||
if len(sendResp.Messages) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return sendResp.Messages[0].ID, nil
|
||||
}
|
||||
267
pkg/whatsapp/businessapi/sending.go
Normal file
267
pkg/whatsapp/businessapi/sending.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/events"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
var errNoBusinessAccount = errors.New("business_account_id is required for this operation")
|
||||
|
||||
// SendAudio sends an audio message via Business API.
|
||||
func (c *Client) SendAudio(ctx context.Context, jid types.JID, audioData []byte, mimeType string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
mediaID, err := c.uploadMedia(ctx, audioData, mimeType)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", fmt.Errorf("failed to upload audio: %w", err)
|
||||
}
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "audio",
|
||||
Audio: &MediaObject{ID: mediaID},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Audio sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendSticker sends a sticker message via Business API.
|
||||
func (c *Client) SendSticker(ctx context.Context, jid types.JID, stickerData []byte, mimeType string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
mediaID, err := c.uploadMedia(ctx, stickerData, mimeType)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", fmt.Errorf("failed to upload sticker: %w", err)
|
||||
}
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "sticker",
|
||||
Sticker: &MediaObject{ID: mediaID},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Sticker sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendLocation sends a location message via Business API.
|
||||
func (c *Client) SendLocation(ctx context.Context, jid types.JID, latitude, longitude float64, name, address string) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "location",
|
||||
Location: &LocationObject{
|
||||
Latitude: latitude,
|
||||
Longitude: longitude,
|
||||
Name: name,
|
||||
Address: address,
|
||||
},
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Location sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, fmt.Sprintf("%.6f,%.6f", latitude, longitude)))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendContacts sends one or more contact cards.
|
||||
func (c *Client) SendContacts(ctx context.Context, jid types.JID, contacts []SendContactObject) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "contacts",
|
||||
Contacts: contacts,
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Contacts sent via Business API", "account_id", c.id, "to", phoneNumber)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendInteractive sends an interactive message (buttons, list, or flow).
|
||||
func (c *Client) SendInteractive(ctx context.Context, jid types.JID, interactive *InteractiveObject) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "interactive",
|
||||
Interactive: interactive,
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Interactive message sent via Business API", "account_id", c.id, "to", phoneNumber, "type", interactive.Type)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendTemplateMessage sends a template message.
|
||||
func (c *Client) SendTemplateMessage(ctx context.Context, jid types.JID, tmpl *TemplateMessageObject) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
reqBody := SendMessageRequest{
|
||||
MessagingProduct: "whatsapp",
|
||||
To: phoneNumber,
|
||||
Type: "template",
|
||||
Template: tmpl,
|
||||
}
|
||||
|
||||
messageID, err := c.sendMessage(ctx, reqBody)
|
||||
if err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
|
||||
return "", err
|
||||
}
|
||||
|
||||
logging.Debug("Template message sent via Business API", "account_id", c.id, "to", phoneNumber, "template", tmpl.Name)
|
||||
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, tmpl.Name))
|
||||
return messageID, nil
|
||||
}
|
||||
|
||||
// SendReaction sends a reaction (emoji) to an existing message.
|
||||
func (c *Client) SendReaction(ctx context.Context, jid types.JID, messageID string, emoji string) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
phoneNumber := jidToPhoneNumber(jid)
|
||||
|
||||
msg := ReactionMessage{
|
||||
MessagingProduct: "whatsapp",
|
||||
MessageType: "reaction",
|
||||
Reaction: ReactionObject{
|
||||
MessageID: messageID,
|
||||
Emoji: emoji,
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil {
|
||||
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, emoji, err))
|
||||
return err
|
||||
}
|
||||
|
||||
logging.Debug("Reaction sent via Business API", "account_id", c.id, "to", phoneNumber, "emoji", emoji)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAsRead marks a received message as read.
|
||||
func (c *Client) MarkAsRead(ctx context.Context, messageID string) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
msg := ReadReceiptMessage{
|
||||
MessagingProduct: "whatsapp",
|
||||
Status: "read",
|
||||
MessageID: messageID,
|
||||
}
|
||||
|
||||
if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logging.Debug("Message marked as read via Business API", "account_id", c.id, "message_id", messageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPhoneNumbers returns all phone numbers for the business account.
|
||||
func (c *Client) ListPhoneNumbers(ctx context.Context) (*PhoneNumberListResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"fields": {"id,display_phone_number,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 {
|
||||
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 {
|
||||
body := map[string]string{
|
||||
"verification_method": method,
|
||||
}
|
||||
|
||||
var resp RequestCodeResponse
|
||||
return c.graphAPIPost(ctx, phoneNumberID+"/request_code", body, &resp)
|
||||
}
|
||||
|
||||
// VerifyCode verifies a phone number with the code received via SMS/VOICE.
|
||||
func (c *Client) VerifyCode(ctx context.Context, phoneNumberID string, code string) error {
|
||||
body := VerifyCodeRequest{Code: code}
|
||||
|
||||
var resp VerifyCodeResponse
|
||||
return c.graphAPIPost(ctx, phoneNumberID+"/verify_code", 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)
|
||||
}
|
||||
50
pkg/whatsapp/businessapi/templates.go
Normal file
50
pkg/whatsapp/businessapi/templates.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ListTemplates returns all message templates for the business account.
|
||||
// Requires BusinessAccountID in the client config.
|
||||
func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"fields": {"id,name,status,language,category,created_at,components,rejection_reasons,quality_score"},
|
||||
}
|
||||
|
||||
var resp TemplateListResponse
|
||||
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/message_templates", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UploadTemplate creates a new message template.
|
||||
func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return nil, errNoBusinessAccount
|
||||
}
|
||||
|
||||
var resp TemplateUploadResponse
|
||||
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/message_templates", tmpl, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteTemplate deletes a template by name and language.
|
||||
func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error {
|
||||
if c.config.BusinessAccountID == "" {
|
||||
return errNoBusinessAccount
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"name": {name},
|
||||
"language": {language},
|
||||
}
|
||||
return c.graphAPIDelete(ctx, c.config.BusinessAccountID+"/message_templates", params)
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
package businessapi
|
||||
|
||||
// SendMessageRequest represents a request to send a text message via Business API
|
||||
import "encoding/json"
|
||||
|
||||
// SendMessageRequest represents a request to send a message via Business API
|
||||
type SendMessageRequest struct {
|
||||
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
|
||||
RecipientType string `json:"recipient_type,omitempty"` // "individual"
|
||||
To string `json:"to"` // Phone number in E.164 format
|
||||
Type string `json:"type"` // "text", "image", "video", "document"
|
||||
Text *TextObject `json:"text,omitempty"`
|
||||
Image *MediaObject `json:"image,omitempty"`
|
||||
Video *MediaObject `json:"video,omitempty"`
|
||||
Document *DocumentObject `json:"document,omitempty"`
|
||||
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
|
||||
RecipientType string `json:"recipient_type,omitempty"` // "individual"
|
||||
To string `json:"to"` // Phone number in E.164 format
|
||||
Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "template"
|
||||
Text *TextObject `json:"text,omitempty"`
|
||||
Image *MediaObject `json:"image,omitempty"`
|
||||
Video *MediaObject `json:"video,omitempty"`
|
||||
Document *DocumentObject `json:"document,omitempty"`
|
||||
Audio *MediaObject `json:"audio,omitempty"`
|
||||
Sticker *MediaObject `json:"sticker,omitempty"`
|
||||
Location *LocationObject `json:"location,omitempty"`
|
||||
Contacts []SendContactObject `json:"contacts,omitempty"`
|
||||
Interactive *InteractiveObject `json:"interactive,omitempty"`
|
||||
Template *TemplateMessageObject `json:"template,omitempty"`
|
||||
}
|
||||
|
||||
// TextObject represents a text message
|
||||
@@ -82,10 +90,12 @@ type WebhookEntry struct {
|
||||
Changes []WebhookChange `json:"changes"`
|
||||
}
|
||||
|
||||
// WebhookChange represents a change notification
|
||||
// WebhookChange represents a change notification.
|
||||
// Value is kept as raw JSON because the shape differs per Field type:
|
||||
// "messages" → WebhookValue, "message_template_status_update" → TemplateStatusValue, etc.
|
||||
type WebhookChange struct {
|
||||
Value WebhookValue `json:"value"`
|
||||
Field string `json:"field"` // "messages"
|
||||
Value json.RawMessage `json:"value"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
// WebhookValue contains the actual webhook data
|
||||
@@ -394,3 +404,386 @@ type BusinessAccountDetails struct {
|
||||
TimezoneID string `json:"timezone_id"`
|
||||
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
|
||||
}
|
||||
|
||||
// TemplateStatusValue represents the value payload in a message_template_status_update webhook.
|
||||
// This is a distinct shape from WebhookValue — Meta does not include messaging_product or
|
||||
// metadata in these notifications.
|
||||
type TemplateStatusValue struct {
|
||||
Event string `json:"event"` // e.g. "TEMPLATE_APPROVED", "TEMPLATE_REJECTED"
|
||||
TemplateName string `json:"template_name"` // name used during upload
|
||||
TemplateID string `json:"template_id"` // Meta-assigned template ID
|
||||
Language string `json:"language"` // e.g. "en_US"
|
||||
Status string `json:"status"` // "APPROVED", "REJECTED", "PENDING"
|
||||
RejectionReasons []string `json:"rejection_reasons"` // non-empty only when Status == "REJECTED"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outgoing message types (sending)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LocationObject is used when sending a location message
|
||||
type LocationObject struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// SendContactObject is a contact card in an outgoing contacts message
|
||||
type SendContactObject struct {
|
||||
Name ContactNameObj `json:"name"`
|
||||
Addresses []ContactAddressObj `json:"addresses,omitempty"`
|
||||
Emails []ContactEmailObj `json:"emails,omitempty"`
|
||||
Phones []ContactPhoneObj `json:"phones,omitempty"`
|
||||
Org *ContactOrgObj `json:"org,omitempty"`
|
||||
URLs []ContactURLObj `json:"urls,omitempty"`
|
||||
}
|
||||
|
||||
type ContactNameObj struct {
|
||||
FormattedName string `json:"formatted_name"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
type ContactAddressObj struct {
|
||||
City string `json:"city,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Street string `json:"street,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Zip string `json:"zip,omitempty"`
|
||||
}
|
||||
|
||||
type ContactEmailObj struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type ContactPhoneObj struct {
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type ContactOrgObj struct {
|
||||
Company string `json:"company,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type ContactURLObj struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// InteractiveObject is the payload for interactive messages (buttons, list, flow)
|
||||
type InteractiveObject struct {
|
||||
Type string `json:"type"` // "button", "list", "flow"
|
||||
Header *InteractiveHeader `json:"header,omitempty"`
|
||||
Body *InteractiveBody `json:"body"`
|
||||
Footer *InteractiveFooter `json:"footer,omitempty"`
|
||||
Buttons []InteractiveButton `json:"buttons,omitempty"` // type "button": 1-3 reply buttons
|
||||
Action *InteractiveAction `json:"action,omitempty"` // type "list" or "flow"
|
||||
}
|
||||
|
||||
type InteractiveHeader struct {
|
||||
Type string `json:"type"` // "text", "image", "video", "document"
|
||||
Text string `json:"text,omitempty"`
|
||||
Image *MediaObject `json:"image,omitempty"`
|
||||
Video *MediaObject `json:"video,omitempty"`
|
||||
Document *MediaObject `json:"document,omitempty"`
|
||||
}
|
||||
|
||||
type InteractiveBody struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type InteractiveFooter struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type InteractiveButton struct {
|
||||
Type string `json:"type"` // "reply"
|
||||
Reply InteractiveButtonReply `json:"reply"`
|
||||
}
|
||||
|
||||
type InteractiveButtonReply struct {
|
||||
ID string `json:"id"` // up to 20 chars; returned in webhook reply
|
||||
Title string `json:"title"` // up to 20 chars; displayed on button
|
||||
}
|
||||
|
||||
// InteractiveAction is shared by list and flow interactive messages
|
||||
type InteractiveAction struct {
|
||||
// list fields
|
||||
Button string `json:"button,omitempty"` // label on the list button
|
||||
Sections []InteractiveSection `json:"sections,omitempty"` // 1-10 sections
|
||||
// flow fields
|
||||
Name string `json:"name,omitempty"` // "flow"
|
||||
Parameters *FlowActionParams `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type InteractiveSection struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Rows []InteractiveSectionRow `json:"rows"` // 1-10 rows
|
||||
}
|
||||
|
||||
type InteractiveSectionRow struct {
|
||||
ID string `json:"id"` // up to 20 chars
|
||||
Title string `json:"title"` // up to 60 chars
|
||||
Description string `json:"description,omitempty"` // up to 72 chars
|
||||
}
|
||||
|
||||
// FlowActionParams defines parameters when sending an interactive flow message
|
||||
type FlowActionParams struct {
|
||||
Type string `json:"type"` // "payload" or "unpublished"
|
||||
FlowToken string `json:"flow_token"`
|
||||
Name string `json:"name"` // initial screen name
|
||||
Data map[string]any `json:"data"` // dynamic key/value pairs for the screen
|
||||
}
|
||||
|
||||
// TemplateMessageObject defines a template message for sending
|
||||
type TemplateMessageObject struct {
|
||||
Name string `json:"name"`
|
||||
Language TemplateLanguage `json:"language"`
|
||||
Components []TemplateSendComponent `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateLanguage struct {
|
||||
Code string `json:"code"` // e.g. "en_US", "en"
|
||||
Policy string `json:"policy,omitempty"` // "deterministic"
|
||||
}
|
||||
|
||||
type TemplateSendComponent struct {
|
||||
Type string `json:"type"` // "header", "body", "buttons"
|
||||
SubType string `json:"sub_type,omitempty"` // "quick_reply" or "url" (buttons only)
|
||||
Index string `json:"index,omitempty"` // "0","1","2" (buttons only)
|
||||
Parameters []TemplateParameter `json:"parameters"`
|
||||
}
|
||||
|
||||
type TemplateParameter struct {
|
||||
Type string `json:"type"` // "text","currency","date_time","image","video","document","location","payload"
|
||||
Text string `json:"text,omitempty"`
|
||||
Currency *CurrencyObject `json:"currency,omitempty"`
|
||||
DateTime *DateTimeObject `json:"date_time,omitempty"`
|
||||
Image *MediaObject `json:"image,omitempty"`
|
||||
Video *MediaObject `json:"video,omitempty"`
|
||||
Document *MediaObject `json:"document,omitempty"`
|
||||
Location *LocationObject `json:"location,omitempty"`
|
||||
Payload string `json:"payload,omitempty"` // quick_reply button payload
|
||||
}
|
||||
|
||||
type CurrencyObject struct {
|
||||
Currency string `json:"currency"` // ISO 4217
|
||||
Amount string `json:"amount"`
|
||||
}
|
||||
|
||||
type DateTimeObject struct {
|
||||
Raw string `json:"raw"` // RFC 3339 datetime string
|
||||
}
|
||||
|
||||
// ReactionMessage is the body sent to react to an existing message.
|
||||
// Uses "message_type" (not "type") at the top level — distinct from SendMessageRequest.
|
||||
type ReactionMessage struct {
|
||||
MessagingProduct string `json:"messaging_product"` // "whatsapp"
|
||||
MessageType string `json:"message_type"` // "reaction"
|
||||
Reaction ReactionObject `json:"reaction"`
|
||||
}
|
||||
|
||||
type ReactionObject struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
|
||||
// ReadReceiptMessage marks a received message as read
|
||||
type ReadReceiptMessage struct {
|
||||
MessagingProduct string `json:"messaging_product"` // "whatsapp"
|
||||
Status string `json:"status"` // "read"
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template management (CRUD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TemplateListResponse struct {
|
||||
Data []TemplateInfo `json:"data"`
|
||||
Paging *PagingInfo `json:"paging,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // APPROVED, PENDING, REJECTED
|
||||
Language string `json:"language"`
|
||||
Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION
|
||||
CreatedAt string `json:"created_at"`
|
||||
Components []TemplateComponentDef `json:"components"`
|
||||
RejectionReasons []string `json:"rejection_reasons,omitempty"`
|
||||
QualityScore string `json:"quality_score,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateComponentDef struct {
|
||||
Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS
|
||||
Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION
|
||||
Text string `json:"text,omitempty"`
|
||||
Buttons []TemplateButtonDef `json:"buttons,omitempty"`
|
||||
Example *TemplateComponentExample `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateButtonDef struct {
|
||||
Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
Dynamic bool `json:"dynamic,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateComponentExample struct {
|
||||
HeaderHandle []string `json:"header_handle,omitempty"`
|
||||
BodyExample [][]string `json:"body_example,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateUploadRequest struct {
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION
|
||||
Components []TemplateUploadComponent `json:"components"`
|
||||
AllowCategoryChange bool `json:"allow_category_change,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateUploadComponent struct {
|
||||
Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS
|
||||
Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION (HEADER only)
|
||||
Text string `json:"text,omitempty"`
|
||||
Buttons []TemplateUploadButton `json:"buttons,omitempty"`
|
||||
Example *TemplateUploadExample `json:"example,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateUploadButton struct {
|
||||
Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url,omitempty"`
|
||||
PhoneNumber string `json:"phone_number,omitempty"`
|
||||
Example []string `json:"example,omitempty"` // example values for dynamic parts
|
||||
}
|
||||
|
||||
type TemplateUploadExample struct {
|
||||
HeaderHandle []string `json:"header_handle,omitempty"`
|
||||
BodyExample [][]string `json:"body_example,omitempty"`
|
||||
}
|
||||
|
||||
type TemplateUploadResponse struct {
|
||||
Data TemplateUploadData `json:"data"`
|
||||
}
|
||||
|
||||
type TemplateUploadData struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flow management (CRUD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FlowListResponse struct {
|
||||
Data []FlowInfo `json:"data"`
|
||||
Paging *PagingInfo `json:"paging,omitempty"`
|
||||
}
|
||||
|
||||
type FlowInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // DRAFT, PUBLISHED, DEPRECATED, BLOCKED, ERROR
|
||||
Categories []string `json:"categories"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EndpointURL string `json:"endpoint_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
SignedPreviewURL string `json:"signed_preview_url,omitempty"`
|
||||
SignedFlowURL string `json:"signed_flow_url,omitempty"`
|
||||
}
|
||||
|
||||
type FlowCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Categories []string `json:"categories"` // e.g. ["SIGN_IN"]
|
||||
EndpointURL string `json:"endpoint_url,omitempty"`
|
||||
CloneFlowID string `json:"clone_flow_id,omitempty"`
|
||||
}
|
||||
|
||||
type FlowCreateResponse struct {
|
||||
Data FlowCreateData `json:"data"`
|
||||
}
|
||||
|
||||
type FlowCreateData struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type FlowActionResponse struct {
|
||||
Data FlowActionData `json:"data"`
|
||||
}
|
||||
|
||||
type FlowActionData struct {
|
||||
Success bool `json:"success"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phone number management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PhoneNumberListResponse struct {
|
||||
Data []PhoneNumberListItem `json:"data"`
|
||||
Paging *PagingInfo `json:"paging,omitempty"`
|
||||
}
|
||||
|
||||
type PhoneNumberListItem struct {
|
||||
ID string `json:"id"`
|
||||
DisplayPhoneNumber string `json:"display_phone_number"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
VerifiedName string `json:"verified_name"`
|
||||
CodeVerificationStatus string `json:"code_verification_status"`
|
||||
QualityRating string `json:"quality_rating"`
|
||||
Throughput ThroughputInfo `json:"throughput"`
|
||||
}
|
||||
|
||||
type RequestCodeRequest struct {
|
||||
CertificateData string `json:"certificate_data,omitempty"`
|
||||
}
|
||||
|
||||
type RequestCodeResponse struct {
|
||||
Data RequestCodeData `json:"data"`
|
||||
}
|
||||
|
||||
type RequestCodeData struct {
|
||||
MessageStatus string `json:"message_status"` // "CODE_SENT"
|
||||
}
|
||||
|
||||
type VerifyCodeRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type VerifyCodeResponse struct {
|
||||
Data VerifyCodeData `json:"data"`
|
||||
}
|
||||
|
||||
type VerifyCodeData struct {
|
||||
MessageStatus string `json:"message_status"` // "CODE_VERIFIED"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared / pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PagingInfo struct {
|
||||
Cursors PagingCursors `json:"cursors"`
|
||||
Next string `json:"next,omitempty"`
|
||||
}
|
||||
|
||||
type PagingCursors struct {
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user