Feature: Twilio Whatsapp Integration (#779)

Twilio Whatsapp Integration

Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2020-04-30 01:41:13 +05:30 committed by GitHub
parent 168042f9a4
commit 0cb7333977
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 238 additions and 81 deletions

View file

@ -17,6 +17,7 @@ gem 'jbuilder'
gem 'kaminari'
gem 'responders'
gem 'rest-client'
gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'

View file

@ -448,6 +448,7 @@ GEM
faraday
inflecto
virtus
telephone_number (1.4.6)
thor (0.20.3)
thread_safe (0.3.6)
time_diff (0.3.0)
@ -560,6 +561,7 @@ DEPENDENCIES
spring
spring-watcher-listen
telegram-bot-ruby
telephone_number
time_diff
twilio-ruby (~> 5.32.0)
twitty!

View file

@ -21,13 +21,14 @@ class ContactBuilder
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:identifier]
additional_attributes: contact_attributes[:additional_attributes]
)
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

View file

@ -15,7 +15,6 @@ class Messages::Outgoing::NormalBuilder
def perform
@message = @conversation.messages.build(message_params)
@message.save
if @attachments.present?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
@ -24,8 +23,8 @@ class Messages::Outgoing::NormalBuilder
)
attachment.file.attach(uploaded_attachment)
end
@message.save
end
@message.save
@message
end

View file

@ -2,13 +2,15 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
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)
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e
render_could_not_create_error(e.message)
end
end
private
@ -26,25 +28,30 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::BaseControlle
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
end
def phone_number
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
end
def medium
permitted_params[:medium]
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
@twilio_channel = current_account.twilio_sms.create!(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
phone_number: phone_number,
medium: medium
)
@inbox = current_account.inboxes.create(
name: permitted_params[:name],
channel: @twilio_channel
)
end
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium
)
end
end

View file

@ -23,7 +23,9 @@ class Twilio::CallbackController < ApplicationController
:FromZip,
:Body,
:ToCountry,
:FromState
:FromState,
:MediaUrl0,
:MediaContentType0
)
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -33,6 +33,14 @@
:style="badgeStyle"
src="~dashboard/assets/images/twitter-badge.png"
/>
<img
v-if="badge === 'Channel::TwilioSms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png"
/>
</div>
</template>
<script>

View file

