Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e926cc4c6b | ||
|
|
b1acd38c10 | ||
|
|
7b732e6a16 | ||
|
|
c349785bb9 |
@@ -0,0 +1,25 @@
|
||||
class Channels::Whatsapp::WebhookSetupJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
# Meta's Graph API calls (phone registration + webhook subscription) are slow and fail
|
||||
# transiently. Retry a few times, and once retries are exhausted mark the channel for
|
||||
# reauthorization so the inbox has a visible recovery path instead of silently missing its
|
||||
# webhook subscription. Running these off the request thread also keeps them clear of the
|
||||
# 15s Rack::Timeout, which previously aborted inbox creation and rolled it back.
|
||||
retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
channel = job.arguments.first
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed after retries: #{error.message}")
|
||||
channel.prompt_reauthorization! if channel.is_a?(Channel::Whatsapp)
|
||||
end
|
||||
|
||||
# A deleted channel can't be set up; discard instead of retrying (takes precedence over
|
||||
# the StandardError retry above, which would otherwise catch DeserializationError too).
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(whatsapp_channel, run_health_check: false)
|
||||
whatsapp_channel.setup_webhooks
|
||||
# Health check runs only after registration so a freshly provisioned number
|
||||
# isn't flagged as pending before setup_webhooks has a chance to register it.
|
||||
whatsapp_channel.check_provisioning_health if run_health_check
|
||||
end
|
||||
end
|
||||
@@ -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,15 +120,47 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
delegate :media_url, to: :provider_service
|
||||
delegate :api_headers, to: :provider_service
|
||||
|
||||
# Runs inside Channels::Whatsapp::WebhookSetupJob, off the request thread so the slow Meta
|
||||
# Graph calls can't trip Rack::Timeout. Raises on failure so the job can retry the flaky
|
||||
# calls and, once retries are exhausted, mark the channel for reauthorization.
|
||||
def setup_webhooks
|
||||
perform_webhook_setup
|
||||
end
|
||||
|
||||
# Enqueue on the same channel record so GlobalID resolves it in the job. If the queue
|
||||
# is unavailable, fall back to prompting reauthorization so the inbox has a visible
|
||||
# recovery path instead of silently committing without its webhook registered.
|
||||
def enqueue_webhook_setup(run_health_check: false)
|
||||
Channels::Whatsapp::WebhookSetupJob.perform_later(self, run_health_check: run_health_check)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
|
||||
Rails.logger.error "[WHATSAPP] Failed to enqueue webhook setup: #{e.message}"
|
||||
prompt_reauthorization!
|
||||
end
|
||||
|
||||
# Runs after webhook registration (inside WebhookSetupJob) so it observes the
|
||||
# post-registration provisioning state; prompts reauthorization if Meta still reports
|
||||
# the number as not provisioned. Only used for new embedded-signup channels — running
|
||||
# it before registration would spuriously flag freshly created numbers as pending.
|
||||
def check_provisioning_health
|
||||
health_data = Whatsapp::HealthService.new(self).fetch_health_status
|
||||
return unless health_data
|
||||
|
||||
if provisioning_pending?(health_data)
|
||||
prompt_reauthorization!
|
||||
else
|
||||
Rails.logger.info "[WHATSAPP] Channel #{phone_number} health check passed"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Health check failed for channel #{phone_number}: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provisioning_pending?(health_data)
|
||||
health_data[:platform_type] == 'NOT_APPLICABLE' ||
|
||||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
|
||||
end
|
||||
|
||||
def ensure_webhook_verify_token
|
||||
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
@@ -15,15 +15,14 @@ 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
|
||||
# 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.
|
||||
check_channel_health_and_prompt_reauth(channel) if @inbox_id.blank?
|
||||
# 2. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
||||
# The job runs Meta's slow phone-registration/subscription calls off the request thread so they
|
||||
# can't trip Rack::Timeout and roll back the just-created channel. The provisioning health check
|
||||
# runs inside the job, after registration — and only for new channels, since a reauthorized
|
||||
# number in a pending state would otherwise trigger a spurious disconnect right after reauth.
|
||||
channel.enqueue_webhook_setup(run_health_check: @inbox_id.blank?)
|
||||
channel
|
||||
|
||||
rescue StandardError => e
|
||||
@@ -55,24 +54,6 @@ class Whatsapp::EmbeddedSignupService
|
||||
end
|
||||
end
|
||||
|
||||
def check_channel_health_and_prompt_reauth(channel)
|
||||
health_data = Whatsapp::HealthService.new(channel).fetch_health_status
|
||||
return unless health_data
|
||||
|
||||
if channel_in_pending_state?(health_data)
|
||||
channel.prompt_reauthorization!
|
||||
else
|
||||
Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}"
|
||||
end
|
||||
|
||||
def channel_in_pending_state?(health_data)
|
||||
health_data[:platform_type] == 'NOT_APPLICABLE' ||
|
||||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
|
||||
end
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channels::Whatsapp::WebhookSetupJob do
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
|
||||
it 'runs webhook setup' do
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(channel).to have_received(:setup_webhooks)
|
||||
end
|
||||
|
||||
it 'runs the provisioning health check when requested' do
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
allow(channel).to receive(:check_provisioning_health)
|
||||
|
||||
described_class.perform_now(channel, run_health_check: true)
|
||||
|
||||
expect(channel).to have_received(:check_provisioning_health)
|
||||
end
|
||||
|
||||
it 'skips the provisioning health check by default' do
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
allow(channel).to receive(:check_provisioning_health)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(channel).not_to have_received(:check_provisioning_health)
|
||||
end
|
||||
|
||||
context 'when webhook setup keeps failing' do
|
||||
before do
|
||||
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
||||
allow(setup_service).to receive(:perform).and_raise(StandardError, 'meta down')
|
||||
end
|
||||
|
||||
it 'retries and marks the channel for reauthorization once retries are exhausted' do
|
||||
expect(channel.reauthorization_required?).to be false
|
||||
|
||||
perform_enqueued_jobs { described_class.perform_later(channel) }
|
||||
|
||||
expect(channel.reauthorization_required?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,
|
||||
@@ -159,6 +158,60 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enqueue_webhook_setup' do
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, account: create(:account),
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
|
||||
it 'enqueues the setup job with the health-check flag' do
|
||||
expect { channel.enqueue_webhook_setup(run_health_check: true) }
|
||||
.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel, run_health_check: true)
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when the queue is unavailable' do
|
||||
allow(Channels::Whatsapp::WebhookSetupJob).to receive(:perform_later).and_raise(StandardError, 'redis down')
|
||||
|
||||
expect(channel.reauthorization_required?).to be false
|
||||
channel.enqueue_webhook_setup
|
||||
expect(channel.reauthorization_required?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#check_provisioning_health' do
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, account: create(:account),
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:health_service) { instance_double(Whatsapp::HealthService) }
|
||||
|
||||
before { allow(Whatsapp::HealthService).to receive(:new).with(channel).and_return(health_service) }
|
||||
|
||||
it 'prompts reauthorization when platform_type is NOT_APPLICABLE' do
|
||||
allow(health_service).to receive(:fetch_health_status)
|
||||
.and_return(platform_type: 'NOT_APPLICABLE', throughput: { 'level' => 'STANDARD' })
|
||||
|
||||
channel.check_provisioning_health
|
||||
expect(channel.reauthorization_required?).to be true
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
|
||||
allow(health_service).to receive(:fetch_health_status)
|
||||
.and_return(platform_type: 'CLOUD_API', throughput: { 'level' => 'NOT_APPLICABLE' })
|
||||
|
||||
channel.check_provisioning_health
|
||||
expect(channel.reauthorization_required?).to be true
|
||||
end
|
||||
|
||||
it 'does not prompt reauthorization for a healthy number' do
|
||||
allow(health_service).to receive(:fetch_health_status)
|
||||
.and_return(platform_type: 'CLOUD_API', throughput: { 'level' => 'STANDARD' })
|
||||
|
||||
channel.check_provisioning_health
|
||||
expect(channel.reauthorization_required?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#teardown_webhooks' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -41,67 +44,11 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
.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({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates channel and sets up webhooks' do
|
||||
expect(channel).to receive(:setup_webhooks)
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
it 'checks health status after channel creation' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
expect(health_service).to receive(:fetch_health_status)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when channel is in pending state' do
|
||||
it 'prompts reauthorization for pending channel' do
|
||||
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({
|
||||
platform_type: 'NOT_APPLICABLE',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
|
||||
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({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'NOT_APPLICABLE' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is healthy' do
|
||||
it 'does not prompt reauthorization for healthy channel' do
|
||||
expect(channel).not_to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
it 'creates the channel and enqueues webhook setup with the health check' do
|
||||
expect { service.perform }
|
||||
.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel, run_health_check: true)
|
||||
end
|
||||
|
||||
context 'when parameters are invalid' do
|
||||
@@ -120,30 +67,6 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
|
||||
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
|
||||
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
|
||||
expect(real_channel.reauthorization_required?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reauthorization flow' do
|
||||
@@ -162,8 +85,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 +94,11 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses ReauthorizationService and sets up webhooks' do
|
||||
it 'uses ReauthorizationService and enqueues webhook setup without the health check' 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, run_health_check: false)
|
||||
end
|
||||
|
||||
context 'with real channel requiring reauthorization' do
|
||||
|
||||
Reference in New Issue
Block a user