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