fix(twilio): register messaging webhook for voice inboxes and add webhook health tab
This commit is contained in:
@@ -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
|
||||
|
||||
+29
-8
@@ -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?
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'twilio-health'">
|
||||
<TwilioHealth
|
||||
:health-data="healthData"
|
||||
:is-registering-webhook="isRegisteringWebhook"
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappManualMigrationDialog
|
||||
v-if="showWhatsAppManualMigration"
|
||||
ref="whatsappManualMigrationDialog"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
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');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-4 mx-6">
|
||||
<div
|
||||
class="px-5 py-5 space-y-6 rounded-xl outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2"
|
||||
>
|
||||
<div>
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.TWILIO_HEALTH.TITLE') }}
|
||||
</span>
|
||||
<p class="mt-1 text-body-main text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.TWILIO_HEALTH.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="healthData" class="grid grid-cols-1 gap-4 xs:grid-cols-2">
|
||||
<div
|
||||
v-for="webhook in webhooks"
|
||||
:key="webhook.name"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ webhook.label }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="webhook.tooltip"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-between items-center">
|
||||
<span
|
||||
v-if="webhook.configured"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||
{{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||
{{
|
||||
webhook.actual
|
||||
? t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||
: t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||
}}
|
||||
</span>
|
||||
<ButtonV4
|
||||
v-if="!webhook.configured"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isRegisteringWebhook"
|
||||
:disabled="isRegisteringWebhook"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRegisterWebhook"
|
||||
>
|
||||
{{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
<div
|
||||
class="flex justify-center items-center p-8 text-center text-n-slate-11"
|
||||
>
|
||||
<div>
|
||||
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.TWILIO_HEALTH.NO_DATA') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user