Feature: Twilio SMS Channel (#658)

Twilio SMS Channel
Fixes :  #350
This commit is contained in:
Pranav Raj S 2020-04-05 22:11:27 +05:30 committed by GitHub
parent 8e59564793
commit a1a81e3799
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 918 additions and 33 deletions

View file

@ -25,7 +25,7 @@ gem 'uglifier'
##-- for active storage --## ##-- for active storage --##
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'azure-storage', require: false gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'mini_magick' gem 'mini_magick'
@ -62,9 +62,9 @@ gem 'chargebee'
##--- gems for channels ---## ##--- gems for channels ---##
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'telegram-bot-ruby' gem 'telegram-bot-ruby'
gem 'twilio-ruby', '~> 5.32.0'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
gem 'twitty', git: 'https://github.com/chatwoot/twitty' gem 'twitty', git: 'https://github.com/chatwoot/twitty'
# facebook client # facebook client
gem 'koala' gem 'koala'
# Random name generator # Random name generator

View file

@ -102,15 +102,13 @@ GEM
descendants_tracker (~> 0.0.4) descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0) ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
azure-core (0.1.15) azure-storage-blob (2.0.0)
faraday (~> 0.9) azure-storage-common (~> 2.0)
faraday_middleware (~> 0.10) nokogiri (~> 1.10.4)
nokogiri (~> 1.6) azure-storage-common (2.0.1)
azure-storage (0.15.0.preview) faraday (~> 1.0)
azure-core (~> 0.1) faraday_middleware (~> 1.0.0.rc1)
faraday (~> 0.9) nokogiri (~> 1.10.4)
faraday_middleware (~> 0.10)
nokogiri (~> 1.6, >= 1.6.8)
bcrypt (3.1.13) bcrypt (3.1.13)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.4.6) bootsnap (1.4.6)
@ -172,10 +170,10 @@ GEM
railties (>= 4.2.0) railties (>= 4.2.0)
faker (2.11.0) faker (2.11.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (0.17.3) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday_middleware (0.14.0) faraday_middleware (1.0.0)
faraday (>= 0.7.4, < 1.0) faraday (~> 1.0)
ffi (1.12.2) ffi (1.12.2)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.1) foreman (0.87.1)
@ -410,8 +408,8 @@ GEM
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
semantic_range (2.3.0) semantic_range (2.3.0)
sentry-raven (2.13.0) sentry-raven (3.0.0)
faraday (>= 0.7.6, < 1.0) faraday (>= 1.0)
shoulda-matchers (4.3.0) shoulda-matchers (4.3.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
sidekiq (6.0.6) sidekiq (6.0.6)
@ -449,6 +447,10 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
twilio-ruby (5.32.0)
faraday (~> 1.0.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
tzinfo (1.2.7) tzinfo (1.2.7)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2019.3) tzinfo-data (1.2019.3)
@ -496,7 +498,7 @@ DEPENDENCIES
annotate annotate
attr_extras attr_extras
aws-sdk-s3 aws-sdk-s3
azure-storage azure-storage-blob
bootsnap bootsnap
brakeman brakeman
browser browser
@ -553,6 +555,7 @@ DEPENDENCIES
spring-watcher-listen spring-watcher-listen
telegram-bot-ruby telegram-bot-ruby
time_diff time_diff
twilio-ruby (~> 5.32.0)
twitty! twitty!
tzinfo-data tzinfo-data
uglifier uglifier

View file

@ -0,0 +1,37 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
build_contact
end
private
def account
@account ||= inbox.account
end
def build_contact
ActiveRecord::Base.transaction do
contact = account.contacts.create!(
name: contact_attributes[:name],
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:identifier]
)
contact_inbox = ::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
contact_inbox
rescue StandardError => e
Rails.logger e
end
end
end

View file

@ -0,0 +1,50 @@
class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseController
before_action :authorize_request
def create
authenticate_twilio
build_inbox
setup_webhooks
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e
render_could_not_create_error(e.message)
end
private
def authorize_request
authorize ::Inbox
end
def authenticate_twilio
client = Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token])
client.messages.list(limit: 1)
end
def setup_webhooks
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
end
def build_inbox
ActiveRecord::Base.transaction do
twilio_sms = current_account.twilio_sms.create(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
phone_number: permitted_params[:phone_number]
)
@inbox = current_account.inboxes.create(
name: permitted_params[:name],
channel: twilio_sms
)
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name
)
end
end

