diff --git a/app/jobs/channels/whatsapp/webhook_setup_job.rb b/app/jobs/channels/whatsapp/webhook_setup_job.rb new file mode 100644 index 000000000..481b27103 --- /dev/null +++ b/app/jobs/channels/whatsapp/webhook_setup_job.rb @@ -0,0 +1,11 @@ +class Channels::Whatsapp::WebhookSetupJob < ApplicationJob + queue_as :low + + # Runs Meta's phone-registration and webhook-subscription calls off the request + # thread. Inline, these Graph API calls can exceed the 15s Rack::Timeout and, since + # RequestTimeoutException bypasses setup_webhooks' rescue, abort inbox creation and + # roll it back — leaving the number connected on Meta but no inbox in Chatwoot. + def perform(whatsapp_channel) + whatsapp_channel.setup_webhooks + end +end diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 87ff199d5..034206d6e 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -35,7 +35,7 @@ class Channel::Whatsapp < ApplicationRecord after_create :sync_templates after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config? before_destroy :teardown_webhooks - after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks? + after_commit :enqueue_webhook_setup, on: :create, if: :should_auto_setup_webhooks? def name 'Whatsapp' @@ -120,6 +120,8 @@ class Channel::Whatsapp < ApplicationRecord delegate :media_url, to: :provider_service delegate :api_headers, to: :provider_service + # Runs synchronously inside Channels::Whatsapp::WebhookSetupJob; the Meta Graph + # calls are kept off the request thread so a slow response can't trip Rack::Timeout. def setup_webhooks perform_webhook_setup rescue StandardError => e @@ -127,6 +129,11 @@ class Channel::Whatsapp < ApplicationRecord prompt_reauthorization! end + # Enqueue on the same channel record so GlobalID resolves it in the job. + def enqueue_webhook_setup + Channels::Whatsapp::WebhookSetupJob.perform_later(self) + end + private def ensure_webhook_verify_token diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 495d36975..3acbb75b5 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -15,11 +15,12 @@ class Whatsapp::EmbeddedSignupService phone_info = fetch_phone_info(access_token) channel = create_or_reauthorize_channel(access_token, phone_info) - # NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because: + # Enqueue webhook setup explicitly instead of relying on the after_commit callback because: # 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger - # 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes - # 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback - channel.setup_webhooks + # 2. The channel is marked with source: 'embedded_signup' to skip the after_commit callback + # Run it in a job so Meta's slow phone-registration/subscription calls can't trip Rack::Timeout + # and roll back the just-created channel. + Channels::Whatsapp::WebhookSetupJob.perform_later(channel) # Skip health check during reauthorization — phone numbers in pending provisioning state # (platform_type: NOT_APPLICABLE) would incorrectly trigger a disconnect email right after # a successful reauth. Only run health check for new channel creation. diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 10a192004..2bc344568 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -124,26 +124,25 @@ RSpec.describe Channel::Whatsapp do end context 'when channel is created through manual setup' do - it 'setups webhooks via after_commit callback' do - expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service) - expect(webhook_service).to receive(:perform) - + it 'enqueues webhook setup via after_commit callback' do # Explicitly set source to nil to test manual setup behavior (not embedded_signup) - create(:channel_whatsapp, - account: account, - provider: 'whatsapp_cloud', - provider_config: { - 'business_account_id' => 'test_waba_id', - 'api_key' => 'test_access_token', - 'source' => nil - }, - validate_provider_config: false, - sync_templates: false) + expect do + create(:channel_whatsapp, + account: account, + provider: 'whatsapp_cloud', + provider_config: { + 'business_account_id' => 'test_waba_id', + 'api_key' => 'test_access_token', + 'source' => nil + }, + validate_provider_config: false, + sync_templates: false) + end.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob) end end context 'when channel is created with different provider' do - it 'does not setup webhooks for 360dialog provider' do + it 'does not enqueue webhook setup for 360dialog provider' do expect(Whatsapp::WebhookSetupService).not_to receive(:new) create(:channel_whatsapp, diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 9c65a131b..9a7fc4c7a 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -20,7 +20,10 @@ describe Whatsapp::EmbeddedSignupService do business_name: 'Test Business' } end - let(:channel) { instance_double(Channel::Whatsapp) } + let(:channel) do + create(:channel_whatsapp, account: account, phone_number: '+15550000001', + validate_provider_config: false, sync_templates: false) + end describe '#perform' do before do @@ -42,9 +45,6 @@ describe Whatsapp::EmbeddedSignupService do .and_return(channel_creation) allow(channel_creation).to receive(:perform).and_return(channel) - allow(channel).to receive(:setup_webhooks) - allow(channel).to receive(:phone_number).and_return('+1234567890') - health_service = instance_double(Whatsapp::HealthService) allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) allow(health_service).to receive(:fetch_health_status).and_return({ @@ -54,11 +54,8 @@ describe Whatsapp::EmbeddedSignupService do }) end - it 'creates channel and sets up webhooks' do - expect(channel).to receive(:setup_webhooks) - - result = service.perform - expect(result).to eq(channel) + it 'creates channel and enqueues webhook setup' do + expect { service.perform }.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel) end it 'checks health status after channel creation' do @@ -121,27 +118,15 @@ describe Whatsapp::EmbeddedSignupService do expect { service.perform }.to raise_error('Token error') end - it 'prompts reauthorization when webhook setup fails' do - # Create a real channel to test the actual webhook failure behavior + # Webhook setup now runs in Channels::Whatsapp::WebhookSetupJob; the channel's own + # setup_webhooks rescue marks it for reauthorization when the Meta calls fail. + it 'marks the channel for reauthorization when webhook setup fails' do real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890', validate_provider_config: false, sync_templates: false) - - # Mock the channel creation to return our real channel - channel_creation = instance_double(Whatsapp::ChannelCreationService) - allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation) - allow(channel_creation).to receive(:perform).and_return(real_channel) - - # Mock webhook setup to fail allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error') - # Verify channel is not marked for reauthorization initially expect(real_channel.reauthorization_required?).to be false - - # The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks) - result = service.perform - expect(result).to eq(real_channel) - - # Verify the channel is now marked for reauthorization + real_channel.setup_webhooks expect(real_channel.reauthorization_required?).to be true end end @@ -162,8 +147,6 @@ describe Whatsapp::EmbeddedSignupService do ).and_return(reauth_service) allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel) - allow(channel).to receive(:phone_number).and_return('+1234567890') - health_service = instance_double(Whatsapp::HealthService) allow(Whatsapp::HealthService).to receive(:new).and_return(health_service) allow(health_service).to receive(:fetch_health_status).and_return({ @@ -173,12 +156,10 @@ describe Whatsapp::EmbeddedSignupService do }) end - it 'uses ReauthorizationService and sets up webhooks' do + it 'uses ReauthorizationService and enqueues webhook setup' do expect(reauth_service).to receive(:perform) - expect(channel).to receive(:setup_webhooks) - result = service_with_inbox.perform - expect(result).to eq(channel) + expect { service_with_inbox.perform }.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel) end context 'with real channel requiring reauthorization' do