feat: Support for Whatsapp Cloud API (#4938)

Ability to configure Whatsapp Cloud API Inboxes

fixes: #4712
This commit is contained in:
Sojan Jose 2022-07-06 21:45:03 +02:00 committed by GitHub
parent 4375a7646e
commit a6c609f43d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 999 additions and 229 deletions

View file

@ -0,0 +1,20 @@
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
# This concern handles the token verification step.
module MetaTokenVerifyConcern
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
Rails.logger.info("#{service.capitalize} webhook verified")
render json: params['hub.challenge']
else
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
end
end
private
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
end

View file

@ -1,15 +1,5 @@
class Webhooks::InstagramController < ApplicationController
skip_before_action :authenticate_user!, raise: false
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern
def events
Rails.logger.info('Instagram webhook received events')
@ -24,7 +14,7 @@ class Webhooks::InstagramController < ApplicationController
private
def valid_instagram_token?(token)
def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end
end

View file

@ -1,6 +1,16 @@
class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
private
def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
end

View file

@ -197,6 +197,7 @@
"PROVIDERS": {
"LABEL": "API Provider",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"360_DIALOG": "360Dialog"
},
"INBOX_NAME": {
@ -209,12 +210,31 @@
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please enter a valid value. Phone number should start with `+` sign."
},
"PHONE_NUMBER_ID": {
"LABEL": "Phone number ID",
"PLACEHOLDER": "Please enter the Phone number ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"BUSINESS_ACCOUNT_ID": {
"LABEL": "Business Account ID",
"PLACEHOLDER": "Please enter the Business Account ID obtained from Facebook developer dashboard.",
"ERROR": "Please enter a valid value."
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
"LABEL": "API key",
"SUBTITLE": "Configure the WhatsApp API key.",
"PLACEHOLDER": "API key",
"ERROR": "Please enter a valid value."
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@ -424,7 +444,7 @@
"FORWARD_EMAIL_SUB_TEXT": "Start forwarding your emails to the following email address.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used in the integration with the 360Dialog WhatsApp channel.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key"
},
"AUTO_ASSIGNMENT":{

View file

@ -19,6 +19,13 @@
:script="currentInbox.callback_webhook_url"
/>
</div>
<div class="medium-6 small-offset-3">
<woot-code
v-if="isAWhatsappWhatsappCloudInbox"
lang="html"
:script="currentInbox.callback_webhook_url"
/>
</div>
<div class="medium-6 small-offset-3">
<woot-code
v-if="isALineInbox"
@ -92,6 +99,12 @@ export default {
isASmsInbox() {
return this.currentInbox.channel_type === 'Channel::Sms';
},
isAWhatsappWhatsappCloudInbox() {
return (
this.currentInbox.channel_type === 'Channel::Whatsapp' &&
this.currentInbox.provider === 'whatsapp_cloud'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@ -111,6 +124,12 @@ export default {
)}`;
}
if (this.isAWhatsappWhatsappCloudInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isAEmailInbox) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}

View file

@ -0,0 +1,186 @@
<template>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
v-model.trim="inboxName"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
@blur="$v.inboxName.$touch"
/>
<span v-if="$v.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
@blur="$v.phoneNumber.$touch"
/>
<span v-if="$v.phoneNumber.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.phoneNumberId.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER_ID.LABEL') }}
</span>
<input
v-model.trim="phoneNumberId"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER_ID.PLACEHOLDER')
"
@blur="$v.phoneNumberId.$touch"
/>
<span v-if="$v.phoneNumberId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER_ID.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.businessAccountId.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BUSINESS_ACCOUNT_ID.LABEL') }}
</span>
<input
v-model.trim="businessAccountId"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.BUSINESS_ACCOUNT_ID.PLACEHOLDER')
"
@blur="$v.businessAccountId.$touch"
/>
<span v-if="$v.businessAccountId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BUSINESS_ACCOUNT_ID.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.apiKey.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
</span>
<input
v-model.trim="apiKey"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.PLACEHOLDER')"
@blur="$v.apiKey.$touch"
/>
<span v-if="$v.apiKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.ERROR') }}
</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.webhookVerifyToken.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WEBHOOK_VERIFY_TOKEN.LABEL') }}
</span>
<input
v-model.trim="webhookVerifyToken"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.WEBHOOK_VERIFY_TOKEN.PLACEHOLDER')
"
@blur="$v.webhookVerifyToken.$touch"
/>
<span v-if="$v.webhookVerifyToken.$error" class="message">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WEBHOOK_VERIFY_TOKEN.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
export default {
mixins: [alertMixin],
data() {
return {
inboxName: '',
phoneNumber: '',
apiKey: '',
phoneNumberId: '',
businessAccountId: '',
webhookVerifyToken: '',
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
validations: {
inboxName: { required },
phoneNumber: { required, shouldStartWithPlusSign },
apiKey: { required },
phoneNumberId: { required },
businessAccountId: { required },
webhookVerifyToken: { required },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const whatsappChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.inboxName,
channel: {
type: 'whatsapp',
phone_number: this.phoneNumber,
provider: 'whatsapp_cloud',
provider_config: {
api_key: this.apiKey,
phone_number_id: this.phoneNumberId,
business_account_id: this.businessAccountId,
webhook_verify_token: this.webhookVerifyToken,
},
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: whatsappChannel.id,
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE'));
}
},
},
};
</script>

View file

@ -8,6 +8,9 @@
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
@ -19,7 +22,8 @@
</div>
<twilio v-if="provider === 'twilio'" type="whatsapp" />
<three-sixty-dialog-whatsapp v-else />
<three-sixty-dialog-whatsapp v-else-if="provider === '360dialog'" />
<cloud-whatsapp v-else />
</div>
</template>
@ -27,16 +31,18 @@
import PageHeader from '../../SettingsSubPageHeader';
import Twilio from './Twilio';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp';
import CloudWhatsapp from './CloudWhatsapp';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
},
data() {
return {
provider: 'twilio',
provider: 'whatsapp_cloud',
};
},
};

View file

@ -2,12 +2,39 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
queue_as :default
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params) || find_channel(params)
return if channel.blank?
case channel.provider
when 'whatsapp_cloud'
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
else
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
end
end
private
def find_channel(params)
return unless params[:phone_number]
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
return unless channel
Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
# TODO: pass to appropriate provider service from here
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params['whatsapp'].with_indifferent_access).perform
def find_channel_from_whatsapp_business_payload(params)
# for the case where facebook cloud api support multiple numbers for a single app
# https://github.com/chatwoot/chatwoot/issues/4712#issuecomment-1173838350
# we will give priority to the phone_number in the payload
return unless params[:object] == 'whatsapp_business_account'
get_channel_from_wb_payload(params)
end
def get_channel_from_wb_payload(wb_params)
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
# validate to ensure the phone number id matches the whatsapp channel
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end

View file

@ -21,135 +21,42 @@ class Channel::Whatsapp < ApplicationRecord
include Channelable
self.table_name = 'channel_whatsapp'
EDITABLE_ATTRS = [:phone_number, { provider_config: {} }].freeze
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
# default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud].freeze
validates :provider, inclusion: { in: PROVIDERS }
validates :phone_number, presence: true, uniqueness: true
before_save :validate_provider_config
validate :validate_provider_config
after_create :sync_templates
def name
'Whatsapp'
end
# all this should happen in provider service . but hack mode on
def api_base_path
# provide the environment variable when testing against sandbox : 'https://waba-sandbox.360dialog.io/v1'
ENV.fetch('360DIALOG_BASE_URL', 'https://waba.360dialog.io/v1')
end
# Extract later into provider Service
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
else
send_text_message(phone_number, message)
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end
end
def send_template(phone_number, template_info)
send_template_message(phone_number, template_info)
end
def media_url(media_id)
"#{api_base_path}/media/#{media_id}"
end
def api_headers
{ 'D360-API-KEY' => provider_config['api_key'], 'Content-Type' => 'application/json' }
end
def messaging_window_enabled?
true
end
def sync_templates
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
end
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
private
def send_text_message(phone_number, message)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
to: phone_number,
text: { body: message.content },
type: 'text'
}.to_json
)
process_response(response)
end
def send_attachment_message(phone_number, message)
attachment = message.attachments.first
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
attachment_url = attachment.download_url
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
'to' => phone_number,
'type' => type,
type.to_s => {
'link': attachment_url,
'caption': message.content
}
}.to_json
)
process_response(response)
end
def send_template_message(phone_number, template_info)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
to: phone_number,
template: template_body_parameters(template_info),
type: 'template'
}.to_json
)
process_response(response)
end
def process_response(response)
if response.success?
response['messages'].first['id']
else
Rails.logger.error response.body
nil
end
end
def template_body_parameters(template_info)
{
name: template_info[:name],
namespace: template_info[:namespace],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
}
end
# Extract later into provider Service
def validate_provider_config
response = HTTParty.post(
"#{api_base_path}/configs/webhook",
headers: { 'D360-API-KEY': provider_config['api_key'], 'Content-Type': 'application/json' },
body: {
url: "#{ENV['FRONTEND_URL']}/webhooks/whatsapp/#{phone_number}"
}.to_json
)
errors.add(:provider_config, 'error setting up the webook') unless response.success?
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
end

View file

@ -120,6 +120,8 @@ class Inbox < ApplicationRecord
"#{ENV['FRONTEND_URL']}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV['FRONTEND_URL']}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV['FRONTEND_URL']}/webhooks/whatsapp/#{channel.phone_number}"
end
end

View file

@ -0,0 +1,110 @@
# Mostly modeled after the intial implementation of the service based on 360 Dialog
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageBaseService
pattr_initialize [:inbox!, :params!]
def perform
processed_params
set_contact
return unless @contact
set_conversation
return if @processed_params[:messages].blank?
@message = @conversation.messages.build(
content: message_content(@processed_params[:messages].first),
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: @processed_params[:messages].first[:id].to_s
)
attach_files
@message.save!
end
private
def processed_params
@processed_params ||= params
end
def message_content(message)
# TODO: map interactive messages back to button messages in chatwoot
message.dig(:text, :body) ||
message.dig(:button, :text) ||
message.dig(:interactive, :button_reply, :title) ||
message.dig(:interactive, :list_reply, :title)
end
def account
@account ||= inbox.account
end
def set_contact
contact_params = @processed_params[:contacts]&.first
return if contact_params.blank?
contact_inbox = ::ContactBuilder.new(
source_id: contact_params[:wa_id],
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" }
).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.last
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def file_content_type(file_type)
return :image if %w[image sticker].include?(file_type)
return :audio if %w[audio voice].include?(file_type)
return :video if ['video'].include?(file_type)
:file
end
def message_type
@processed_params[:messages].first[:type]
end
def attach_files
return if %w[text button interactive].include?(message_type)
attachment_payload = @processed_params[:messages].first[message_type.to_sym]
attachment_file = download_attachment_file(attachment_payload)
@message.content ||= attachment_payload[:caption]
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def download_attachment_file(attachment_payload)
Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
end
end

View file

@ -1,100 +1,5 @@
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageService
pattr_initialize [:inbox!, :params!]
def perform
set_contact
return unless @contact
set_conversation
return if params[:messages].blank?
@message = @conversation.messages.build(
content: message_content(params[:messages].first),
account_id: @inbox.account_id,
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: params[:messages].first[:id].to_s
)
attach_files
@message.save!
end
private
def message_content(message)
# TODO: map interactive messages back to button messages in chatwoot
message.dig(:text, :body) ||
message.dig(:button, :text) ||
message.dig(:interactive, :button_reply, :title) ||
message.dig(:interactive, :list_reply, :title)
end
def account
@account ||= inbox.account
end
def set_contact
contact_params = params[:contacts]&.first
return if contact_params.blank?
contact_inbox = ::ContactBuilder.new(
source_id: contact_params[:wa_id],
inbox: inbox,
contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{params[:messages].first[:from]}" }
).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.last
return if @conversation
@conversation = ::Conversation.create!(conversation_params)
end
def file_content_type(file_type)
return :image if %w[image sticker].include?(file_type)
return :audio if %w[audio voice].include?(file_type)
return :video if ['video'].include?(file_type)
:file
end
def attach_files
return if %w[text button interactive].include?(message_type)
attachment_payload = params[:messages].first[message_type.to_sym]
attachment_file = Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
@message.content ||= attachment_payload[:caption]
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def message_type
params[:messages].first[:type]
end
class Whatsapp::IncomingMessageService < Whatsapp::IncomingMessageBaseService
end

View file

@ -0,0 +1,15 @@
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
# https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageBaseService
private
def processed_params
@processed_params ||= params[:entry].first['changes'].first['value']
end
def download_attachment_file(attachment_payload)
url_response = HTTParty.get(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers)
end
end

View file

@ -0,0 +1,29 @@
#######################################
# To create a whatsapp provider
# - Inherit this as the base class.
# - Implement `send_message` method in your child class.
# - Implement `send_template_message` method in your child class.
# - Implement `sync_templates` method in your child class.
# - Implement `validate_provider_config` method in your child class.
# - Use Childclass.new(whatsapp_channel: channel).perform.
######################################
class Whatsapp::Providers::BaseService
pattr_initialize [:whatsapp_channel!]
def send_message(_phone_number, _message)
raise 'Overwrite this method in child class'
end
def send_template(_phone_number, _template_info)
raise 'Overwrite this method in child class'
end
def sync_template
raise 'Overwrite this method in child class'
end
def validate_provider_config
raise 'Overwrite this method in child class'
end
end

View file

@ -0,0 +1,112 @@
class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseService
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
else
send_text_message(phone_number, message)
end
end
def send_template(phone_number, template_info)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
to: phone_number,
template: template_body_parameters(template_info),
type: 'template'
}.to_json
)
process_response(response)
end
def sync_templates
response = HTTParty.get("#{api_base_path}/configs/templates", headers: api_headers)
whatsapp_channel.update(message_templates: response['waba_templates'], message_templates_last_updated: Time.now.utc) if response.success?
end
def validate_provider_config?
response = HTTParty.post(
"#{api_base_path}/configs/webhook",
headers: { 'D360-API-KEY': whatsapp_channel.provider_config['api_key'], 'Content-Type': 'application/json' },
body: {
url: "#{ENV['FRONTEND_URL']}/webhooks/whatsapp/#{whatsapp_channel.phone_number}"
}.to_json
)
response.success?
end
def api_headers
{ 'D360-API-KEY' => whatsapp_channel.provider_config['api_key'], 'Content-Type' => 'application/json' }
end
def media_url(media_id)
"#{api_base_path}/media/#{media_id}"
end
private
def api_base_path
# provide the environment variable when testing against sandbox : 'https://waba-sandbox.360dialog.io/v1'
ENV.fetch('360DIALOG_BASE_URL', 'https://waba.360dialog.io/v1')
end
def send_text_message(phone_number, message)
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
to: phone_number,
text: { body: message.content },
type: 'text'
}.to_json
)
process_response(response)
end
def send_attachment_message(phone_number, message)
attachment = message.attachments.first
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
attachment_url = attachment.download_url
response = HTTParty.post(
"#{api_base_path}/messages",
headers: api_headers,
body: {
'to' => phone_number,
'type' => type,
type.to_s => {
'link': attachment_url,
'caption': message.content
}
}.to_json
)
process_response(response)
end
def process_response(response)
if response.success?
response['messages'].first['id']
else
Rails.logger.error response.body
nil
end
end
def template_body_parameters(template_info)
{
name: template_info[:name],
namespace: template_info[:namespace],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
}
end
end

View file

@ -0,0 +1,112 @@
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
def send_message(phone_number, message)
if message.attachments.present?
send_attachment_message(phone_number, message)
else
send_text_message(phone_number, message)
end
end
def send_template(phone_number, template_info)
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
to: phone_number,
template: template_body_parameters(template_info),
type: 'template'
}.to_json
)
process_response(response)
end
def sync_templates
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
whatsapp_channel.update(message_templates: response['data'], message_templates_last_updated: Time.now.utc) if response.success?
end
def validate_provider_config?
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
response.success?
end
def api_headers
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def media_url(media_id)
"https://graph.facebook.com/v13.0/#{media_id}"
end
private
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
def phone_id_path
"https://graph.facebook.com/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def business_account_path
"https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}"
end
def send_text_message(phone_number, message)
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
to: phone_number,
text: { body: message.content },
type: 'text'
}.to_json
)
process_response(response)
end
def send_attachment_message(phone_number, message)
attachment = message.attachments.first
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
attachment_url = attachment.download_url
response = HTTParty.post(
"#{phone_id_path}/messages",
headers: api_headers,
body: {
messaging_product: 'whatsapp',
'to' => phone_number,
'type' => type,
type.to_s => {
'link': attachment_url,
'caption': message.content
}
}.to_json
)
process_response(response)
end
def process_response(response)
if response.success?
response['messages'].first['id']
else
Rails.logger.error response.body
nil
end
end
def template_body_parameters(template_info)
{
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [{
type: 'body',
parameters: template_info[:parameters]
}]
}
end
end

View file

@ -43,7 +43,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates.each do |template|
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?

View file

@ -83,6 +83,7 @@ end
### WhatsApp Channel
if resource.whatsapp?
json.provider resource.channel.try(:provider)
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
end

View file

@ -285,8 +285,9 @@ Rails.application.routes.draw do
post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events'
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/whatsapp/:phone_number', to: 'webhooks/whatsapp#verify'
post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload'
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'

View file

@ -41,6 +41,25 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(1)
end
context 'when provider_config' do
let(:inbox) { create(:channel_whatsapp, account: account, sync_templates: false, validate_provider_config: false).inbox }
it 'returns provider config attributes for admin' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(JSON.parse(response.body)['payload'].last.key?('provider_config')).to eq(true)
end
it 'will not return provider config for agent' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token,
as: :json
expect(JSON.parse(response.body)['payload'].last.key?('provider_config')).to eq(false)
end
end
end
end

View file

@ -1,6 +1,27 @@
require 'rails_helper'
RSpec.describe 'Webhooks::InstagramController', type: :request do
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
get '/webhooks/instagram/verify'
expect(response).to have_http_status(:not_found)
end
it 'returns 401 when invalid params' do
with_modified_env IG_VERIFY_TOKEN: '123456' do
get '/webhooks/instagram/verify', params: { 'hub.challenge' => '123456', 'hub.mode' => 'subscribe', 'hub.verify_token' => 'invalid' }
expect(response).to have_http_status(:not_found)
end
end
it 'returns challenge when valid params' do
with_modified_env IG_VERIFY_TOKEN: '123456' do
get '/webhooks/instagram/verify', params: { 'hub.challenge' => '123456', 'hub.mode' => 'subscribe', 'hub.verify_token' => '123456' }
expect(response.body).to include '123456'
end
end
end
describe 'POST /webhooks/instagram' do
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }

View file

@ -1,6 +1,27 @@
require 'rails_helper'
RSpec.describe 'Webhooks::WhatsappController', type: :request do
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
get "/webhooks/whatsapp/#{channel.phone_number}"
expect(response).to have_http_status(:unauthorized)
end
it 'returns 401 when invalid params' do
get "/webhooks/whatsapp/#{channel.phone_number}",
params: { 'hub.challenge' => '123456', 'hub.mode' => 'subscribe', 'hub.verify_token' => 'invalid' }
expect(response).to have_http_status(:unauthorized)
end
it 'returns challenge when valid params' do
get "/webhooks/whatsapp/#{channel.phone_number}",
params: { 'hub.challenge' => '123456', 'hub.mode' => 'subscribe', 'hub.verify_token' => channel.provider_config['webhook_verify_token'] }
expect(response.body).to include '123456'
end
end
describe 'POST /webhooks/whatsapp/{:phone_number}' do
it 'call the whatsapp events job with the params' do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)

View file

@ -36,11 +36,17 @@ FactoryBot.define do
transient do
sync_templates { true }
validate_provider_config { true }
end
before(:create) do |channel_whatsapp, options|
# since factory already has the required message templates, we just need to bypass it getting updated
channel_whatsapp.define_singleton_method(:sync_templates) { return } unless options.sync_templates
channel_whatsapp.define_singleton_method(:validate_provider_config) { return } unless options.validate_provider_config
if channel_whatsapp.provider == 'whatsapp_cloud'
channel_whatsapp.provider_config = { 'api_key' => 'test_key', 'phone_number_id' => '123456789', 'business_account_id' => '123456789',
'webhook_verify_token': 'test_token' }
end
end
after(:create) do |channel_whatsapp|

View file

@ -0,0 +1,91 @@
require 'rails_helper'
RSpec.describe Webhooks::WhatsappEventsJob, type: :job do
subject(:job) { described_class }
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:params) { { phone_number: channel.phone_number } }
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'enqueues the job' do
expect { job.perform_later(params) }.to have_enqueued_job(described_class)
.with(params)
.on_queue('default')
end
context 'when whatsapp_cloud provider' do
it 'enques Whatsapp::IncomingMessageWhatsappCloudService' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
job.perform_now(params)
end
end
context 'when default provider' do
it 'enques Whatsapp::IncomingMessageService' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
channel.update(provider: 'default')
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageService).to receive(:new)
job.perform_now(params)
end
end
context 'when whatsapp business params' do
it 'enques Whatsapp::IncomingMessageWhatsappCloudService based on the number in payload' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [
{
changes: [
{
value: {
metadata: {
phone_number_id: other_channel.provider_config['phone_number_id'],
display_phone_number: other_channel.phone_number.delete('+')
}
}
}
]
}
]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: other_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [
{
changes: [
{
value: {
metadata: {
phone_number_id: 'random phone number id',
display_phone_number: other_channel.phone_number.delete('+')
}
}
}
]
}
]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new).with(inbox: other_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Whatsapp do
describe 'validate_provider_config' do
let(:channel) { build(:channel_whatsapp, provider: 'whatsapp_cloud', account: create(:account)) }
it 'validates false when provider config is wrong' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key').to_return(status: 401)
expect(channel.save).to eq(false)
end
it 'validates true when provider config is right' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
.to_return(status: 200,
body: { data: [{
id: '123456789', name: 'test_template'
}] }.to_json)
expect(channel.save).to eq(true)
end
end
end

View file

@ -0,0 +1 @@
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb

View file

@ -0,0 +1,116 @@
require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:message) { create(:message, message_type: :outgoing, content: 'test', inbox: whatsapp_channel.inbox) }
let(:response_headers) { { 'Content-Type' => 'application/json' } }
let(:whatsapp_response) { { messages: [{ id: 'message_id' }] } }
before do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
end
describe '#send_message' do
context 'when called' do
it 'calls message endpoints for normal messages' do
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: {
messaging_product: 'whatsapp',
to: '+123456789',
text: { body: message.content },
type: 'text'
}.to_json
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for attachment message messages' do
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')
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'image'
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
end
end
describe '#send_template' do
let(:template_info) do
{
name: 'test_template',
namespace: 'test_namespace',
lang_code: 'en_US',
parameters: [{ type: 'text', text: 'test' }]
}
end
let(:template_body) do
{
messaging_product: 'whatsapp',
to: '+123456789',
template: {
name: template_info[:name],
language: {
policy: 'deterministic',
code: template_info[:lang_code]
},
components: [
{ type: 'body',
parameters: template_info[:parameters] }
]
},
type: 'template'
}
end
context 'when called' do
it 'calls message endpoints with template params for template messages' do
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with(
body: template_body.to_json
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_template('+123456789', template_info)).to eq('message_id')
end
end
end
describe '#sync_templates' do
context 'when called' do
it 'updated the message templates' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
.to_return(status: 200, headers: response_headers, body: { data: [{ id: '123456789', name: 'test_template' }] }.to_json)
expect(subject.sync_templates).to eq(true)
expect(whatsapp_channel.reload.message_templates).to eq([{ id: '123456789', name: 'test_template' }.stringify_keys])
end
end
end
describe '#validate_provider_config' do
context 'when called' do
it 'returns true if valid' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
expect(subject.validate_provider_config?).to eq(true)
expect(whatsapp_channel.errors.present?).to eq(false)
end
it 'returns false if invalid' do
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key').to_return(status: 401)
expect(subject.validate_provider_config?).to eq(false)
end
end
end
end