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');
+
+
+
+
+
+
+
+ {{ t('INBOX_MGMT.TWILIO_HEALTH.TITLE') }}
+
+
+ {{ t('INBOX_MGMT.TWILIO_HEALTH.DESCRIPTION') }}
+
+
+
+
+
+
+
+ {{ webhook.label }}
+
+
+
+
+
+
+ {{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
+
+
+
+ {{
+ webhook.actual
+ ? t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.URL_MISMATCH')
+ : t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.ACTION_REQUIRED')
+ }}
+
+
+ {{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('INBOX_MGMT.TWILIO_HEALTH.NO_DATA') }}
+
+
+
+
+
+
+
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',