@ -26,6 +26,7 @@
<file-upload
v-if="showFileUpload"
:size="4096 * 4096"
accept="jpg,jpeg,png,mp3,ogg,amr,pdf,mp4"
@input-file="onFileUpload"
>
<i
@ -142,7 +143,10 @@ export default {
return 10000;
},
showFileUpload() {
return this.channelType === 'Channel::WebWidget';
return (
this.channelType === 'Channel::WebWidget' ||
this.channelType === 'Channel::TwilioSms'
);
},
replyButtonLabel() {
if (this.isPrivate) {
@ -295,6 +299,9 @@ export default {
},
onFileUpload(file) {
if (!file) {
return;
}
this.isUploading.image = true;
this.$store
.dispatch('sendAttachment', [this.currentChat.id, { file: file.file }])

View file

@ -6,10 +6,26 @@
"404": "Diesem Konto sind keine Posteingänge zugeordnet."
},
"CREATE_FLOW": [
{ "title": "Wählen Sie Kanal", "route": "settings_inbox_new", "body": "Wählen Sie den Anbieter, den Sie in Chatwoot integrieren möchten." },
{ "title": "Posteingang erstellen", "route": "settings_inboxes_page_channel", "body": "Authentifizieren Sie Ihr Konto und erstellen Sie einen Posteingang." },
{ "title": "Agenten hinzufügen", "route": "settings_inboxes_add_agents", "body": "Fügen Sie dem erstellten Posteingang Agenten hinzu." },
{ "title": "Voila!", "route": "settings_inbox_finish", "body": "Sie sind bereit zu gehen!" }
{
"title": "Wählen Sie Kanal",
"route": "settings_inbox_new",
"body": "Wählen Sie den Anbieter, den Sie in Chatwoot integrieren möchten."
},
{
"title": "Posteingang erstellen",
"route": "settings_inboxes_page_channel",
"body": "Authentifizieren Sie Ihr Konto und erstellen Sie einen Posteingang."
},
{
"title": "Agenten hinzufügen",
"route": "settings_inboxes_add_agents",
"body": "Fügen Sie dem erstellten Posteingang Agenten hinzu."
},
{
"title": "Voila!",
"route": "settings_inbox_finish",
"body": "Sie sind bereit zu gehen!"
}
],
"ADD": {
"FB": {
@ -33,12 +49,11 @@
"LABEL": "Widget Farbe",
"PLACEHOLDER": "Aktualisieren Sie die im Widget verwendete Widget-Farbe"
},
"SUBMIT_BUTTON":"Posteingang erstellen"
"SUBMIT_BUTTON": "Posteingang erstellen"
},
"TWILIO": {
"TITLE": "Twilio SMS Channel",
"DESC": "Integrieren Sie Twilio und unterstützen Sie Ihre Kunden per SMS.",
"TITLE": "Twilio SMS/Whatsapp Channel",
"DESC": "Integrieren Sie Twilio und unterstützen Sie Ihre Kunden per SMS/Whatsapp.",
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Bitte geben Sie Ihre Twilio Account SID ein",
@ -76,7 +91,7 @@
"TITLE": "Posteingangsdetails",
"DESC": "Wählen Sie aus der Dropdown-Liste die Facebook-Seite aus, zu der Sie eine Verbindung zu Chatwoot herstellen möchten. Sie können Ihrem Posteingang auch einen benutzerdefinierten Namen geben, um ihn besser identifizieren zu können."
},
"FINISH":{
"FINISH": {
"TITLE": "Geschafft!",
"DESC": "Sie haben die Integration Ihrer Facebook-Seite in Chatwoot erfolgreich abgeschlossen. Wenn ein Kunde das nächste Mal eine Nachricht an Ihre Seite sendet, wird die Konversation automatisch in Ihrem Posteingang angezeigt. <br> Wir stellen Ihnen außerdem ein Widget-Skript zur Verfügung, das Sie ganz einfach zu Ihrer Website hinzufügen können. Sobald dies auf Ihrer Website live ist, können Kunden Ihnen ohne Hilfe eines externen Tools direkt von Ihrer Website aus eine Nachricht senden, und die Konversation wird direkt hier auf Chatwoot angezeigt. <br> Cool, oder? Nun, wir versuchen es auf jeden Fall :)"
}

View file

@ -6,10 +6,26 @@
"404": "There are no inboxes attached to this account."
},
"CREATE_FLOW": [
{ "title": "Choose Channel", "route": "settings_inbox_new", "body": "Choose the provider you want to integrate with Chatwoot." },
{ "title": "Create Inbox", "route": "settings_inboxes_page_channel", "body": "Authenticate your account and create an inbox." },
{ "title": "Add Agents", "route": "settings_inboxes_add_agents", "body": "Add agents to the created inbox." },
{ "title": "Voila!", "route": "settings_inbox_finish", "body": "You are all set to go!" }
{
"title": "Choose Channel",
"route": "settings_inbox_new",
"body": "Choose the provider you want to integrate with Chatwoot."
},
{
"title": "Create Inbox",
"route": "settings_inboxes_page_channel",
"body": "Authenticate your account and create an inbox."
},
{
"title": "Add Agents",
"route": "settings_inboxes_add_agents",
"body": "Add agents to the created inbox."
},
{
"title": "Voila!",
"route": "settings_inbox_finish",
"body": "You are all set to go!"
}
],
"ADD": {
"FB": {
@ -46,16 +62,20 @@
"LABEL": "Widget Color",
"PLACEHOLDER": "Update the widget color used in widget"
},
"SUBMIT_BUTTON":"Create inbox"
"SUBMIT_BUTTON": "Create inbox"
},
"TWILIO": {
"TITLE": "Twilio SMS Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS.",
"TITLE": "Twilio SMS/Whatsapp Channel",
"DESC": "Integrate Twilio and start supporting your customers via SMS or Whatsapp.",
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Please enter your Twilio Account SID",
"ERROR": "This field is required"
},
"CHANNEL_TYPE": {
"LABEL": "Channel Type",
"ERROR": "Please select your Channel Type"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Please enter your Twilio Auth Token",
@ -88,7 +108,7 @@
"TITLE": "Inbox Details",
"DESC": "From the dropdown below, select the Facebook Page you want to connect to Chatwoot. You can also give a custom name to your inbox for better identification."
},
"FINISH":{
"FINISH": {
"TITLE": "Nailed It!",
"DESC": "You have successfully finished integrating your Facebook Page with Chatwoot. Next time a customer messages your Page, the conversation will automatically appear on your inbox.<br>We are also providing you with a widget script that you can easily add to your website. Once this is live on your website, customers can message you right from your website without the help of any external tool and the conversation will appear right here, on Chatwoot.<br>Cool, huh? Well, we sure try to be :)"
}

View file

@ -23,6 +23,13 @@
>
{{ contact.email }}
</a>
<a
v-if="contact.phone_number"
:href="`tel:${contact.phone_number}`"
class="contact--email"
>
{{ contact.phone_number }}
</a>
<div
v-if="
@ -211,7 +218,7 @@ export default {
text-transform: capitalize;
font-weight: $font-weight-bold;
font-size: $font-size-medium;
font-size: $font-size-default;
}
.contact--email {

View file

@ -14,9 +14,22 @@
: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>
<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.medium.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.CHANNEL_TYPE.LABEL') }}
<select v-model="medium">
<option value="sms">SMS</option>
<option value="whatsapp">Whatsapp</option>
</select>
<span v-if="$v.medium.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.CHANNEL_TYPE.ERROR')
}}</span>
</label>
</div>
@ -29,9 +42,9 @@
: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>
<span v-if="$v.phoneNumber.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.PHONE_NUMBER.ERROR')
}}</span>
</label>
</div>
@ -44,9 +57,9 @@
: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>
<span v-if="$v.accountSID.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.ACCOUNT_SID.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
@ -58,9 +71,9 @@
: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>
<span v-if="$v.authToken.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.ERROR')
}}</span>
</label>
</div>
@ -92,6 +105,7 @@ export default {
return {
accountSID: '',
authToken: '',
medium: '',
channelName: '',
phoneNumber: '',
};
@ -106,6 +120,7 @@ export default {
phoneNumber: { required, shouldStartWithPlusSign },
authToken: { required },
accountSID: { required },
medium: { required },
},
methods: {
async createChannel() {
@ -120,6 +135,7 @@ export default {
{
twilio_channel: {
name: this.channelName,
medium: this.medium,
account_sid: this.accountSID,
auth_token: this.authToken,
phone_number: this.phoneNumber,

View file

@ -5,6 +5,7 @@
# id :bigint not null, primary key
# account_sid :string not null
# auth_token :string not null
# medium :integer default("sms")
# phone_number :string not null
# created_at :datetime not null
# updated_at :datetime not null
@ -23,6 +24,8 @@ class Channel::TwilioSms < ApplicationRecord
validates :auth_token, presence: true
validates :phone_number, uniqueness: { scope: :account_id }, presence: true
enum medium: { sms: 0, whatsapp: 1 }
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy

View file

@ -70,11 +70,12 @@ class Message < ApplicationRecord
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
after_create :reopen_conversation,
:dispatch_event,
:send_reply,
:execute_message_template_hooks,
:notify_via_mail
# we need to wait for the active storage attachments to be available
after_create_commit :dispatch_event, :send_reply
after_update :dispatch_update_event
def channel_token

View file

@ -1,10 +1,12 @@
class Twilio::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:params!]
def perform
set_contact
set_conversation
@conversation.messages.create(
@message = @conversation.messages.create(
content: params[:Body],
account_id: @inbox.account_id,
inbox_id: @inbox.id,
@ -12,6 +14,7 @@ class Twilio::IncomingMessageService
contact_id: @contact.id,
source_id: params[:SmsSid]
)
attach_files
end
private
@ -31,6 +34,14 @@ class Twilio::IncomingMessageService
@account ||= inbox.account
end
def phone_number
twilio_inbox.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
end
def formatted_phone_number
TelephoneNumber.parse(phone_number).international_number
end
def set_contact
contact_inbox = ::ContactBuilder.new(
source_id: params[:From],
@ -61,17 +72,40 @@ class Twilio::IncomingMessageService
def contact_attributes
{
name: params[:From],
phone_number: params[:From],
contact_attributes: additional_attributes
name: formatted_phone_number,
phone_number: phone_number,
additional_attributes: additional_attributes
}
end
def additional_attributes
{
from_zip_code: params[:FromZip],
from_country: params[:FromCountry],
from_state: params[:FromState]
}
if twilio_inbox.sms?
{
from_zip_code: params[:FromZip],
from_country: params[:FromCountry],
from_state: params[:FromState]
}
else
{}
end
end
def attach_files
return if params[:MediaUrl0].blank?
file_resource = LocalResource.new(params[:MediaUrl0], params[:MediaContentType0])
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(params[:MediaContentType0])
)
attachment.file.attach(
io: file_resource.file,
filename: file_resource.tmp_filename,
content_type: file_resource.encoding
)
@message.save!
end
end

View file

@ -7,11 +7,7 @@ class Twilio::OutgoingMessageService
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
)
twilio_message = client.messages.create(message_params)
message.update!(source_id: twilio_message.sid)
end
@ -19,6 +15,21 @@ class Twilio::OutgoingMessageService
delegate :conversation, to: :message
delegate :contact, to: :conversation
delegate :contact_inbox, to: :conversation
def message_params
params = {
body: message.content,
from: channel.phone_number,
to: contact_inbox.source_id
}
params[:media_url] = attachments if channel.whatsapp? && message.attachments.present?
params
end
def attachments
message.attachments.map(&:file_url)
end
def inbox
@inbox ||= message.inbox

View file

@ -0,0 +1,5 @@
class AddMediumToTwilioSms < ActiveRecord::Migration[6.0]
def change
add_column :channel_twilio_sms, :medium, :integer, index: true, default: 0
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_04_17_093432) do
ActiveRecord::Schema.define(version: 2020_04_29_082655) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -121,6 +121,7 @@ ActiveRecord::Schema.define(version: 2020_04_17_093432) do
t.integer "account_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.integer "medium", default: 0
t.index ["account_id", "phone_number"], name: "index_channel_twilio_sms_on_account_id_and_phone_number", unique: true
end
@ -287,11 +288,9 @@ ActiveRecord::Schema.define(version: 2020_04_17_093432) do
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end
create_table "tags", id: :serial, force: :cascade do |t|

View file

@ -1,8 +1,9 @@
class LocalResource
attr_reader :uri
def initialize(uri)
def initialize(uri, file_type = nil)
@uri = URI(uri)
@file_type = file_type
end
def file
@ -23,11 +24,12 @@ class LocalResource
io.read.encoding
end
def find_file_type
@file_type ? @file_type.split('/').last : Pathname.new(uri.path).extname
end
def tmp_filename
[
Time.now.to_i.to_s,
Pathname.new(uri.path).extname
]
[Time.now.to_i.to_s, find_file_type].join('.')
end
def tmp_folder

View file

@ -21,7 +21,8 @@ RSpec.describe '/api/v1/accounts/{account.id}/channels/twilio_channel', type: :r
account_sid: 'sid',
auth_token: 'token',
phone_number: '+1234567890',
name: 'SMS Channel'
name: 'SMS Channel',
medium: 'sms'
}
}
end

View file

@ -3,6 +3,7 @@ FactoryBot.define do
auth_token { SecureRandom.uuid }
account_sid { SecureRandom.uuid }
sequence(:phone_number) { |n| "+123456789#{n}1" }
medium { :sms }
inbox
account
end

View file

@ -10,7 +10,9 @@ describe Twilio::OutgoingMessageService do
let!(:account) { create(:account) }
let!(:widget_inbox) { create(:inbox, account: account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, 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) }
@ -55,5 +57,18 @@ describe Twilio::OutgoingMessageService do
expect(outgoing_message.reload.source_id).to eq('1234')
end
end
it 'if outgoing message has attachment and is for whatsapp' do
# check for message attachment url
allow(messages_double).to receive(:create).with(hash_including(media_url: [anything])).and_return(message_record_double)
allow(message_record_double).to receive(:sid).and_return('1234')
message = build(
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: conversation
)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
message.save!
end
end
end