From 19ec392f3c2fe334d1403c626c51fe1981efc563 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 14 Jul 2026 14:49:23 +0530 Subject: [PATCH] fix(twilio): register messaging webhook for voice inboxes and add webhook health tab --- .../channels/twilio_channels_controller.rb | 2 +- ...nagement.rb => inbox_health_management.rb} | 37 ++++-- .../api/v1/accounts/inboxes_controller.rb | 2 +- .../dashboard/i18n/locale/en/inboxMgmt.json | 19 +++ .../dashboard/settings/inbox/Settings.vue | 24 +++- .../inbox/components/TwilioHealth.vue | 123 ++++++++++++++++++ app/services/twilio/health_service.rb | 52 ++++++++ app/services/twilio/webhook_setup_service.rb | 6 +- .../api/v1/accounts/inboxes_controller.rb | 7 +- .../models/enterprise/channel/twilio_sms.rb | 9 ++ .../twilio/voice_webhook_setup_service.rb | 54 +++++--- .../v1/accounts/inboxes_controller_spec.rb | 51 +++++++- .../v1/accounts/inboxes_controller_spec.rb | 4 + spec/services/twilio/health_service_spec.rb | 110 ++++++++++++++++ .../twilio/webhook_setup_service_spec.rb | 6 +- 15 files changed, 463 insertions(+), 43 deletions(-) rename app/controllers/api/v1/accounts/concerns/{whatsapp_health_management.rb => inbox_health_management.rb} (56%) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/components/TwilioHealth.vue create mode 100644 app/services/twilio/health_service.rb create mode 100644 spec/services/twilio/health_service_spec.rb diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index 1691b5489..192520b21 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -36,7 +36,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: end def setup_webhooks - ::Twilio::WebhookSetupService.new(inbox: @inbox).perform + ::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform end def phone_number diff --git a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb b/app/controllers/api/v1/accounts/concerns/inbox_health_management.rb similarity index 56% rename from app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb rename to app/controllers/api/v1/accounts/concerns/inbox_health_management.rb index 795d7f2a9..d1353725f 100644 --- a/app/controllers/api/v1/accounts/concerns/whatsapp_health_management.rb +++ b/app/controllers/api/v1/accounts/concerns/inbox_health_management.rb @@ -1,10 +1,10 @@ -module Api::V1::Accounts::Concerns::WhatsappHealthManagement +module Api::V1::Accounts::Concerns::InboxHealthManagement extend ActiveSupport::Concern included do skip_before_action :check_authorization, only: [:health, :register_webhook] before_action :check_admin_authorization?, only: [:register_webhook] - before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook] + before_action :validate_health_supported_channel, only: [:health, :register_webhook] end def sync_templates @@ -17,15 +17,14 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement end def health - health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status - render json: health_data + render json: fetch_health_data rescue StandardError => e Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}" render json: { error: e.message }, status: :unprocessable_entity end def register_webhook - Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback + register_channel_webhook render json: { message: 'Webhook registered successfully' }, status: :ok rescue StandardError => e @@ -35,10 +34,32 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement private - def validate_whatsapp_cloud_channel - return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + def fetch_health_data + return Whatsapp::HealthService.new(@inbox.channel).fetch_health_status if whatsapp_cloud_channel? - render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request + Twilio::HealthService.new(channel: @inbox.channel).perform + end + + def register_channel_webhook + return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel? + + Twilio::WebhookSetupService.new(channel: @inbox.channel).perform + # No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging. + @inbox.channel.try(:reprovision_voice_webhooks!) + end + + def validate_health_supported_channel + return if whatsapp_cloud_channel? || twilio_sms_channel? + + render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request + end + + def whatsapp_cloud_channel? + @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud' + end + + def twilio_sms_channel? + @inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms? end def whatsapp_channel? diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index bb9ca2a70..c1221ff79 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # we are already handling the authorization in fetch inbox before_action :check_authorization, except: [:show] - include Api::V1::Accounts::Concerns::WhatsappHealthManagement + include Api::V1::Accounts::Concerns::InboxHealthManagement def index @inboxes = policy_scope(Current.account.inboxes) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 50d8ba3f9..dd0bfac63 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -687,6 +687,25 @@ }, "CHANNEL_PREFERENCES": "Channel Preferences", "WIDGET_FEATURES": "Widget features", + "TWILIO_HEALTH": { + "TITLE": "Webhook configuration", + "DESCRIPTION": "Twilio needs these webhooks configured on your number for Chatwoot to receive messages and calls", + "NO_DATA": "Health data is not available", + "WEBHOOKS": { + "MESSAGING": "Messaging webhook", + "VOICE": "Voice webhook", + "VOICE_STATUS": "Voice status callback", + "VOICE_APP": "Outbound calling (TwiML app)" + }, + "WEBHOOK": { + "CONFIGURED_SUCCESS": "Webhook configured successfully", + "ACTION_REQUIRED": "Webhook not configured", + "URL_MISMATCH": "Webhook URL mismatch", + "REGISTER_BUTTON": "Register Webhook", + "NOT_SET": "not set", + "TOOLTIP": "Expected {expected}, currently set to {actual}" + } + }, "ACCOUNT_HEALTH": { "TITLE": "Manage your WhatsApp account", "DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 8e42d0c27..df32d6da9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -29,6 +29,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import BotConfiguration from './components/BotConfiguration.vue'; import AccountHealth from './components/AccountHealth.vue'; +import TwilioHealth from './components/TwilioHealth.vue'; import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue'; import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue'; import { FEATURE_FLAGS } from '../../../../featureFlags'; @@ -80,6 +81,7 @@ export default { ColorPicker, SelectInput, AccountHealth, + TwilioHealth, WhatsappManualMigrationDialog, WhatsappManualMigrationBanner, Widget, @@ -159,6 +161,9 @@ export default { shouldShowWhatsAppConfiguration() { return this.isAWhatsAppCloudChannel; }, + shouldShowTwilioHealth() { + return this.isATwilioChannel && this.inbox.medium === 'sms'; + }, whatsAppAPIProviderName() { if (this.isAWhatsAppCloudChannel) { return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'); @@ -243,6 +248,16 @@ export default { ]; } + if (this.shouldShowTwilioHealth) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'twilio-health', + name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'), + }, + ]; + } + if ( this.isATwilioChannel && this.inbox.phone_number && @@ -572,7 +587,7 @@ export default { async fetchHealthData() { if (!this.inbox) return; - if (!this.isAWhatsAppCloudChannel) { + if (!this.isAWhatsAppCloudChannel && !this.shouldShowTwilioHealth) { return; } @@ -1408,6 +1423,13 @@ export default { @register-webhook="registerWebhook" /> +
+ +
+import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import ButtonV4 from 'next/button/Button.vue'; +import Icon from 'dashboard/components-next/icon/Icon.vue'; + +const props = defineProps({ + healthData: { + type: Object, + default: null, + }, + isRegisteringWebhook: { + type: Boolean, + default: false, + }, +}); + +const emit = defineEmits(['registerWebhook']); + +const { t } = useI18n(); + +const WEBHOOK_LABELS = { + messaging: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.MESSAGING', + voice: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE', + voice_status: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE_STATUS', + voice_app: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE_APP', +}; + +const webhooks = computed(() => + (props.healthData?.webhooks || []).map(webhook => ({ + ...webhook, + label: t(WEBHOOK_LABELS[webhook.name]), + tooltip: t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.TOOLTIP', { + expected: webhook.expected, + actual: webhook.actual || t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.NOT_SET'), + }), + })) +); + +const handleRegisterWebhook = () => emit('registerWebhook'); + + + diff --git a/app/services/twilio/health_service.rb b/app/services/twilio/health_service.rb new file mode 100644 index 000000000..d257dd76d --- /dev/null +++ b/app/services/twilio/health_service.rb @@ -0,0 +1,52 @@ +class Twilio::HealthService + include Rails.application.routes.url_helpers + + pattr_initialize [:channel!] + + # Compares the webhooks Twilio actually has against the ones Chatwoot expects. + # Errors (bad credentials, unknown number) bubble up to the controller as a 422. + def perform + webhooks = channel.messaging_service_sid? ? messaging_service_webhooks : phone_number_webhooks + + { + status: webhooks.all? { |webhook| webhook[:configured] } ? 'healthy' : 'misconfigured', + webhooks: webhooks + } + end + + private + + def messaging_service_webhooks + service = channel.client.messaging.services(channel.messaging_service_sid).fetch + + [webhook('messaging', twilio_callback_index_url, service.inbound_request_url)] + end + + def phone_number_webhooks + number = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number).first + raise "Phone number #{channel.phone_number} was not found in the connected Twilio account" if number.nil? + + webhooks = [webhook('messaging', twilio_callback_index_url, number.sms_url)] + webhooks += voice_webhooks(number) if channel.voice_enabled? + webhooks + end + + def voice_webhooks(number) + [ + webhook('voice', channel.voice_call_webhook_url, number.voice_url), + webhook('voice_status', channel.voice_status_webhook_url, number.status_callback), + # Outbound calls dial through the TwiML app, so a stale voice_url here breaks them silently. + webhook('voice_app', channel.voice_call_webhook_url, twiml_app_voice_url) + ] + end + + def twiml_app_voice_url + return if channel.twiml_app_sid.blank? + + channel.client.applications(channel.twiml_app_sid).fetch.voice_url + end + + def webhook(name, expected, actual) + { name: name, expected: expected, actual: actual.presence, configured: expected == actual } + end +end diff --git a/app/services/twilio/webhook_setup_service.rb b/app/services/twilio/webhook_setup_service.rb index b949bf37c..fbc726243 100644 --- a/app/services/twilio/webhook_setup_service.rb +++ b/app/services/twilio/webhook_setup_service.rb @@ -1,7 +1,7 @@ class Twilio::WebhookSetupService include Rails.application.routes.url_helpers - pattr_initialize [:inbox!] + pattr_initialize [:channel!] def perform if channel.messaging_service_sid? @@ -41,10 +41,6 @@ class Twilio::WebhookSetupService @phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number) end - def channel - @channel ||= inbox.channel - end - def twilio_client @twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token) end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index cdeeda74e..67a05ba37 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -100,7 +100,7 @@ module Enterprise::Api::V1::Accounts::InboxesController ) config = voice_params[:provider_config] || {} - Current.account.twilio_sms.create!( + channel = Current.account.twilio_sms.create!( phone_number: voice_params[:phone_number], account_sid: config[:account_sid], auth_token: config[:auth_token], @@ -109,5 +109,10 @@ module Enterprise::Api::V1::Accounts::InboxesController medium: :sms, voice_enabled: true ) + + # A voice channel is an SMS channel with voice_enabled, so it needs the messaging + # webhook too. Voice webhooks are provisioned by the model's provision_twiml_app hook. + ::Twilio::WebhookSetupService.new(channel: channel).perform + channel end end diff --git a/enterprise/app/models/enterprise/channel/twilio_sms.rb b/enterprise/app/models/enterprise/channel/twilio_sms.rb index 15c201795..8c02591db 100644 --- a/enterprise/app/models/enterprise/channel/twilio_sms.rb +++ b/enterprise/app/models/enterprise/channel/twilio_sms.rb @@ -20,6 +20,15 @@ module Enterprise::Channel::TwilioSms ) end + # Re-points the TwiML app and the number at our voice webhooks, reusing the existing app. + def reprovision_voice_webhooks! + return unless voice_enabled? + + service = ::Twilio::VoiceWebhookSetupService.new(channel: self) + update!(twiml_app_sid: service.sync_twiml_app!) + service.configure_number_webhooks! + end + def voice_call_webhook_url digits = phone_number.delete_prefix('+') Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) diff --git a/enterprise/app/services/twilio/voice_webhook_setup_service.rb b/enterprise/app/services/twilio/voice_webhook_setup_service.rb index e9a679b36..2342399fb 100644 --- a/enterprise/app/services/twilio/voice_webhook_setup_service.rb +++ b/enterprise/app/services/twilio/voice_webhook_setup_service.rb @@ -14,6 +14,40 @@ class Twilio::VoiceWebhookSetupService app_sid end + # Outbound calls dial through the TwiML app, so its voice_url must track the current host too. + def sync_twiml_app! + return create_twiml_app! if channel.twiml_app_sid.blank? + + channel.client.applications(channel.twiml_app_sid).update( + voice_url: channel.voice_call_webhook_url, + voice_method: HTTP_METHOD + ) + channel.twiml_app_sid + rescue StandardError => e + log_twilio_error('TWIML_APP_UPDATE', e) + raise + end + + def configure_number_webhooks! + numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) + if numbers.empty? + Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" + return + end + + channel.client + .incoming_phone_numbers(numbers.first.sid) + .update( + voice_url: channel.voice_call_webhook_url, + voice_method: HTTP_METHOD, + status_callback: channel.voice_status_webhook_url, + status_callback_method: HTTP_METHOD + ) + rescue StandardError => e + log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e) + raise + end + private def validate_token_credentials! @@ -36,26 +70,6 @@ class Twilio::VoiceWebhookSetupService raise end - def configure_number_webhooks! - numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) - if numbers.empty? - Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" - return - end - - channel.client - .incoming_phone_numbers(numbers.first.sid) - .update( - voice_url: channel.voice_call_webhook_url, - voice_method: HTTP_METHOD, - status_callback: channel.voice_status_webhook_url, - status_callback_method: HTTP_METHOD - ) - rescue StandardError => e - log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e) - raise - end - def log_twilio_error(context, error) details = build_error_details(context, error) add_twilio_specific_details(details, error) diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 0fd6ad7bf..f590c2935 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -1233,7 +1233,7 @@ RSpec.describe 'Inboxes API', type: :request do expect(response).to have_http_status(:bad_request) json_response = response.parsed_body - expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels') end it 'returns bad request error for agent' do @@ -1245,7 +1245,7 @@ RSpec.describe 'Inboxes API', type: :request do expect(response).to have_http_status(:bad_request) json_response = response.parsed_body - expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels') end end @@ -1262,7 +1262,7 @@ RSpec.describe 'Inboxes API', type: :request do expect(response).to have_http_status(:bad_request) json_response = response.parsed_body - expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels') + expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels') end end @@ -1277,4 +1277,49 @@ RSpec.describe 'Inboxes API', type: :request do end end end + + describe 'Twilio inbox health' do + let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account) } + let(:twilio_inbox) { create(:inbox, account: account, channel: twilio_channel) } + let(:health_service) { instance_double(Twilio::HealthService) } + let(:health_data) do + { status: 'misconfigured', webhooks: [{ name: 'messaging', configured: false }] } + end + + let(:webhook_service) { instance_double(Twilio::WebhookSetupService, perform: true) } + + before do + allow(Twilio::HealthService).to receive(:new).with(channel: twilio_channel).and_return(health_service) + allow(health_service).to receive(:perform).and_return(health_data) + allow(Twilio::WebhookSetupService).to receive(:new).with(channel: twilio_channel).and_return(webhook_service) + end + + it 'returns the twilio webhook health' do + get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}/health", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['status']).to eq('misconfigured') + end + + it 'returns bad request for a twilio whatsapp inbox' do + whatsapp_medium_inbox = create(:inbox, account: account, channel: create(:channel_twilio_sms, :whatsapp, account: account)) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_medium_inbox.id}/health", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:bad_request) + end + + it 'registers the messaging webhook' do + post "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}/register_webhook", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(webhook_service).to have_received(:perform) + end + end end diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb index ead387995..c28e2ab50 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb @@ -31,6 +31,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do headers: { 'Content-Type' => 'application/json' }) allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + messaging_webhook_service = instance_double(Twilio::WebhookSetupService, perform: true) + allow(Twilio::WebhookSetupService).to receive(:new).and_return(messaging_webhook_service) post "/api/v1/accounts/#{account.id}/inboxes", headers: admin.create_new_auth_token, @@ -45,6 +47,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include('Voice Inbox') expect(response.body).to include('+15551234567') + # the number must also receive SMS, not just calls + expect(messaging_webhook_service).to have_received(:perform) end end end diff --git a/spec/services/twilio/health_service_spec.rb b/spec/services/twilio/health_service_spec.rb new file mode 100644 index 000000000..19ab359a2 --- /dev/null +++ b/spec/services/twilio/health_service_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +NUMBER_INSTANCE = Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberInstance +NUMBER_LIST = Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList + +describe Twilio::HealthService do + include Rails.application.routes.url_helpers + + let(:twilio_client) { instance_double(Twilio::REST::Client) } + let(:numbers_list) { instance_double(NUMBER_LIST) } + + before do + allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client) + allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list) + end + + describe '#perform' do + context 'with a phone number' do + let(:channel) { create(:channel_twilio_sms, :with_phone_number) } + let(:sms_url) { twilio_callback_index_url } + + before do + allow(numbers_list).to receive(:list).and_return([instance_double(NUMBER_INSTANCE, sms_url: sms_url)]) + end + + it 'reports healthy when the messaging webhook points at chatwoot' do + result = described_class.new(channel: channel).perform + + expect(result[:status]).to eq('healthy') + expect(result[:webhooks]).to contain_exactly(hash_including(name: 'messaging', configured: true)) + end + + context 'when the messaging webhook points elsewhere' do + let(:sms_url) { 'https://demo.twilio.com/welcome/sms/reply' } + + it 'reports misconfigured with the current url' do + result = described_class.new(channel: channel).perform + + expect(result[:status]).to eq('misconfigured') + expect(result[:webhooks].first).to include(configured: false, expected: twilio_callback_index_url, actual: sms_url) + end + end + + context 'when the number is missing from the twilio account' do + before { allow(numbers_list).to receive(:list).and_return([]) } + + it 'raises' do + expect { described_class.new(channel: channel).perform }.to raise_error(/was not found/) + end + end + end + + context 'with voice enabled' do + let(:channel) { create(:channel_twilio_sms, :with_voice) } + let(:sms_url) { nil } + let(:number) do + instance_double(NUMBER_INSTANCE, sms_url: sms_url, voice_url: channel.voice_call_webhook_url, + status_callback: channel.voice_status_webhook_url) + end + let(:twiml_app) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + allow(numbers_list).to receive(:list).and_return([number]) + allow(twilio_client).to receive(:applications).and_return(twiml_app) + allow(twiml_app).to receive(:fetch).and_return( + instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationInstance, voice_url: twiml_app_voice_url) + ) + end + + context 'when everything is registered' do + let(:sms_url) { twilio_callback_index_url } + let(:twiml_app_voice_url) { channel.voice_call_webhook_url } + + it 'reports healthy' do + result = described_class.new(channel: channel).perform + + expect(result[:status]).to eq('healthy') + expect(result[:webhooks].map { |webhook| webhook[:name] }).to eq(%w[messaging voice voice_status voice_app]) + end + end + + context 'when the twiml app points at a stale host' do + let(:sms_url) { twilio_callback_index_url } + let(:twiml_app_voice_url) { 'https://old-host.example.com/twilio/voice/call/15551234567' } + + it 'flags outbound calling as misconfigured even though the number is fine' do + result = described_class.new(channel: channel).perform + + expect(result[:status]).to eq('misconfigured') + expect(result[:webhooks]).to include(hash_including(name: 'voice', configured: true), + hash_including(name: 'voice_app', configured: false)) + end + end + + context 'when the messaging webhook is missing' do + let(:twiml_app_voice_url) { channel.voice_call_webhook_url } + + it 'flags messaging while voice stays configured' do + result = described_class.new(channel: channel).perform + + expect(result[:status]).to eq('misconfigured') + expect(result[:webhooks]).to include(hash_including(name: 'messaging', configured: false), + hash_including(name: 'voice', configured: true)) + end + end + end + end +end diff --git a/spec/services/twilio/webhook_setup_service_spec.rb b/spec/services/twilio/webhook_setup_service_spec.rb index fbbc11a60..732b07b76 100644 --- a/spec/services/twilio/webhook_setup_service_spec.rb +++ b/spec/services/twilio/webhook_setup_service_spec.rb @@ -23,7 +23,7 @@ describe Twilio::WebhookSetupService do end it 'updates the messaging service' do - described_class.new(inbox: channel_twilio_sms.inbox).perform + described_class.new(channel: channel_twilio_sms).perform expect(services).to have_received(:update) end @@ -44,7 +44,7 @@ describe Twilio::WebhookSetupService do allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double) allow(phone_double).to receive(:list).and_return([]) - described_class.new(inbox: channel_twilio_sms.inbox).perform + described_class.new(channel: channel_twilio_sms).perform expect(phone_double).not_to have_received(:update) end @@ -53,7 +53,7 @@ describe Twilio::WebhookSetupService do allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double) allow(phone_double).to receive(:list).and_return([phone_record_double]) - described_class.new(inbox: channel_twilio_sms.inbox).perform + described_class.new(channel: channel_twilio_sms).perform expect(phone_double).to have_received(:update).with( sms_method: 'POST',