diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb
new file mode 100644
index 000000000..b3f920644
--- /dev/null
+++ b/app/controllers/concerns/meta_token_verify_concern.rb
@@ -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
diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb
index e6fe93566..b658915ed 100644
--- a/app/controllers/webhooks/instagram_controller.rb
+++ b/app/controllers/webhooks/instagram_controller.rb
@@ -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
diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb
index 7560da1e4..8f408d2b0 100644
--- a/app/controllers/webhooks/whatsapp_controller.rb
+++ b/app/controllers/webhooks/whatsapp_controller.rb
@@ -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
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index ed811a6e9..e6ed1e397 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -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":{
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
index e4fda7269..6d0a0a597 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
@@ -19,6 +19,13 @@
:script="currentInbox.callback_webhook_url"
/>
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index dadce2d3b..d43e3feda 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -8,6 +8,9 @@
-
+
+
@@ -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',
};
},
};
diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb
index 315f08907..fabd127a2 100644
--- a/app/jobs/webhooks/whatsapp_events_job.rb
+++ b/app/jobs/webhooks/whatsapp_events_job.rb
@@ -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
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 1f7a14c39..7be40ab33 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -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
diff --git a/app/models/inbox.rb b/app/models/inbox.rb
index bb5c8dba0..d3127b3cf 100644
--- a/app/models/inbox.rb
+++ b/app/models/inbox.rb
@@ -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
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
new file mode 100644
index 000000000..e55d5fd26
--- /dev/null
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -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
diff --git a/app/services/whatsapp/incoming_message_service.rb b/app/services/whatsapp/incoming_message_service.rb
index c96e6e771..f619bb269 100644
--- a/app/services/whatsapp/incoming_message_service.rb
+++ b/app/services/whatsapp/incoming_message_service.rb
@@ -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
diff --git a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb
new file mode 100644
index 000000000..4cd93942f
--- /dev/null
+++ b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb
@@ -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
diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb
new file mode 100644
index 000000000..62401337e
--- /dev/null
+++ b/app/services/whatsapp/providers/base_service.rb
@@ -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
diff --git a/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
new file mode 100644
index 000000000..04f4e8f7f
--- /dev/null
+++ b/app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
@@ -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
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
new file mode 100644
index 000000000..ea8c01791
--- /dev/null
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -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
diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb
index efba78f7e..6b96d1731 100644
--- a/app/services/whatsapp/send_on_whatsapp_service.rb
+++ b/app/services/whatsapp/send_on_whatsapp_service.rb
@@ -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?
diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index 7fada86bd..e63576bdb 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index a6a75c8d8..37e435adb 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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'
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 6cf33e7ad..bea71b6dd 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -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
diff --git a/spec/controllers/webhooks/instagram_controller_spec.rb b/spec/controllers/webhooks/instagram_controller_spec.rb
index fce701587..4011489a4 100644
--- a/spec/controllers/webhooks/instagram_controller_spec.rb
+++ b/spec/controllers/webhooks/instagram_controller_spec.rb
@@ -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 }
diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb
index bb6947862..4a63f5ba0 100644
--- a/spec/controllers/webhooks/whatsapp_controller_spec.rb
+++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb
@@ -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)
diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb
index 3db99b47a..a1cd4bee4 100644
--- a/spec/factories/channel/channel_whatsapp.rb
+++ b/spec/factories/channel/channel_whatsapp.rb
@@ -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|
diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
new file mode 100644
index 000000000..2a28fe41f
--- /dev/null
+++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
@@ -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
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
new file mode 100644
index 000000000..d38f14d11
--- /dev/null
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -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
diff --git a/spec/services/whatsapp/providers/whatsapp_360_dialog_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_360_dialog_service_spec.rb
new file mode 100644
index 000000000..b36a9de56
--- /dev/null
+++ b/spec/services/whatsapp/providers/whatsapp_360_dialog_service_spec.rb
@@ -0,0 +1 @@
+## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
new file mode 100644
index 000000000..cba8176b6
--- /dev/null
+++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
@@ -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