From cf10f3d03b666cd45b45df5d1fee28a533a21b46 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 3 Feb 2022 15:22:13 -0800 Subject: [PATCH] chore: Provider APIs for SMS Channel - Bandwidth (#3889) fixes: #3888 --- app/builders/contact_inbox_builder.rb | 20 +- .../api/v1/accounts/inboxes_controller.rb | 41 ++-- app/controllers/webhooks/sms_controller.rb | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 52 ++++- .../settings/campaigns/AddCampaign.vue | 2 +- .../settings/campaigns/EditCampaign.vue | 2 +- .../dashboard/settings/inbox/ChannelList.vue | 2 +- .../dashboard/settings/inbox/FinishSetup.vue | 24 ++- .../routes/dashboard/settings/inbox/Index.vue | 3 + .../settings/inbox/channels/BandwidthSms.vue | 181 ++++++++++++++++++ .../dashboard/settings/inbox/channels/Sms.vue | 23 ++- .../dashboard/store/modules/inboxes.js | 6 +- .../store/modules/specs/inboxes/fixtures.js | 7 + .../modules/specs/inboxes/getters.spec.js | 6 +- app/javascript/shared/mixins/inboxMixin.js | 1 + app/jobs/send_reply_job.rb | 21 +- app/jobs/webhooks/sms_events_job.rb | 13 ++ app/models/account.rb | 1 + app/models/campaign.rb | 5 +- app/models/channel/sms.rb | 81 ++++++++ app/models/channel/whatsapp.rb | 2 +- app/models/inbox.rb | 2 + .../contacts/contactable_inboxes_service.rb | 8 + app/services/sms/incoming_message_service.rb | 66 +++++++ .../sms/oneoff_sms_campaign_service.rb | 32 ++++ app/services/sms/send_on_sms_service.rb | 16 ++ config/routes.rb | 1 + db/migrate/20220129024443_add_sms_channel.rb | 11 ++ db/schema.rb | 10 + spec/builders/contact_inbox_builder_spec.rb | 47 +++++ .../v1/accounts/inboxes_controller_spec.rb | 12 ++ .../webhooks/sms_controller_spec.rb | 12 ++ spec/factories/channel/channel_sms.rb | 16 ++ spec/jobs/send_reply_job_spec.rb | 9 + spec/jobs/webhooks/sms_events_job_spec.rb | 56 ++++++ spec/models/campaign_spec.rb | 21 ++ .../contactable_inboxes_service_spec.rb | 6 +- .../sms/incoming_message_service_spec.rb | 31 +++ .../sms/oneoff_sms_campaign_service_spec.rb | 47 +++++ spec/services/sms/send_on_sms_service_spec.rb | 28 +++ 40 files changed, 879 insertions(+), 51 deletions(-) create mode 100644 app/controllers/webhooks/sms_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BandwidthSms.vue create mode 100644 app/jobs/webhooks/sms_events_job.rb create mode 100644 app/models/channel/sms.rb create mode 100644 app/services/sms/incoming_message_service.rb create mode 100644 app/services/sms/oneoff_sms_campaign_service.rb create mode 100644 app/services/sms/send_on_sms_service.rb create mode 100644 db/migrate/20220129024443_add_sms_channel.rb create mode 100644 spec/controllers/webhooks/sms_controller_spec.rb create mode 100644 spec/factories/channel/channel_sms.rb create mode 100644 spec/jobs/webhooks/sms_events_job_spec.rb create mode 100644 spec/services/sms/incoming_message_service_spec.rb create mode 100644 spec/services/sms/oneoff_sms_campaign_service_spec.rb create mode 100644 spec/services/sms/send_on_sms_service_spec.rb diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 8ee1b3fec..e7ae8b0aa 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -4,7 +4,7 @@ class ContactInboxBuilder def perform @contact = Contact.find(contact_id) @inbox = @contact.account.inboxes.find(inbox_id) - return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type + return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type source_id = @source_id || generate_source_id create_contact_inbox(source_id) if source_id.present? @@ -13,12 +13,18 @@ class ContactInboxBuilder private def generate_source_id - return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms' - return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp' - return @contact.email if @inbox.channel_type == 'Channel::Email' - return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api' - - nil + case @inbox.channel_type + when 'Channel::TwilioSms' + twilio_source_id + when 'Channel::Whatsapp' + wa_source_id + when 'Channel::Email' + @contact.email + when 'Channel::Sms' + @contact.phone_number + when 'Channel::Api' + SecureRandom.uuid + end end def wa_source_id diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 9f2cfba8c..62a471cda 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -91,20 +91,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def create_channel - case permitted_params[:channel][:type] - when 'web_widget' - Current.account.web_widgets.create!(permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].except(:type)) - when 'api' - Current.account.api_channels.create!(permitted_params(Channel::Api::EDITABLE_ATTRS)[:channel].except(:type)) - when 'email' - Current.account.email_channels.create!(permitted_params(Channel::Email::EDITABLE_ATTRS)[:channel].except(:type)) - when 'line' - Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) - when 'telegram' - Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) - when 'whatsapp' - Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type)) - end + return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) + + account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) end def update_channel_feature_flags @@ -123,6 +112,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController ) end + def channel_type_from_params + { + 'web_widget' => Channel::WebWidget, + 'api' => Channel::Api, + 'email' => Channel::Email, + 'line' => Channel::Line, + 'telegram' => Channel::Telegram, + 'whatsapp' => Channel::Whatsapp, + 'sms' => Channel::Sms + }[permitted_params[:channel][:type]] + end + + def account_channels_method + { + 'web_widget' => Current.account.web_widgets, + 'api' => Current.account.api_channels, + 'email' => Current.account.email_channels, + 'line' => Current.account.line_channels, + 'telegram' => Current.account.telegram_channels, + 'whatsapp' => Current.account.whatsapp_channels, + 'sms' => Current.account.sms_channels + }[permitted_params[:channel][:type]] + end + def get_channel_attributes(channel_type) if channel_type.constantize.const_defined?('EDITABLE_ATTRS') channel_type.constantize::EDITABLE_ATTRS.presence diff --git a/app/controllers/webhooks/sms_controller.rb b/app/controllers/webhooks/sms_controller.rb new file mode 100644 index 000000000..914357dc9 --- /dev/null +++ b/app/controllers/webhooks/sms_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::SmsController < ActionController::API + def process_payload + Webhooks::SmsEventsJob.perform_later(params['_json']&.first&.to_unsafe_hash) + head :ok + end +end diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 537998ac9..86cf1a3c3 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -136,8 +136,56 @@ } }, "SMS": { - "TITLE": "SMS Channel via Twilio", - "DESC": "Start supporting your customers via SMS with Twilio integration." + "TITLE": "SMS Channel", + "DESC": "Start supporting your customers via SMS.", + "PROVIDERS": { + "LABEL": "API Provider", + "TWILIO": "Twilio", + "BANDWIDTH": "Bandwidth" + }, + "API": { + "ERROR_MESSAGE": "We were not able to save the SMS channel" + }, + "BANDWIDTH": { + "ACCOUNT_ID": { + "LABEL": "Account ID", + "PLACEHOLDER": "Please enter your Bandwidth Account ID", + "ERROR": "This field is required" + }, + "API_KEY": { + "LABEL": "API Key", + "PLACEHOLDER": "Please enter your Bandwith API Key", + "ERROR": "This field is required" + }, + "API_SECRET": { + "LABEL": "API Secret", + "PLACEHOLDER": "Please enter your Bandwith API Secret", + "ERROR": "This field is required" + }, + "APPLICATION_ID": { + "LABEL": "Application ID", + "PLACEHOLDER": "Please enter your Bandwidth Application ID", + "ERROR": "This field is required" + }, + "INBOX_NAME": { + "LABEL": "Inbox Name", + "PLACEHOLDER": "Please enter a inbox name", + "ERROR": "This field is required" + }, + "PHONE_NUMBER": { + "LABEL": "Phone number", + "PLACEHOLDER": "Please enter the phone number from which message will be sent.", + "ERROR": "Please enter a valid value. Phone number should start with `+` sign." + }, + "SUBMIT_BUTTON": "Create Bandwidth Channel", + "API": { + "ERROR_MESSAGE": "We were not able to authenticate Bandwidth credentials, please try again" + }, + "API_CALLBACK": { + "TITLE": "Callback URL", + "SUBTITLE": "You have to configure the message callback URL in Bandwidth with the URL mentioned here." + } + } }, "WHATSAPP": { "TITLE": "WhatsApp Channel", diff --git a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue index a71744939..e17960d9b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue @@ -247,7 +247,7 @@ export default { if (this.isOngoingType) { return this.$store.getters['inboxes/getWebsiteInboxes']; } - return this.$store.getters['inboxes/getTwilioSMSInboxes']; + return this.$store.getters['inboxes/getSMSInboxes']; }, sendersAndBotList() { return [ diff --git a/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue index 6405e23dd..19a292e34 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/campaigns/EditCampaign.vue @@ -171,7 +171,7 @@ export default { if (this.isOngoingType) { return this.$store.getters['inboxes/getWebsiteInboxes']; } - return this.$store.getters['inboxes/getTwilioSMSInboxes']; + return this.$store.getters['inboxes/getSMSInboxes']; }, pageTitle() { return `${this.$t('CAMPAIGN.EDIT.TITLE')} - ${ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 6f0121679..ac8518f16 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -50,7 +50,7 @@ export default { { key: 'facebook', name: 'Messenger' }, { key: 'twitter', name: 'Twitter' }, { key: 'whatsapp', name: 'WhatsApp' }, - { key: 'sms', name: 'SMS via Twilio' }, + { key: 'sms', name: 'SMS' }, { key: 'email', name: 'Email' }, { key: 'api', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index 95cbd7646..270c28fc6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -29,6 +29,14 @@ > +
+ + +
Whatsapp + + Sms + Email diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BandwidthSms.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BandwidthSms.vue new file mode 100644 index 000000000..dd531f951 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/BandwidthSms.vue @@ -0,0 +1,181 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Sms.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Sms.vue index cc4d18f1a..b669ad27d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Sms.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Sms.vue @@ -4,18 +4,39 @@ :header-title="$t('INBOX_MGMT.ADD.SMS.TITLE')" :header-content="$t('INBOX_MGMT.ADD.SMS.DESC')" /> - +
+ +
+ +
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index e266887f7..a180a5b80 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -78,9 +78,11 @@ export const getters = { item => item.channel_type === INBOX_TYPES.TWILIO ); }, - getTwilioSMSInboxes($state) { + getSMSInboxes($state) { return $state.records.filter( - item => item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms' + item => + item.channel_type === INBOX_TYPES.SMS || + (item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms') ); }, dialogFlowEnabledInboxes($state) { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js index 9db92b00a..f7b06d232 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js @@ -55,4 +55,11 @@ export default [ website_token: 'randomid125', enable_auto_assignment: true, }, + { + id: 6, + channel_id: 6, + name: 'Test Widget 6', + channel_type: 'Channel::Sms', + provider: 'default', + }, ]; diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js index 73d6624e0..e8af7dd58 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js @@ -19,14 +19,14 @@ describe('#getters', () => { expect(getters.getTwilioInboxes(state).length).toEqual(1); }); - it('getTwilioSMSInboxes', () => { + it('getSMSInboxes', () => { const state = { records: inboxList }; - expect(getters.getTwilioSMSInboxes(state).length).toEqual(1); + expect(getters.getSMSInboxes(state).length).toEqual(2); }); it('dialogFlowEnabledInboxes', () => { const state = { records: inboxList }; - expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(5); + expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6); }); it('getInbox', () => { diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 022b9327e..aebbeebc1 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -8,6 +8,7 @@ export const INBOX_TYPES = { EMAIL: 'Channel::Email', TELEGRAM: 'Channel::Telegram', LINE: 'Channel::Line', + SMS: 'Channel::Sms', }; export default { diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index 08597a54d..cef80c9df 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -6,19 +6,20 @@ class SendReplyJob < ApplicationJob conversation = message.conversation channel_name = conversation.inbox.channel.class.to_s + services = { + 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService, + 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService, + 'Channel::Line' => ::Line::SendOnLineService, + 'Channel::Telegram' => ::Telegram::SendOnTelegramService, + 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService, + 'Channel::Sms' => ::Sms::SendOnSmsService + } + case channel_name when 'Channel::FacebookPage' send_on_facebook_page(message) - when 'Channel::TwitterProfile' - ::Twitter::SendOnTwitterService.new(message: message).perform - when 'Channel::TwilioSms' - ::Twilio::SendOnTwilioService.new(message: message).perform - when 'Channel::Line' - ::Line::SendOnLineService.new(message: message).perform - when 'Channel::Telegram' - ::Telegram::SendOnTelegramService.new(message: message).perform - when 'Channel::Whatsapp' - ::Whatsapp::SendOnWhatsappService.new(message: message).perform + else + services[channel_name].new(message: message).perform if services[channel_name].present? end end diff --git a/app/jobs/webhooks/sms_events_job.rb b/app/jobs/webhooks/sms_events_job.rb new file mode 100644 index 000000000..c982e0da1 --- /dev/null +++ b/app/jobs/webhooks/sms_events_job.rb @@ -0,0 +1,13 @@ +class Webhooks::SmsEventsJob < ApplicationJob + queue_as :default + + def perform(params = {}) + return unless params[:type] == 'message-received' + + channel = Channel::Sms.find_by(phone_number: params[:to]) + return unless channel + + # TODO: pass to appropriate provider service from here + Sms::IncomingMessageService.new(inbox: channel.inbox, params: params[:message].with_indifferent_access).perform + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 10e022aa4..da0dd062b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -69,6 +69,7 @@ class Account < ApplicationRecord has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget' has_many :webhooks, dependent: :destroy_async has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp' + has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms' has_many :working_hours, dependent: :destroy_async has_many :automation_rules, dependent: :destroy diff --git a/app/models/campaign.rb b/app/models/campaign.rb index a103eb44f..0093342d6 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -58,6 +58,7 @@ class Campaign < ApplicationRecord return if completed? Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS' + Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms' end private @@ -69,14 +70,14 @@ class Campaign < ApplicationRecord def validate_campaign_inbox return unless inbox - errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS'].include? inbox.inbox_type + errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type end # TO-DO we clean up with better validations when campaigns evolve into more inboxes def ensure_correct_campaign_attributes return if inbox.blank? - if inbox.inbox_type == 'Twilio SMS' + if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type) self.campaign_type = 'one_off' self.scheduled_at ||= Time.now.utc else diff --git a/app/models/channel/sms.rb b/app/models/channel/sms.rb new file mode 100644 index 000000000..ff7dd2433 --- /dev/null +++ b/app/models/channel/sms.rb @@ -0,0 +1,81 @@ +# == Schema Information +# +# Table name: channel_sms +# +# id :bigint not null, primary key +# phone_number :string not null +# provider :string default("default") +# provider_config :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# +# Indexes +# +# index_channel_sms_on_phone_number (phone_number) UNIQUE +# + +class Channel::Sms < ApplicationRecord + include Channelable + + self.table_name = 'channel_sms' + EDITABLE_ATTRS = [:phone_number, { provider_config: {} }].freeze + + validates :phone_number, presence: true, uniqueness: true + # before_save :validate_provider_config + + def name + 'Sms' + end + + # all this should happen in provider service . but hack mode on + def api_base_path + 'https://messaging.bandwidth.com/api/v2' + end + + # Extract later into provider Service + def send_message(phone_number, message) + if message.attachments.present? + send_attachment_message(phone_number, message) + else + send_text_message(phone_number, message.content) + end + end + + def send_text_message(contact_number, message) + response = HTTParty.post( + "#{api_base_path}/users/#{provider_config['account_id']}/messages", + basic_auth: bandwidth_auth, + headers: { 'Content-Type' => 'application/json' }, + body: { + 'to' => contact_number, + 'from' => phone_number, + 'text' => message, + 'applicationId' => provider_config['application_id'] + }.to_json + ) + + response.success? ? response.parsed_response['id'] : nil + end + + private + + def send_attachment_message(phone_number, message) + # fix me + end + + def bandwidth_auth + { username: provider_config['api_key'], password: provider_config['api_secret'] } + end + + # Extract later into provider Service + # let's revisit later + def validate_provider_config + response = HTTParty.post( + "#{api_base_path}/users/#{provider_config['account_id']}/messages", + basic_auth: bandwidth_auth, + headers: { 'Content-Type': 'application/json' } + ) + errors.add(:provider_config, 'error setting up') unless response.success? + end +end diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 3642fac16..9349735fc 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -149,6 +149,6 @@ class Channel::Whatsapp < ApplicationRecord url: "#{ENV['FRONTEND_URL']}/webhooks/whatsapp/#{phone_number}" }.to_json ) - errors.add(:bot_token, 'error setting up the webook') unless response.success? + errors.add(:provider_config, 'error setting up the webook') unless response.success? end end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 774c66f9d..2d7077fa9 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -107,6 +107,8 @@ class Inbox < ApplicationRecord case channel_type when 'Channel::TwilioSms' "#{ENV['FRONTEND_URL']}/twilio/callback" + when 'Channel::Sms' + "#{ENV['FRONTEND_URL']}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}" when 'Channel::Line' "#{ENV['FRONTEND_URL']}/webhooks/line/#{channel.line_channel_id}" end diff --git a/app/services/contacts/contactable_inboxes_service.rb b/app/services/contacts/contactable_inboxes_service.rb index fcd91d4c3..c5cde516f 100644 --- a/app/services/contacts/contactable_inboxes_service.rb +++ b/app/services/contacts/contactable_inboxes_service.rb @@ -14,6 +14,8 @@ class Contacts::ContactableInboxesService twilio_contactable_inbox(inbox) when 'Channel::Whatsapp' whatsapp_contactable_inbox(inbox) + when 'Channel::Sms' + sms_contactable_inbox(inbox) when 'Channel::Email' email_contactable_inbox(inbox) when 'Channel::Api' @@ -52,6 +54,12 @@ class Contacts::ContactableInboxesService { source_id: @contact.phone_number.delete('+'), inbox: inbox } end + def sms_contactable_inbox(inbox) + return unless @contact.phone_number + + { source_id: @contact.phone_number, inbox: inbox } + end + def twilio_contactable_inbox(inbox) return if @contact.phone_number.blank? diff --git a/app/services/sms/incoming_message_service.rb b/app/services/sms/incoming_message_service.rb new file mode 100644 index 000000000..62fda96ac --- /dev/null +++ b/app/services/sms/incoming_message_service.rb @@ -0,0 +1,66 @@ +class Sms::IncomingMessageService + include ::FileTypeHelper + + pattr_initialize [:inbox!, :params!] + + def perform + set_contact + set_conversation + @message = @conversation.messages.create( + content: params[:text], + account_id: @inbox.account_id, + inbox_id: @inbox.id, + message_type: :incoming, + sender: @contact, + source_id: params[:id] + ) + end + + private + + def account + @account ||= @inbox.account + end + + def phone_number + params[:from] + end + + def formatted_phone_number + TelephoneNumber.parse(phone_number).international_number + end + + def set_contact + contact_inbox = ::ContactBuilder.new( + source_id: params[:from], + inbox: @inbox, + contact_attributes: contact_attributes + ).perform + + @contact_inbox = contact_inbox + @contact = contact_inbox.contact + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id + } + end + + def set_conversation + @conversation = @contact_inbox.conversations.first + return if @conversation + + @conversation = ::Conversation.create!(conversation_params) + end + + def contact_attributes + { + name: formatted_phone_number, + phone_number: phone_number + } + end +end diff --git a/app/services/sms/oneoff_sms_campaign_service.rb b/app/services/sms/oneoff_sms_campaign_service.rb new file mode 100644 index 000000000..73a101d24 --- /dev/null +++ b/app/services/sms/oneoff_sms_campaign_service.rb @@ -0,0 +1,32 @@ +class Sms::OneoffSmsCampaignService + pattr_initialize [:campaign!] + + def perform + raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off? + raise 'Completed Campaign' if campaign.completed? + + # marks campaign completed so that other jobs won't pick it up + campaign.completed! + + audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id') + audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title) + process_audience(audience_labels) + end + + private + + delegate :inbox, to: :campaign + delegate :channel, to: :inbox + + def process_audience(audience_labels) + campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact| + next if contact.phone_number.blank? + + send_message(to: contact.phone_number, content: campaign.message) + end + end + + def send_message(to:, content:) + channel.send_text_message(to, content) + end +end diff --git a/app/services/sms/send_on_sms_service.rb b/app/services/sms/send_on_sms_service.rb new file mode 100644 index 000000000..ccfede28c --- /dev/null +++ b/app/services/sms/send_on_sms_service.rb @@ -0,0 +1,16 @@ +class Sms::SendOnSmsService < Base::SendOnChannelService + private + + def channel_class + Channel::Sms + end + + def perform_reply + send_on_sms + end + + def send_on_sms + message_id = channel.send_message(message.conversation.contact_inbox.source_id, message) + message.update!(source_id: message_id) if message_id.present? + end +end diff --git a/config/routes.rb b/config/routes.rb index 49e7bf059..f90b6b84b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -271,6 +271,7 @@ Rails.application.routes.draw do post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload' post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload' post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload' + post 'webhooks/sms/:phone_number', to: 'webhooks/sms#process_payload' get 'webhooks/instagram', to: 'webhooks/instagram#verify' post 'webhooks/instagram', to: 'webhooks/instagram#events' diff --git a/db/migrate/20220129024443_add_sms_channel.rb b/db/migrate/20220129024443_add_sms_channel.rb new file mode 100644 index 000000000..c65019f7e --- /dev/null +++ b/db/migrate/20220129024443_add_sms_channel.rb @@ -0,0 +1,11 @@ +class AddSmsChannel < ActiveRecord::Migration[6.1] + def change + create_table :channel_sms do |t| + t.integer :account_id, null: false + t.string :phone_number, null: false, index: { unique: true } + t.string :provider, default: 'default' + t.jsonb :provider_config, default: {} + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 27da2435d..081dbdc3a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -228,6 +228,16 @@ ActiveRecord::Schema.define(version: 2022_01_31_081750) do t.index ["line_channel_id"], name: "index_channel_line_on_line_channel_id", unique: true end + create_table "channel_sms", force: :cascade do |t| + t.integer "account_id", null: false + t.string "phone_number", null: false + t.string "provider", default: "default" + t.jsonb "provider_config", default: {} + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["phone_number"], name: "index_channel_sms_on_phone_number", unique: true + end + create_table "channel_telegram", force: :cascade do |t| t.string "bot_name" t.integer "account_id", null: false diff --git a/spec/builders/contact_inbox_builder_spec.rb b/spec/builders/contact_inbox_builder_spec.rb index 40f19aba1..80658b5ac 100644 --- a/spec/builders/contact_inbox_builder_spec.rb +++ b/spec/builders/contact_inbox_builder_spec.rb @@ -99,6 +99,53 @@ describe ::ContactInboxBuilder do end end + describe 'sms inbox' do + let!(:sms_channel) { create(:channel_sms, account: account) } + let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) } + + it 'does not create contact inbox when contact inbox already exists with the source id provided' do + existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number) + contact_inbox = described_class.new( + contact_id: contact.id, + inbox_id: sms_inbox.id, + source_id: contact.phone_number + ).perform + + expect(contact_inbox.id).to be(existing_contact_inbox.id) + end + + it 'does not create contact inbox when contact inbox already exists with phone number and source id is not provided' do + existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number) + contact_inbox = described_class.new( + contact_id: contact.id, + inbox_id: sms_inbox.id + ).perform + + expect(contact_inbox.id).to be(existing_contact_inbox.id) + end + + it 'creates a new contact inbox when different source id is provided' do + existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number) + contact_inbox = described_class.new( + contact_id: contact.id, + inbox_id: sms_inbox.id, + source_id: '+224213223422' + ).perform + + expect(contact_inbox.id).not_to be(existing_contact_inbox.id) + expect(contact_inbox.source_id).not_to be('+224213223422') + end + + it 'creates a contact inbox with contact phone number when source id not provided and no contact inbox exists' do + contact_inbox = described_class.new( + contact_id: contact.id, + inbox_id: sms_inbox.id + ).perform + + expect(contact_inbox.source_id).not_to be(contact.phone_number) + end + end + describe 'email inbox' do let!(:email_channel) { create(:channel_email, account: account) } let!(:email_inbox) { create(:inbox, channel: email_channel, account: account) } diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 1c7082abc..8ccf667ac 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -309,6 +309,18 @@ RSpec.describe 'Inboxes API', type: :request do expect(response.body).to include('callback_webhook_url') end + it 'creates a sms inbox when administrator' do + post "/api/v1/accounts/#{account.id}/inboxes", + headers: admin.create_new_auth_token, + params: { name: 'Sms Inbox', + channel: { type: 'sms', phone_number: '+123456789', provider_config: { test: 'test' } } }, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include('Sms Inbox') + expect(response.body).to include('+123456789') + end + it 'creates the webwidget inbox that allow messages after conversation is resolved' do post "/api/v1/accounts/#{account.id}/inboxes", headers: admin.create_new_auth_token, diff --git a/spec/controllers/webhooks/sms_controller_spec.rb b/spec/controllers/webhooks/sms_controller_spec.rb new file mode 100644 index 000000000..0ce9bb94d --- /dev/null +++ b/spec/controllers/webhooks/sms_controller_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe 'Webhooks::SmsController', type: :request do + describe 'POST /webhooks/sms/{:phone_number}' do + it 'call the sms events job with the params' do + allow(Webhooks::SmsEventsJob).to receive(:perform_later) + expect(Webhooks::SmsEventsJob).to receive(:perform_later) + post '/webhooks/sms/123221321', params: { content: 'hello' } + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/factories/channel/channel_sms.rb b/spec/factories/channel/channel_sms.rb new file mode 100644 index 000000000..3eb5b499a --- /dev/null +++ b/spec/factories/channel/channel_sms.rb @@ -0,0 +1,16 @@ +FactoryBot.define do + factory :channel_sms, class: 'Channel::Sms' do + sequence(:phone_number) { |n| "+123456789#{n}1" } + account + provider_config do + { 'account_id' => '1', + 'application_id' => '1', + 'api_key' => '1', + 'api_secret' => '1' } + end + + after(:create) do |channel_sms| + create(:inbox, channel: channel_sms, account: channel_sms.account) + end + end +end diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index 6954bb3c3..03b249368 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -75,5 +75,14 @@ RSpec.describe SendReplyJob, type: :job do expect(process_service).to receive(:perform) described_class.perform_now(message.id) end + + it 'calls ::Sms::SendOnSmsService when its sms message' do + sms_channel = create(:channel_sms) + message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox)) + allow(::Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service) + expect(::Sms::SendOnSmsService).to receive(:new).with(message: message) + expect(process_service).to receive(:perform) + described_class.perform_now(message.id) + end end end diff --git a/spec/jobs/webhooks/sms_events_job_spec.rb b/spec/jobs/webhooks/sms_events_job_spec.rb new file mode 100644 index 000000000..927e8adaa --- /dev/null +++ b/spec/jobs/webhooks/sms_events_job_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe Webhooks::SmsEventsJob, type: :job do + subject(:job) { described_class.perform_later(params) } + + let!(:sms_channel) { create(:channel_sms) } + let!(:params) do + { + time: '2022-02-02T23:14:05.309Z', + type: 'message-received', + to: sms_channel.phone_number, + description: 'Incoming message received', + message: { + 'id': '3232420-2323-234324', + 'owner': sms_channel.phone_number, + 'applicationId': '2342349-324234d-32432432', + 'time': '2022-02-02T23:14:05.262Z', + 'segmentCount': 1, + 'direction': 'in', + 'to': [ + sms_channel.phone_number + ], + 'from': '+14234234234', + 'text': 'test message' + } + } + end + + it 'enqueues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(params) + .on_queue('default') + end + + context 'when invalid params' do + it 'returns nil when no bot_token' do + expect(described_class.perform_now({})).to be_nil + end + + it 'returns nil when invalid type' do + expect(described_class.perform_now({ type: 'invalid' })).to be_nil + end + end + + context 'when valid params' do + it 'calls Sms::IncomingMessageService' do + process_service = double + allow(Sms::IncomingMessageService).to receive(:new).and_return(process_service) + allow(process_service).to receive(:perform) + expect(Sms::IncomingMessageService).to receive(:new).with(inbox: sms_channel.inbox, + params: params[:message].with_indifferent_access) + expect(process_service).to receive(:perform) + described_class.perform_now(params) + end + end +end diff --git a/spec/models/campaign_spec.rb b/spec/models/campaign_spec.rb index 4ea07cd75..54936ac2b 100644 --- a/spec/models/campaign_spec.rb +++ b/spec/models/campaign_spec.rb @@ -78,6 +78,27 @@ RSpec.describe Campaign, type: :model do end end + context 'when SMS campaign' do + let!(:sms_channel) { create(:channel_sms) } + let!(:sms_inbox) { create(:inbox, channel: sms_channel) } + let(:campaign) { build(:campaign, inbox: sms_inbox) } + + it 'only saves campaign type as oneoff and wont leave scheduled_at empty' do + campaign.campaign_type = 'ongoing' + campaign.save! + expect(campaign.reload.campaign_type).to eq 'one_off' + expect(campaign.scheduled_at.present?).to eq true + end + + it 'calls sms service on trigger!' do + sms_service = double + expect(Sms::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service) + expect(sms_service).to receive(:perform) + campaign.save! + campaign.trigger! + end + end + context 'when Website campaign' do let(:campaign) { build(:campaign) } diff --git a/spec/services/contacts/contactable_inboxes_service_spec.rb b/spec/services/contacts/contactable_inboxes_service_spec.rb index 860771fbe..3efc00b1a 100644 --- a/spec/services/contacts/contactable_inboxes_service_spec.rb +++ b/spec/services/contacts/contactable_inboxes_service_spec.rb @@ -15,8 +15,8 @@ describe Contacts::ContactableInboxesService do let!(:email_inbox) { create(:inbox, channel: email_channel, account: account) } let!(:api_channel) { create(:channel_api, account: account) } let!(:api_inbox) { create(:inbox, channel: api_channel, account: account) } - let!(:website_channel) { create(:channel_widget, account: account) } - let!(:website_inbox) { create(:inbox, channel: website_channel, account: account) } + let!(:website_inbox) { create(:inbox, channel: create(:channel_widget, account: account), account: account) } + let!(:sms_inbox) { create(:inbox, channel: create(:channel_sms, account: account), account: account) } describe '#get' do it 'returns the contactable inboxes for the contact' do @@ -25,7 +25,7 @@ describe Contacts::ContactableInboxesService do expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: twilio_sms_inbox }) expect(contactable_inboxes).to include({ source_id: "whatsapp:#{contact.phone_number}", inbox: twilio_whatsapp_inbox }) expect(contactable_inboxes).to include({ source_id: contact.email, inbox: email_inbox }) - expect(contactable_inboxes.pluck(:inbox)).to include(api_inbox) + expect(contactable_inboxes).to include({ source_id: contact.phone_number, inbox: sms_inbox }) end it 'doest not return the non contactable inboxes for the contact' do diff --git a/spec/services/sms/incoming_message_service_spec.rb b/spec/services/sms/incoming_message_service_spec.rb new file mode 100644 index 000000000..1e86d8015 --- /dev/null +++ b/spec/services/sms/incoming_message_service_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +describe Sms::IncomingMessageService do + describe '#perform' do + let!(:sms_channel) { create(:channel_sms) } + + context 'when valid text message params' do + it 'creates appropriate conversations, message and contacts' do + params = { + + 'id': '3232420-2323-234324', + 'owner': sms_channel.phone_number, + 'applicationId': '2342349-324234d-32432432', + 'time': '2022-02-02T23:14:05.262Z', + 'segmentCount': 1, + 'direction': 'in', + 'to': [ + sms_channel.phone_number + ], + 'from': '+14234234234', + 'text': 'test message' + + }.with_indifferent_access + described_class.new(inbox: sms_channel.inbox, params: params).perform + expect(sms_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('+1 423-423-4234') + expect(sms_channel.inbox.messages.first.content).to eq('test message') + end + end + end +end diff --git a/spec/services/sms/oneoff_sms_campaign_service_spec.rb b/spec/services/sms/oneoff_sms_campaign_service_spec.rb new file mode 100644 index 000000000..9049175d8 --- /dev/null +++ b/spec/services/sms/oneoff_sms_campaign_service_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +describe Sms::OneoffSmsCampaignService do + subject(:sms_campaign_service) { described_class.new(campaign: campaign) } + + let(:account) { create(:account) } + let!(:sms_channel) { create(:channel_sms) } + let!(:sms_inbox) { create(:inbox, channel: sms_channel) } + let(:label1) { create(:label, account: account) } + let(:label2) { create(:label, account: account) } + let!(:campaign) do + create(:campaign, inbox: sms_inbox, account: account, + audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }]) + end + + describe 'perform' do + before do + stub_request(:post, 'https://messaging.bandwidth.com/api/v2/users/1/messages').to_return( + status: 200, + body: { 'id' => '1' }.to_json, + headers: {} + ) + end + + it 'raises error if the campaign is completed' do + campaign.completed! + + expect { sms_campaign_service.perform }.to raise_error 'Completed Campaign' + end + + it 'raises error invalid campaign when its not a oneoff sms campaign' do + campaign = create(:campaign) + + expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}" + end + + it 'send messages to contacts in the audience and marks the campaign completed' do + contact_with_label1, contact_with_label2, contact_with_both_labels = FactoryBot.create_list(:contact, 3, :with_phone_number, account: account) + contact_with_label1.update_labels([label1.title]) + contact_with_label2.update_labels([label2.title]) + contact_with_both_labels.update_labels([label1.title, label2.title]) + sms_campaign_service.perform + assert_requested(:post, 'https://messaging.bandwidth.com/api/v2/users/1/messages', times: 3) + expect(campaign.reload.completed?).to eq true + end + end +end diff --git a/spec/services/sms/send_on_sms_service_spec.rb b/spec/services/sms/send_on_sms_service_spec.rb new file mode 100644 index 000000000..7304fad88 --- /dev/null +++ b/spec/services/sms/send_on_sms_service_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe Sms::SendOnSmsService do + describe '#perform' do + context 'when a valid message' do + let(:sms_request) { double } + let!(:sms_channel) { create(:channel_sms) } + let!(:contact_inbox) { create(:contact_inbox, inbox: sms_channel.inbox, source_id: '+123456789') } + let!(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: sms_channel.inbox) } + + it 'calls channel.send_message' do + message = create(:message, message_type: :outgoing, content: 'test', + conversation: conversation) + allow(HTTParty).to receive(:post).and_return(sms_request) + allow(sms_request).to receive(:success?).and_return(true) + allow(sms_request).to receive(:parsed_response).and_return({ 'id' => '123456789' }) + expect(HTTParty).to receive(:post).with( + 'https://messaging.bandwidth.com/api/v2/users/1/messages', + basic_auth: { username: '1', password: '1' }, + headers: { 'Content-Type' => 'application/json' }, + body: { 'to' => '+123456789', 'from' => sms_channel.phone_number, 'text' => 'test', 'applicationId' => '1' }.to_json + ) + described_class.new(message: message).perform + expect(message.reload.source_id).to eq('123456789') + end + end + end +end