Compare commits

...
19 changed files with 584 additions and 54 deletions
@@ -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
@@ -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>
@@ -88,6 +88,28 @@ export default {
this.isTogglingInbound = false;
}
},
// Saves on toggle, except when enabling still needs API key credentials: then we
// reveal the inputs and wait for the user to submit them.
async handleVoiceToggle(newValue) {
if (this.isUpdating) return;
const previousValue = this.voiceEnabled;
this.voiceEnabled = newValue;
if (this.needsCredentials) return;
try {
await this.updateVoiceSettings();
} catch (_) {
this.voiceEnabled = previousValue;
}
},
async submitVoiceCredentials() {
try {
await this.updateVoiceSettings();
} catch (_) {
this.voiceEnabled = false;
}
},
async updateVoiceSettings() {
this.isUpdating = true;
try {
@@ -109,6 +131,7 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
throw error;
} finally {
this.isUpdating = false;
}
@@ -119,13 +142,24 @@ export default {
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div
class="relative"
:class="{ 'pointer-events-none opacity-60': isUpdating }"
>
<SettingsToggleSection
:model-value="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
:hide-toggle="isUpdating"
@update:model-value="handleVoiceToggle"
>
<template v-if="isUpdating" #hiddenToggle>
<Spinner class="size-4 text-n-slate-11" />
</template>
</SettingsToggleSection>
</div>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
@@ -186,12 +220,12 @@ export default {
</SettingsFieldSection>
</div>
<div>
<div v-if="needsCredentials">
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
@click="submitVoiceCredentials"
/>
</div>
</div>
+59
View File
@@ -0,0 +1,59 @@
class Twilio::HealthService
include Rails.application.routes.url_helpers
pattr_initialize [:channel!]
# Our Twilio routes are POST-only, so a matching URL on the wrong HTTP method never reaches us.
HTTP_METHOD = 'POST'.freeze
# 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
# With use_inbound_webhook_on_number set, Twilio prefers the number's webhook over ours.
configured = service.inbound_method == HTTP_METHOD && !service.use_inbound_webhook_on_number
[webhook('messaging', twilio_callback_index_url, service.inbound_request_url, extra: configured)]
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, extra: number.sms_method == HTTP_METHOD)]
webhooks += voice_webhooks(number) if channel.voice_enabled?
webhooks
end
def voice_webhooks(number)
[
webhook('voice', channel.voice_call_webhook_url, number.voice_url, extra: number.voice_method == HTTP_METHOD),
webhook('voice_status', channel.voice_status_webhook_url, number.status_callback,
extra: number.status_callback_method == HTTP_METHOD),
# Outbound calls dial through the TwiML app, so a stale voice_url here breaks them silently.
twiml_app_webhook
]
end
def twiml_app_webhook
return webhook('voice_app', channel.voice_call_webhook_url, nil) if channel.twiml_app_sid.blank?
app = channel.client.applications(channel.twiml_app_sid).fetch
webhook('voice_app', channel.voice_call_webhook_url, app.voice_url, extra: app.voice_method == HTTP_METHOD)
end
def webhook(name, expected, actual, extra: false)
{ name: name, expected: expected, actual: actual.presence, configured: expected == actual && extra }
end
end
@@ -172,8 +172,8 @@ class Twilio::IncomingMessageService
def download_with_auth(media_url)
auth_credentials = if twilio_channel.api_key_sid.present?
# When using api_key_sid, the auth token should be the api_secret_key
[twilio_channel.api_key_sid, twilio_channel.auth_token]
# Voice channels keep the secret in api_key_secret; SMS channels store it in auth_token.
[twilio_channel.api_key_sid, twilio_channel.api_key_secret.presence || twilio_channel.auth_token]
else
# When using account_sid, the auth token is the account's auth token
[twilio_channel.account_sid, twilio_channel.auth_token]
+1 -5
View File
@@ -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,54 @@
require 'rails_helper'
describe Twilio::HealthService do
include Rails.application.routes.url_helpers
let(:number_instance) { Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberInstance }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) }
let(:twiml_app) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) }
let(:channel) { create(:channel_twilio_sms, :with_voice) }
let(:number) do
instance_double(number_instance, sms_url: twilio_callback_index_url, sms_method: 'POST',
voice_url: channel.voice_call_webhook_url, voice_method: 'POST',
status_callback: channel.voice_status_webhook_url, status_callback_method: 'POST')
end
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
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, voice_method: 'POST')
)
end
describe '#perform' do
context 'when everything is registered' do
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(: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
end
end
@@ -0,0 +1,38 @@
require 'rails_helper'
describe Twilio::IncomingMessageService do
# Voice channels keep the API key secret in api_key_secret, unlike SMS channels which reuse auth_token.
describe '#perform' do
let(:voice_channel) { create(:channel_twilio_sms, :with_voice) }
let(:params_with_attachment) do
{
SmsSid: 'SMvoice',
From: '+12345',
To: voice_channel.phone_number,
AccountSid: voice_channel.account_sid,
Body: 'mms on a voice inbox',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end
it 'downloads the media using the api key secret, not the account auth token' do
allow(Down).to receive(:download).and_call_original
described_class.new(params: params_with_attachment).perform
expect(Down).to have_received(:download).with(
'https://chatwoot-assets.local/sample.png',
http_basic_authentication: [voice_channel.api_key_sid, voice_channel.api_key_secret]
)
end
end
end
@@ -0,0 +1,87 @@
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 }
let(:sms_method) { 'POST' }
before do
allow(numbers_list).to receive(:list).and_return([instance_double(NUMBER_INSTANCE, sms_url: sms_url, sms_method: sms_method)])
end
it 'reports healthy when the messaging webhook points at chatwoot over POST' 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 a messaging service' do
let(:channel) { create(:channel_twilio_sms) }
let(:messaging) { instance_double(Twilio::REST::Messaging) }
let(:services) { instance_double(Twilio::REST::Messaging::V1::ServiceContext) }
let(:use_inbound_webhook_on_number) { false }
let(:service) do
instance_double(Twilio::REST::Messaging::V1::ServiceInstance,
inbound_request_url: twilio_callback_index_url, inbound_method: 'POST',
use_inbound_webhook_on_number: use_inbound_webhook_on_number)
end
before do
allow(twilio_client).to receive(:messaging).and_return(messaging)
allow(messaging).to receive(:services).with(channel.messaging_service_sid).and_return(services)
allow(services).to receive(:fetch).and_return(service)
end
it 'checks the inbound request url of the messaging service' do
result = described_class.new(channel: channel).perform
expect(result[:status]).to eq('healthy')
expect(result[:webhooks].first).to include(name: 'messaging', configured: true)
end
context 'when the service still defers to the number webhook' do
let(:use_inbound_webhook_on_number) { true }
it 'reports misconfigured because twilio would bypass our inbound url' do
expect(described_class.new(channel: channel).perform[:status]).to eq('misconfigured')
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',