diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 46cf65f3b..2a558cd13 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -69,8 +69,10 @@ class Channel::Whatsapp < ApplicationRecord end end - # Enables voice: turns calling on at Meta (idempotent), subscribes the `calls` - # webhook field, and sets calling_enabled. Raises on Meta failure. + # Enables voice: turns calling on at Meta (idempotent), then re-registers webhooks + # with the in-memory calling_enabled flag so the `calls` field is subscribed. The + # flag is persisted only after registration succeeds, so a webhook failure can't + # leave the inbox reporting voice_enabled? while the WABA isn't subscribed to calls. # Saved with validate: false to skip validate_provider_config's remote credential # re-check, which could spuriously fail and desync the flag from Meta. def enable_voice_calling! @@ -78,21 +80,21 @@ class Channel::Whatsapp < ApplicationRecord raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice') provider_service.update_calling_status('ENABLED') - webhook_setup_service.register_callback self.provider_config = provider_config.merge('calling_enabled' => true) + webhook_setup_service.register_callback save!(validate: false) end - # Disables voice: unsets calling_enabled (gates the call subsystem) and drops - # `calls` from the webhook subscription (best-effort, so a Meta outage can't - # trap admins). Leaves Meta's WABA calling.status untouched. + # Disables voice: unsets calling_enabled (gates the call subsystem) and re-registers + # webhooks, which drops `calls` from the subscription (best-effort, so a Meta outage + # can't trap admins). Leaves Meta's WABA calling.status untouched. def disable_voice_calling! raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported? self.provider_config = provider_config.merge('calling_enabled' => false) save!(validate: false) begin - webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes]) + webhook_setup_service.register_callback rescue StandardError => e Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}" end diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index eef84b022..22e75aac0 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -60,7 +60,7 @@ class Whatsapp::FacebookApiClient data['code_verification_status'] == 'VERIFIED' end - WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze + WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes].freeze def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS) # Step 1: Subscribe app to WABA first (required before override) diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb index a287b4977..2abf113da 100644 --- a/app/services/whatsapp/webhook_setup_service.rb +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService setup_webhook end - def register_callback(subscribed_fields: nil) + def register_callback validate_parameters! - setup_webhook(subscribed_fields: subscribed_fields) + setup_webhook end private @@ -55,21 +55,23 @@ class Whatsapp::WebhookSetupService @channel.save! end - def setup_webhook(subscribed_fields: nil) + def setup_webhook callback_url = build_callback_url verify_token = @channel.provider_config['webhook_verify_token'] - args = [@waba_id, callback_url, verify_token] - if subscribed_fields - @api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields) - else - @api_client.subscribe_waba_webhook(*args) - end + @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields) rescue StandardError => e Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}") raise "Webhook setup failed: #{e.message}" end + # Subscribe to `calls` only when voice calling is enabled on the inbox + def subscribed_fields + fields = %w[messages smb_message_echoes] + fields << 'calls' if @channel.provider_config['calling_enabled'] + fields + end + def build_callback_url frontend_url = ENV.fetch('FRONTEND_URL', nil) phone_number = @channel.phone_number diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index a33999bee..74fb2f6e2 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -177,7 +177,7 @@ describe Whatsapp::FacebookApiClient do .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes calls] }.to_json + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return( status: 200, @@ -224,7 +224,7 @@ describe Whatsapp::FacebookApiClient do .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, body: { override_callback_uri: callback_url, verify_token: verify_token, - subscribed_fields: %w[messages smb_message_echoes calls] }.to_json + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) end diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index d35d14cb9..e80036f32 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -43,7 +43,7 @@ describe Whatsapp::WebhookSetupService do allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end @@ -51,7 +51,8 @@ describe Whatsapp::WebhookSetupService do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages + smb_message_echoes]) service.perform end end @@ -65,14 +66,15 @@ describe Whatsapp::WebhookSetupService do throughput: { level: 'APPLICABLE' } }) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'does NOT register phone, but sets up webhook' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).not_to receive(:register_phone_number) expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages + smb_message_echoes]) service.perform end end @@ -88,7 +90,7 @@ describe Whatsapp::WebhookSetupService do allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end @@ -96,7 +98,8 @@ describe Whatsapp::WebhookSetupService do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages + smb_message_echoes]) service.perform end end @@ -112,7 +115,7 @@ describe Whatsapp::WebhookSetupService do allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456) allow(api_client).to receive(:register_phone_number).with('123456789', 223_456) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) allow(channel).to receive(:save!) end @@ -120,7 +123,8 @@ describe Whatsapp::WebhookSetupService do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:register_phone_number).with('123456789', 223_456) expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token') + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token', subscribed_fields: %w[messages + smb_message_echoes]) service.perform end end @@ -279,14 +283,15 @@ describe Whatsapp::WebhookSetupService do throughput: { level: 'APPLICABLE' } }) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'successfully reauthorizes with new access token' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).not_to receive(:register_phone_number) expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token') + .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token', + subscribed_fields: %w[messages smb_message_echoes]) service_reauth.perform end end @@ -294,7 +299,7 @@ describe Whatsapp::WebhookSetupService do it 'uses the existing webhook verify token during reauthorization' do with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do expect(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'existing_verify_token') + .with(waba_id, anything, 'existing_verify_token', subscribed_fields: %w[messages smb_message_echoes]) service_reauth.perform end end @@ -308,7 +313,7 @@ describe Whatsapp::WebhookSetupService do throughput: { level: 'APPLICABLE' } }) allow(api_client).to receive(:subscribe_waba_webhook) - .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true }) + .with(waba_id, anything, 'test_verify_token', subscribed_fields: %w[messages smb_message_echoes]).and_return({ 'success' => true }) end it 'completes successfully without errors' do