feat(channels): align whatsapp calling toggles

This commit is contained in:
2026-06-06 20:10:06 +08:00
parent 0277898979
commit e2fdb8a171
12 changed files with 436 additions and 10 deletions
+71 -2
View File
@@ -48,11 +48,15 @@ func NewWhatsAppService(repository *Repository) *WhatsAppService {
if apiVersion == "" {
apiVersion = "v22.0"
}
cloudBase := os.Getenv("WHATSAPP_CLOUD_BASE_URL")
if cloudBase == "" {
cloudBase = "https://graph.facebook.com"
}
return &WhatsAppService{
client: client,
repository: repository,
graphAPIBase: "https://graph.facebook.com/" + apiVersion,
graphAPIBase: fmt.Sprintf("%s/%s", cloudBase, apiVersion),
dialogAPIBase: "https://waba.360dialog.io",
}
}
@@ -400,6 +404,12 @@ func buildExpectedWhatsAppWebhookURL(phoneNumber string) string {
// Cloud API: POST /v18.0/{business_account_id}/subscribed_apps
// 360dialog: Webhook is configured via 360dialog Hub dashboard
func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error {
return s.SetupWebhookFields(ctx, channel, webhookURL, nil)
}
// SetupWebhookFields subscribes the app and overrides the callback URL/fields for a WABA.
// Reference: Whatsapp::FacebookApiClient#subscribe_waba_webhook.
func (s *WhatsAppService) SetupWebhookFields(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string, fields []string) error {
if channel.IsCloudAPI() {
url := fmt.Sprintf("%s/%s/subscribed_apps", s.graphAPIBase, channel.BusinessAccountID)
resp, err := s.client.R().
@@ -409,9 +419,29 @@ func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmode
if err != nil {
return fmt.Errorf("WhatsApp Cloud API webhook subscription failed: %w", err)
}
if resp.StatusCode() != 200 {
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
return fmt.Errorf("WhatsApp Cloud API webhook subscription returned status %d", resp.StatusCode())
}
if len(fields) == 0 {
fields = []string{"messages", "smb_message_echoes", "calls"}
}
body := map[string]any{
"override_callback_uri": webhookURL,
"verify_token": channel.WebhookVerifyToken,
"subscribed_fields": fields,
}
resp, err = s.client.R().
SetContext(ctx).
SetAuthToken(channel.AccessToken).
SetBody(body).
Post(url)
if err != nil {
return fmt.Errorf("WhatsApp Cloud API webhook callback override failed: %w", err)
}
if resp.StatusCode() < 200 || resp.StatusCode() >= 300 {
return fmt.Errorf("WhatsApp Cloud API webhook callback override returned status %d", resp.StatusCode())
}
applogger.L().Info("WhatsApp Cloud API webhook subscription created",
"business_account_id", channel.BusinessAccountID,
)
@@ -425,6 +455,45 @@ func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmode
return nil
}
// UpdateCallingStatus enables or disables Meta's WhatsApp Calling setting for a phone number.
// Reference: Enterprise::Whatsapp::Providers::WhatsappCloudService#update_calling_status.
func (s *WhatsAppService) UpdateCallingStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp, status string) error {
if !channel.IsCloudAPI() {
return fmt.Errorf("WhatsApp calling requires a whatsapp_cloud inbox")
}
url := fmt.Sprintf("%s/%s/settings", s.graphAPIBase, channel.PhoneNumberID)
resp, err := s.client.R().
SetContext(ctx).
SetAuthToken(channel.AccessToken).
SetBody(map[string]any{"calling": map[string]any{"status": status}}).
Post(url)
if err != nil {
return fmt.Errorf("WhatsApp calling status update failed: %w", err)
}
if resp.StatusCode() >= 200 && resp.StatusCode() < 300 {
return nil
}
return fmt.Errorf("%s", extractWhatsAppCallingError(resp.Body()))
}
func extractWhatsAppCallingError(body []byte) string {
var parsed map[string]any
if err := json.Unmarshal(body, &parsed); err != nil {
return "Failed to update calling status"
}
errorValue, ok := parsed["error"].(map[string]any)
if !ok {
return "Failed to update calling status"
}
if value, ok := errorValue["error_user_msg"].(string); ok && value != "" {
return value
}
if value, ok := errorValue["message"].(string); ok && value != "" {
return value
}
return "Failed to update calling status"
}
// === Internal Helper Methods ===
// validateAccessToken validates the WhatsApp access token by making a test API call.
+31
View File
@@ -45,6 +45,37 @@ func TestWhatsAppService_FetchHealthStatusFormatsChatwootPayload(t *testing.T) {
assert.NotContains(t, payload, "healthy")
}
func TestWhatsAppService_UpdateCallingStatusPostsSettings(t *testing.T) {
svc := NewWhatsAppService(nil)
svc.graphAPIBase = "https://graph.example.test/v22.0"
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "/v22.0/phone-123/settings", r.URL.Path)
assert.Equal(t, "Bearer token-abc", r.Header.Get("Authorization"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.JSONEq(t, `{"calling":{"status":"ENABLED"}}`, string(body))
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"success":true}`))}, nil
}))
channel := &channelmodel.ChannelWhatsApp{Provider: "whatsapp_cloud", PhoneNumberID: "phone-123", AccessToken: "token-abc"}
err := svc.UpdateCallingStatus(context.Background(), channel, "ENABLED")
require.NoError(t, err)
}
func TestWhatsAppService_UpdateCallingStatusUsesMetaErrorMessage(t *testing.T) {
svc := NewWhatsAppService(nil)
svc.graphAPIBase = "https://graph.example.test/v22.0"
svc.client.SetTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) {
body := `{"error":{"error_user_msg":"Calling is not available for this number","message":"fallback"}}`
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewBufferString(body))}, nil
}))
channel := &channelmodel.ChannelWhatsApp{Provider: "whatsapp_cloud", PhoneNumberID: "phone-123", AccessToken: "token-abc"}
err := svc.UpdateCallingStatus(context.Background(), channel, "ENABLED")
require.Error(t, err)
assert.Equal(t, "Calling is not available for this number", err.Error())
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {