diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb new file mode 100644 index 000000000..cc7afb0fc --- /dev/null +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -0,0 +1,21 @@ +class Twilio::DeliveryStatusController < ApplicationController + def create + ::Twilio::DeliveryStatusService.new(params: permitted_params).perform + + head :no_content + end + + private + + def permitted_params + params.permit( + :AccountSid, + :From, + :MessageSid, + :MessagingServiceSid, + :MessageStatus, + :ErrorCode, + :ErrorMessage + ) + end +end diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index 58a0ee0a7..4ea351679 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -201,7 +201,7 @@ export default { return !!this.sourceId; } - if (this.isAWhatsAppChannel) { + if (this.isAWhatsAppChannel || this.isATwilioChannel) { return this.sourceId && this.isSent; } return false; @@ -211,7 +211,7 @@ export default { return false; } - if (this.isAWhatsAppChannel) { + if (this.isAWhatsAppChannel || this.isATwilioChannel) { return this.sourceId && this.isDelivered; } @@ -227,7 +227,7 @@ export default { return contactLastSeenAt >= this.createdAt; } - if (this.isAWhatsAppChannel) { + if (this.isAWhatsAppChannel || this.isATwilioChannel) { return this.sourceId && this.isRead; } diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 05ca9a567..935ab2a14 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -22,6 +22,7 @@ class Channel::TwilioSms < ApplicationRecord include Channelable + include Rails.application.routes.url_helpers self.table_name = 'channel_twilio_sms' @@ -47,6 +48,7 @@ class Channel::TwilioSms < ApplicationRecord def send_message(to:, body:, media_url: nil) params = send_message_from.merge(to: to, body: body) params[:media_url] = media_url if media_url.present? + params[:status_callback] = twilio_delivery_status_index_url client.messages.create(**params) end diff --git a/app/services/twilio/delivery_status_service.rb b/app/services/twilio/delivery_status_service.rb new file mode 100644 index 000000000..dd0f1ff2e --- /dev/null +++ b/app/services/twilio/delivery_status_service.rb @@ -0,0 +1,55 @@ +class Twilio::DeliveryStatusService + pattr_initialize [:params!] + # Reference: https://www.twilio.com/docs/messaging/api/message-resource#message-status-values + + def perform + return if twilio_channel.blank? + + return unless supported_status? + + process_statuses if message.present? + end + + private + + def process_statuses + @message.status = params[:MessageStatus] + @message.external_error = external_error if error_occurred? + @message.save! + end + + def supported_status? + %w[sent delivered read failed undelivered].include?(params[:MessageStatus]) + end + + def external_error + return nil unless error_occurred? + + error_message = params[:ErrorMessage].presence + error_code = params[:ErrorCode] + + if error_message.present? + "#{error_code} - #{error_message}" + elsif error_code.present? + I18n.t('conversations.messages.delivery_status.error_code', error_code: error_code) + end + end + + def error_occurred? + params[:ErrorCode].present? && %w[failed undelivered].include?(params[:MessageStatus]) + end + + def twilio_channel + @twilio_channel ||= if params[:MessagingServiceSid].present? + ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) + elsif params[:AccountSid].present? && params[:From].present? + ::Channel::TwilioSms.find_by!(account_sid: params[:AccountSid], phone_number: params[:From]) + end + end + + def message + return unless params[:MessageSid] + + @message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid]) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index dcec3e543..8bb388897 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -126,6 +126,8 @@ en: instagram_story_content: "%{story_sender} mentioned you in the story: " instagram_deleted_story_content: This story is no longer available. deleted: This message was deleted + delivery_status: + error_code: "Error code: %{error_code}" activity: status: resolved: "Conversation was marked resolved by %{user_name}" diff --git a/config/routes.rb b/config/routes.rb index ff3cbdd46..e2aaa2ff3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -396,6 +396,7 @@ Rails.application.routes.draw do namespace :twilio do resources :callback, only: [:create] + resources :delivery_status, only: [:create] end get 'microsoft/callback', to: 'microsoft/callbacks#show' diff --git a/spec/controllers/twilio/delivery_status_controller_spec.rb b/spec/controllers/twilio/delivery_status_controller_spec.rb new file mode 100644 index 000000000..05c236259 --- /dev/null +++ b/spec/controllers/twilio/delivery_status_controller_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +RSpec.describe 'Twilio::DeliveryStatusController', type: :request do + include Rails.application.routes.url_helpers + let(:twilio_service) { instance_double(Twilio::DeliveryStatusService) } + + before do + allow(Twilio::DeliveryStatusService).to receive(:new).and_return(twilio_service) + allow(twilio_service).to receive(:perform) + end + + describe 'POST /twilio/delivery' do + it 'calls incoming message service' do + post twilio_delivery_status_index_url, params: {} + expect(twilio_service).to have_received(:perform) + end + end +end diff --git a/spec/models/channel/twilio_sms_spec.rb b/spec/models/channel/twilio_sms_spec.rb index 7ccb26c7a..5f26eb706 100644 --- a/spec/models/channel/twilio_sms_spec.rb +++ b/spec/models/channel/twilio_sms_spec.rb @@ -68,7 +68,8 @@ RSpec.describe Channel::TwilioSms do expect(twilio_messages).to receive(:create).with( messaging_service_sid: channel.messaging_service_sid, to: '+15555550111', - body: 'hello world' + body: 'hello world', + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once channel.send_message(to: '+15555550111', body: 'hello world') @@ -81,7 +82,8 @@ RSpec.describe Channel::TwilioSms do expect(twilio_messages).to receive(:create).with( from: channel.phone_number, to: '+15555550111', - body: 'hello world' + body: 'hello world', + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once channel.send_message(to: '+15555550111', body: 'hello world') @@ -94,7 +96,8 @@ RSpec.describe Channel::TwilioSms do messaging_service_sid: channel.messaging_service_sid, to: '+15555550111', body: 'hello world', - media_url: ['https://example.com/1.jpg'] + media_url: ['https://example.com/1.jpg'], + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once channel.send_message(to: '+15555550111', body: 'hello world', media_url: ['https://example.com/1.jpg']) diff --git a/spec/services/twilio/delivery_status_service_spec.rb b/spec/services/twilio/delivery_status_service_spec.rb new file mode 100644 index 000000000..4f88b98fc --- /dev/null +++ b/spec/services/twilio/delivery_status_service_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +describe Twilio::DeliveryStatusService do + let!(:account) { create(:account) } + let!(:twilio_channel) do + create(:channel_twilio_sms, account: account, account_sid: 'ACxxx', + inbox: create(:inbox, account: account, greeting_enabled: false)) + end + let!(:contact) { create(:contact, account: account, phone_number: '+12345') } + let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) } + let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) } + + describe '#perform' do + context 'when message delivery status is fired' do + before do + create(:message, account: account, inbox: twilio_channel.inbox, conversation: conversation, status: :sent, + source_id: 'SMd560ac79e4a4d36b3ce59f1f50471986') + end + + it 'updates the message if the status is delivered' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'delivered' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('delivered') + end + + it 'updates the message if the status is read' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'read' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('read') + end + + it 'does not update the message if the status is not a support status' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'queued' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('sent') + end + + it 'updates message status to failed if message status is failed' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'failed' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('failed') + end + + it 'updates message status to failed and updates the error message if message status is undelivered' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'failed', + ErrorCode: '30008', + ErrorMessage: 'Unknown error' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('failed') + expect(conversation.reload.messages.last.external_error).to eq('30008 - Unknown error') + end + + it 'updates the error message if message status is undelivered and error message is not present' do + params = { + SmsSid: 'SMxx', + From: '+12345', + AccountSid: 'ACxxx', + MessagingServiceSid: twilio_channel.messaging_service_sid, + MessageSid: conversation.messages.last.source_id, + MessageStatus: 'failed', + ErrorCode: '30008' + } + + described_class.new(params: params).perform + expect(conversation.reload.messages.last.status).to eq('failed') + expect(conversation.reload.messages.last.external_error).to eq('Error code: 30008') + end + end + end +end diff --git a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb index da5d871d9..f85199c6e 100644 --- a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb +++ b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb @@ -41,17 +41,20 @@ describe Twilio::OneoffSmsCampaignService do expect(twilio_messages).to receive(:create).with( body: campaign.message, messaging_service_sid: twilio_sms.messaging_service_sid, - to: contact_with_label1.phone_number + to: contact_with_label1.phone_number, + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once expect(twilio_messages).to receive(:create).with( body: campaign.message, messaging_service_sid: twilio_sms.messaging_service_sid, - to: contact_with_label2.phone_number + to: contact_with_label2.phone_number, + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once expect(twilio_messages).to receive(:create).with( body: campaign.message, messaging_service_sid: twilio_sms.messaging_service_sid, - to: contact_with_both_labels.phone_number + to: contact_with_both_labels.phone_number, + status_callback: 'http://localhost:3000/twilio/delivery_status' ).once sms_campaign_service.perform