View file

@ -0,0 +1,29 @@
class Twilio::CallbackController < ApplicationController
def create
::Twilio::IncomingMessageService.new(params: permitted_params).perform
head :no_content
end
private
def permitted_params
params.permit(
:ApiVersion,
:SmsSid,
:From,
:ToState,
:ToZip,
:AccountSid,
:MessageSid,
:FromCountry,
:ToCity,
:FromCity,
:To,
:FromZip,
:Body,
:ToCountry,
:FromState
)
end
end

View file

@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class TwilioChannel extends ApiClient {
constructor() {
super('channels/twilio_channel', { accountScoped: true });
}
}
export default new TwilioChannel();

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -56,6 +56,7 @@ const INBOX_TYPES = {
WEB: 'Channel::WebWidget', WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage', FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile', TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
}; };
const getInboxClassByType = type => { const getInboxClassByType = type => {
switch (type) { switch (type) {
@ -68,6 +69,9 @@ const getInboxClassByType = type => {
case INBOX_TYPES.TWITTER: case INBOX_TYPES.TWITTER:
return 'ion-social-twitter'; return 'ion-social-twitter';
case INBOX_TYPES.TWILIO:
return 'ion-android-textsms';
default: default:
return ''; return '';
} }

View file

@ -24,6 +24,10 @@
v-if="channel === 'website'" v-if="channel === 'website'"
src="~dashboard/assets/images/channels/website.png" src="~dashboard/assets/images/channels/website.png"
/> />
<img
v-if="channel === 'twilio'"
src="~dashboard/assets/images/channels/twilio.png"
/>
<h3 class="channel__title"> <h3 class="channel__title">
{{ channel }} {{ channel }}
</h3> </h3>
@ -39,7 +43,7 @@ export default {
}, },
methods: { methods: {
isActive(channel) { isActive(channel) {
return ['facebook', 'website', 'twitter'].includes(channel); return ['facebook', 'website', 'twitter', 'twilio'].includes(channel);
}, },
onItemClick() { onItemClick() {
if (this.isActive(this.channel)) { if (this.isActive(this.channel)) {

View file

@ -21,6 +21,7 @@
"WEBSITE_CHANNEL": { "WEBSITE_CHANNEL": {
"TITLE": "Website channel", "TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.", "DESC": "Create a channel for your website and start supporting your customers via our website widget.",
"LOADING_MESSAGE": "Creating Website Support Channel",
"CHANNEL_NAME": { "CHANNEL_NAME": {
"LABEL": "Website Name", "LABEL": "Website Name",
"PLACEHOLDER": "Enter your website name (eg: Acme Inc)" "PLACEHOLDER": "Enter your website name (eg: Acme Inc)"
@ -35,6 +36,34 @@
}, },
"SUBMIT_BUTTON":"Create inbox" "SUBMIT_BUTTON":"Create inbox"
}, },
"TWILIO": {
"TITLE": "Twilio SMS Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS.",
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Please enter your Twilio Account SID",
"ERROR": "This field is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Please enter your Twilio Auth Token",
"ERROR": "This field is required"
},
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel 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 Twilio Channel",
"API": {
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
}
},
"AUTH": { "AUTH": {
"TITLE": "Channels", "TITLE": "Channels",
"DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon." "DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon."

View file

@ -128,11 +128,11 @@ export default {
this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE')); this.showAlert(this.$t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose(); this.onClose();
} catch (error) { } catch (error) {
if (error.response.status === 422) { if (error.response.status === 422) {
this.showAlert(this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE')); this.showAlert(this.$t('AGENT_MGMT.ADD.API.EXIST_MESSAGE'));
} else { } else {
this.showAlert(this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE')); this.showAlert(this.$t('AGENT_MGMT.ADD.API.ERROR_MESSAGE'));
} }
} }
}, },
}, },

View file

@ -27,7 +27,14 @@ export default {
}, },
data() { data() {
return { return {
channelList: ['website', 'facebook', 'twitter', 'telegram', 'line'], channelList: [
'website',
'facebook',
'twitter',
'twilio',
'telegram',
'line',
],
}; };
}, },
methods: { methods: {

View file

@ -28,12 +28,14 @@
</template> </template>
<script> <script>
import configMixin from 'shared/mixins/configMixin';
import EmptyState from '../../../../components/widgets/EmptyState'; import EmptyState from '../../../../components/widgets/EmptyState';
export default { export default {
components: { components: {
EmptyState, EmptyState,
}, },
mixins: [configMixin],
computed: { computed: {
currentInbox() { currentInbox() {
return this.$store.getters['inboxes/getInbox']( return this.$store.getters['inboxes/getInbox'](

View file

@ -42,6 +42,9 @@
<span v-if="item.channel_type === 'Channel::TwitterProfile'"> <span v-if="item.channel_type === 'Channel::TwitterProfile'">
Twitter Twitter
</span> </span>
<span v-if="item.channel_type === 'Channel::TwilioSms'">
Twilio SMS
</span>
</td> </td>
<!-- Action Buttons --> <!-- Action Buttons -->

View file

@ -2,7 +2,7 @@
<div class="settings columns container"> <div class="settings columns container">
<woot-modal-header <woot-modal-header
:header-image="inbox.avatarUrl" :header-image="inbox.avatarUrl"
:header-title="inbox.name" :header-title="inboxName"
/> />
<div <div
v-if="inbox.channel_type === 'Channel::FacebookPage'" v-if="inbox.channel_type === 'Channel::FacebookPage'"
@ -87,6 +87,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { createMessengerScript } from 'dashboard/helper/scriptGenerator'; import { createMessengerScript } from 'dashboard/helper/scriptGenerator';
import { Compact } from 'vue-color'; import { Compact } from 'vue-color';
import configMixin from 'shared/mixins/configMixin';
import SettingsFormHeader from '../../../../components/SettingsFormHeader.vue'; import SettingsFormHeader from '../../../../components/SettingsFormHeader.vue';
export default { export default {
@ -94,6 +95,7 @@ export default {
Compact, Compact,
SettingsFormHeader, SettingsFormHeader,
}, },
mixins: [configMixin],
data() { data() {
return { return {
selectedAgents: [], selectedAgents: [],
@ -113,6 +115,12 @@ export default {
inbox() { inbox() {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId); return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
}, },
inboxName() {
if (this.inbox.channel_type === 'Channel::TwilioSms') {
return `${this.inbox.name} (${this.inbox.phone_number})`;
}
return this.inbox.name;
},
messengerScript() { messengerScript() {
return createMessengerScript(this.inbox.page_id); return createMessengerScript(this.inbox.page_id);
}, },

View file

@ -1,11 +1,13 @@
import Facebook from './channels/Facebook'; import Facebook from './channels/Facebook';
import Website from './channels/Website'; import Website from './channels/Website';
import Twitter from './channels/Twitter'; import Twitter from './channels/Twitter';
import Twilio from './channels/Twilio';
const channelViewList = { const channelViewList = {
facebook: Facebook, facebook: Facebook,
website: Website, website: Website,
twitter: Twitter, twitter: Twitter,
twilio: Twilio,
}; };
export default { export default {

View file

@ -0,0 +1,143 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.TWILIO.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.TWILIO.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.PLACEHOLDER')"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_NAME.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.PHONE_NUMBER.PLACEHOLDER')"
@blur="$v.phoneNumber.$touch"
/>
<span v-if="$v.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.TWILIO.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.accountSID.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.ACCOUNT_SID.LABEL') }}
<input
v-model.trim="accountSID"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
@blur="$v.accountSID.$touch"
/>
<span v-if="$v.accountSID.$error" class="message">
{{ $t('INBOX_MGMT.ADD.TWILIO.ACCOUNT_SID.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.LABEL') }}
<input
v-model.trim="authToken"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
@blur="$v.authToken.$touch"
/>
<span v-if="$v.authToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.TWILIO.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
accountSID: '',
authToken: '',
channelName: '',
phoneNumber: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
phoneNumber: { required, shouldStartWithPlusSign },
authToken: { required },
accountSID: { required },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const twilioChannel = await this.$store.dispatch(
'inboxes/createTwilioChannel',
{
twilio_channel: {
name: this.channelName,
account_sid: this.accountSID,
auth_token: this.authToken,
phone_number: this.phoneNumber,
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: twilioChannel.id,
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.TWILIO.API.ERROR_MESSAGE'));
}
},
},
};
</script>

View file

@ -5,11 +5,15 @@
:header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')" :header-content="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.DESC')"
/> />
<woot-loading-state <woot-loading-state
v-if="isCreating" v-if="uiFlags.isCreating"
message="Creating Website Support Channel" :message="$('INBOX_MGMT.ADD.WEBSITE_CHANNEL.LOADING_MESSAGE')"
> >
</woot-loading-state> </woot-loading-state>
<form v-if="!isCreating" class="row" @submit.prevent="createChannel()"> <form
v-if="!uiFlags.isCreating"
class="row"
@submit.prevent="createChannel()"
>
<div class="medium-12 columns"> <div class="medium-12 columns">
<label> <label>
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL') }}
@ -45,6 +49,7 @@
<div class="modal-footer"> <div class="modal-footer">
<div class="medium-12 columns"> <div class="medium-12 columns">
<woot-submit-button <woot-submit-button
:loading="uiFlags.isCreating"
:disabled="!websiteUrl || !websiteName" :disabled="!websiteUrl || !websiteName"
:button-text="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.SUBMIT_BUTTON')" :button-text="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.SUBMIT_BUTTON')"
/> />
@ -70,7 +75,6 @@ export default {
websiteName: '', websiteName: '',
websiteUrl: '', websiteUrl: '',
widgetColor: { hex: '#009CE0' }, widgetColor: { hex: '#009CE0' },
isCreating: false,
}; };
}, },
computed: { computed: {

View file

@ -3,6 +3,7 @@ import * as types from '../mutation-types';
import InboxesAPI from '../../api/inboxes'; import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel'; import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel'; import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
export const state = { export const state = {
records: [], records: [],
@ -54,6 +55,18 @@ export const actions = {
throw new Error(error); throw new Error(error);
} }
}, },
createTwilioChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await TwilioChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw new Error(error);
}
},
createFBChannel: async ({ commit }, params) => { createFBChannel: async ({ commit }, params) => {
try { try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });

View file

@ -0,0 +1,8 @@
/* global bus */
export default {
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
},
};

View file

@ -0,0 +1,7 @@
export default {
computed: {
hostURL() {
return window.chatwootConfig.hostURL;
},
},
};

View file

@ -23,9 +23,10 @@ class Account < ApplicationRecord
has_many :messages, dependent: :destroy has_many :messages, dependent: :destroy
has_many :contacts, dependent: :destroy has_many :contacts, dependent: :destroy
has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage' has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage'
has_many :telegram_bots, dependent: :destroy
has_many :twilio_sms, dependent: :destroy, class_name: '::Channel::TwilioSms'
has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile' has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile'
has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget'
has_many :telegram_bots, dependent: :destroy
has_many :canned_responses, dependent: :destroy has_many :canned_responses, dependent: :destroy
has_many :webhooks, dependent: :destroy has_many :webhooks, dependent: :destroy
has_one :subscription, dependent: :destroy has_one :subscription, dependent: :destroy

View file

@ -0,0 +1,33 @@
# == Schema Information
#
# Table name: channel_twilio_sms
#
# id :bigint not null, primary key
# account_sid :string not null
# auth_token :string not null
# phone_number :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_twilio_sms_on_account_id_and_phone_number (account_id,phone_number) UNIQUE
#
class Channel::TwilioSms < ApplicationRecord
self.table_name = 'channel_twilio_sms'
validates :account_id, presence: true
validates :account_sid, presence: true
validates :auth_token, presence: true
validates :phone_number, uniqueness: { scope: :account_id }, presence: true
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
def name
'Twilio SMS'
end
end

View file

@ -11,6 +11,10 @@
# account_id :integer not null # account_id :integer not null
# profile_id :string not null # profile_id :string not null
# #
# Indexes
#
# index_channel_twitter_profiles_on_account_id_and_profile_id (account_id,profile_id) UNIQUE
#
class Channel::TwitterProfile < ApplicationRecord class Channel::TwitterProfile < ApplicationRecord
self.table_name = 'channel_twitter_profiles' self.table_name = 'channel_twitter_profiles'

View file

@ -113,6 +113,8 @@ class Message < ApplicationRecord
::Facebook::SendReplyService.new(message: self).perform ::Facebook::SendReplyService.new(message: self).perform
elsif channel_name == 'Channel::TwitterProfile' elsif channel_name == 'Channel::TwitterProfile'
::Twitter::SendReplyService.new(message: self).perform ::Twitter::SendReplyService.new(message: self).perform
elsif channel_name == 'Channel::TwilioSms'
::Twilio::OutgoingMessageService.new(message: self).perform
end end
end end

View file

@ -10,6 +10,10 @@
# account_id :integer # account_id :integer
# inbox_id :integer # inbox_id :integer
# #
# Indexes
#
# index_webhooks_on_account_id_and_url (account_id,url) UNIQUE
#
class Webhook < ApplicationRecord class Webhook < ApplicationRecord
belongs_to :account belongs_to :account

View file

@ -0,0 +1,77 @@
class Twilio::IncomingMessageService
pattr_initialize [:params!]
def perform
set_contact
set_conversation
@conversation.messages.create(
content: params[:Body],
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
contact_id: @contact.id,
source_id: params[:SmsSid]
)
end
private
def twilio_inbox
@twilio_inbox ||= ::Channel::TwilioSms.find_by!(
account_sid: params[:AccountSid],
phone_number: params[:To]
)
end
def inbox
@inbox ||= twilio_inbox.inbox
end
def account
@account ||= inbox.account
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,
additional_attributes: additional_attributes
}
end
def set_conversation
@conversation = @contact_inbox.conversations.first
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def contact_attributes
{
name: params[:From],
phone_number: params[:From],
contact_attributes: additional_attributes
}
end
def additional_attributes
{
from_zip_code: params[:FromZip],
from_country: params[:FromCountry],
from_state: params[:FromState]
}
end
end

View file

@ -0,0 +1,34 @@
class Twilio::OutgoingMessageService
pattr_initialize [:message!]
def perform
return if message.private
return if message.source_id
return if inbox.channel.class.to_s != 'Channel::TwilioSms'
return unless message.outgoing?
twilio_message = client.messages.create(
body: message.content,
from: channel.phone_number,
to: contact.phone_number
)
message.update!(source_id: twilio_message.sid)
end
private
delegate :conversation, to: :message
delegate :contact, to: :conversation
def inbox
@inbox ||= message.inbox
end
def channel
@channel ||= inbox.channel
end
def client
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
end

View file

@ -0,0 +1,35 @@
class Twilio::WebhookSetupService
include Rails.application.routes.url_helpers
pattr_initialize [:inbox!]
def perform
if phone_numbers.empty?
Rails.logger.info "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
else
twilio_client
.incoming_phone_numbers(phonenumber_sid)
.update(sms_method: 'POST', sms_url: twilio_callback_index_url)
end
rescue Twilio::REST::TwilioError => e
Rails.logger.info "TWILIO_FAILURE: #{e.message}"
end
private
def phonenumber_sid
phone_numbers.first.sid
end
def phone_numbers
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
end
def channel
@channel ||= inbox.channel
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
end

View file

@ -0,0 +1,6 @@
json.id @inbox.id
json.channel_id @inbox.channel_id
json.name @inbox.name
json.channel_type @inbox.channel_type
json.enable_auto_assignment @inbox.enable_auto_assignment
json.phone_number @inbox.channel.phone_number

View file

@ -10,5 +10,6 @@ json.payload do
json.website_token inbox.channel.try(:website_token) json.website_token inbox.channel.try(:website_token)
json.enable_auto_assignment inbox.enable_auto_assignment json.enable_auto_assignment inbox.enable_auto_assignment
json.web_widget_script inbox.channel.try(:web_widget_script) json.web_widget_script inbox.channel.try(:web_widget_script)
json.phone_number inbox.channel.try(:phone_number)
end end
end end

View file

@ -31,6 +31,7 @@
<%= yield %> <%= yield %>
<script> <script>
window.chatwootConfig = { window.chatwootConfig = {
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>', fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>',
billingEnabled: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('BILLING_ENABLED', false)) %>, billingEnabled: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('BILLING_ENABLED', false)) %>,
signupEnabled: '<%= ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) %>' signupEnabled: '<%= ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) %>'

View file

@ -36,7 +36,9 @@ Rails.application.routes.draw do
end end
end end
resources :canned_responses, except: [:show, :edit, :new] resources :canned_responses, except: [:show, :edit, :new]
namespace :channels do
resource :twilio_channel, only: [:create]
end
resources :conversations, only: [:index, :show] do resources :conversations, only: [:index, :show] do
scope module: :conversations do scope module: :conversations do
resources :messages, only: [:index, :create] resources :messages, only: [:index, :create]
@ -135,6 +137,10 @@ Rails.application.routes.draw do
resource :callback, only: [:show] resource :callback, only: [:show]
end end
namespace :twilio do
resources :callback, only: [:create]
end
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Used in mailer templates # Used in mailer templates
resource :app, only: [:index] do resource :app, only: [:index] do

View file

@ -0,0 +1,11 @@
class CreateChannelTwilioSms < ActiveRecord::Migration[6.0]
def change
create_table :channel_twilio_sms do |t|
t.string :phone_number, null: false
t.string :auth_token, null: false
t.string :account_sid, null: false
t.integer :account_id, null: false
t.timestamps
end
end
end

View file

@ -0,0 +1,7 @@
class AddUniqueValidationIndex < ActiveRecord::Migration[6.0]
def change
add_index :channel_twitter_profiles, [:account_id, :profile_id], unique: true
add_index :channel_twilio_sms, [:account_id, :phone_number], unique: true
add_index :webhooks, [:account_id, :url], unique: true
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_03_31_095710) do ActiveRecord::Schema.define(version: 2020_04_04_135009) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -115,6 +115,16 @@ ActiveRecord::Schema.define(version: 2020_03_31_095710) do
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id" t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
end end
create_table "channel_twilio_sms", force: :cascade do |t|
t.string "phone_number", null: false
t.string "auth_token", null: false
t.string "account_sid", null: false
t.integer "account_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id", "phone_number"], name: "index_channel_twilio_sms_on_account_id_and_phone_number", unique: true
end
create_table "channel_twitter_profiles", force: :cascade do |t| create_table "channel_twitter_profiles", force: :cascade do |t|
t.string "name" t.string "name"
t.string "profile_id", null: false t.string "profile_id", null: false
@ -123,6 +133,7 @@ ActiveRecord::Schema.define(version: 2020_03_31_095710) do
t.integer "account_id", null: false t.integer "account_id", null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t| create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
@ -331,6 +342,7 @@ ActiveRecord::Schema.define(version: 2020_03_31_095710) do
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.integer "webhook_type", default: 0 t.integer "webhook_type", default: 0
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end end
add_foreign_key "account_users", "accounts" add_foreign_key "account_users", "accounts"

View file

@ -0,0 +1,40 @@
require 'rails_helper'
describe ::ContactBuilder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:existing_contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
describe '#perform' do
it 'doesnot create contact if it already exist' do
contact_inbox = described_class.new(
source_id: existing_contact_inbox.source_id,
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'creates contact if contact doesnot exist' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).not_to eq(contact.id)
expect(contact_inbox.contact.name).to eq('Contact')
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
end
end

View file

@ -0,0 +1,76 @@
require 'rails_helper'
RSpec.describe '/api/v1/accounts/{account.id}/channels/twilio_channel', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:twilio_client) { instance_double(::Twilio::REST::Client) }
let(:message_double) { instance_double('message') }
let(:twilio_webhook_setup_service) { instance_double(::Twilio::WebhookSetupService) }
before do
allow(::Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(::Twilio::WebhookSetupService).to receive(:new).and_return(twilio_webhook_setup_service)
allow(twilio_webhook_setup_service).to receive(:perform)
end
describe 'POST /api/v1/accounts/{account.id}/channels/twilio_channel' do
let(:params) do
{
twilio_channel: {
account_sid: 'sid',
auth_token: 'token',
phone_number: '+1234567890',
name: 'SMS Channel'
}
}
end
context 'when unauthenticated user' do
it 'returns unauthorized' do
post api_v1_account_channels_twilio_channel_path(account), params: params
expect(response).to have_http_status(:unauthorized)
end
end
context 'when user is logged in' do
context 'with user as administrator' do
it 'creates inbox and returns inbox object' do
allow(twilio_client).to receive(:messages).and_return(message_double)
allow(message_double).to receive(:list).and_return([])
post api_v1_account_channels_twilio_channel_path(account),
params: params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['name']).to eq('SMS Channel')
expect(json_response['phone_number']).to eq('+1234567890')
end
it 'return error if Twilio tokens are incorrect' do
allow(twilio_client).to receive(:messages).and_return(message_double)
allow(message_double).to receive(:list).and_raise(Twilio::REST::TwilioError)
post api_v1_account_channels_twilio_channel_path(account),
params: params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'with user as agent' do
it 'returns unauthorized' do
post api_v1_account_channels_twilio_channel_path(account),
params: params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
end
end
end

View file

@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe 'Twilio::CallbacksController', type: :request do
include Rails.application.routes.url_helpers
let(:twilio_service) { instance_double(Twilio::IncomingMessageService) }
before do
allow(::Twilio::IncomingMessageService).to receive(:new).and_return(twilio_service)
allow(twilio_service).to receive(:perform)
end
describe 'GET /twilio/callback' do
it 'calls incoming message service' do
post twilio_callback_index_url, params: {}
expect(twilio_service).to have_received(:perform)
end
end
end

View file

@ -21,7 +21,7 @@ RSpec.describe 'Twitter::CallbacksController', type: :request do
end end
describe 'GET /twitter/callback' do describe 'GET /twitter/callback' do
it 'renders the page correctly when called with website_token' do it 'returns correct response' do
get twitter_callback_url get twitter_callback_url
account.reload account.reload
expect(response).to redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id) expect(response).to redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)

View file

@ -0,0 +1,9 @@
FactoryBot.define do
factory :channel_twilio_sms, class: 'Channel::TwilioSms' do
auth_token { SecureRandom.uuid }
account_sid { SecureRandom.uuid }
sequence(:phone_number) { |n| "+123456789#{n}1" }
inbox
account
end
end

View file

@ -0,0 +1,39 @@
require 'rails_helper'
describe Twilio::IncomingMessageService do
let!(:account) { create(:account) }
let!(:twilio_sms) do
create(:channel_twilio_sms, account: account, phone_number: '+1234567890', account_sid: 'ACxxx')
end
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_sms.inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_sms.inbox, contact_inbox: contact_inbox) }
describe '#perform' do
it 'creates a new message in existing conversation' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
To: '+1234567890',
Body: 'testing3'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
end
it 'creates a new conversation' do
params = {
SmsSid: 'SMxx',
From: '+123456',
AccountSid: 'ACxxx',
To: '+1234567890',
Body: 'new conversation'
}
described_class.new(params: params).perform
expect(Conversation.count).to eq(2)
end
end
end

View file

@ -0,0 +1,59 @@
require 'rails_helper'
describe Twilio::OutgoingMessageService do
subject(:outgoing_message_service) { described_class.new(message: message) }
let(:twilio_client) { instance_double(::Twilio::REST::Client) }
let(:messages_double) { instance_double('messages') }
let(:message_record_double) { instance_double('message_record_double') }
let!(:account) { create(:account) }
let!(:widget_inbox) { create(:inbox, account: account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
before do
allow(::Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:messages).and_return(messages_double)
end
describe '#perform' do
context 'without reply' do
it 'if message is private' do
create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account)
expect(twilio_client).not_to have_received(:messages)
end
it 'if inbox channel is not facebook page' do
create(:message, message_type: 'outgoing', inbox: widget_inbox, account: account)
expect(twilio_client).not_to have_received(:messages)
end
it 'if message is not outgoing' do
create(:message, message_type: 'incoming', inbox: twilio_inbox, account: account)
expect(twilio_client).not_to have_received(:messages)
end
it 'if message has an source id' do
create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, source_id: SecureRandom.uuid)
expect(twilio_client).not_to have_received(:messages)
end
end
context 'with reply' do
it 'if message is sent from chatwoot and is outgoing' do
allow(messages_double).to receive(:create).and_return(message_record_double)
allow(message_record_double).to receive(:sid).and_return('1234')
outgoing_message = create(
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
)
expect(outgoing_message.reload.source_id).to eq('1234')
end
end
end
end

View file

@ -0,0 +1,48 @@
require 'rails_helper'
describe Twilio::WebhookSetupService do
include Rails.application.routes.url_helpers
let(:channel_twilio_sms) { create(:channel_twilio_sms) }
let(:twilio_client) { instance_double(::Twilio::REST::Client) }
let(:phone_double) { instance_double('phone_double') }
let(:phone_record_double) { instance_double('phone_record_double') }
before do
allow(::Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(phone_double).to receive(:update)
allow(phone_record_double).to receive(:sid).and_return('1234')
end
describe '#perform' do
it 'logs error if phone_number is not found' do
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
allow(phone_double).to receive(:list).and_return([])
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(phone_double).not_to have_received(:update)
end
it 'update webhook_url if phone_number is found' do
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
allow(phone_double).to receive(:list).and_return([phone_record_double])
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(phone_double).to have_received(:update).with(
sms_method: 'POST',
sms_url: twilio_callback_index_url
)
end
it 'doesnot call update if TwilioError is thrown' do
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
allow(phone_double).to receive(:list).and_raise(Twilio::REST::TwilioError)
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(phone_double).not_to have_received(:update)
end
end
end