feat: Support for Whatsapp Cloud API (#4938)
Ability to configure Whatsapp Cloud API Inboxes fixes: #4712
This commit is contained in:
parent
4375a7646e
commit
a6c609f43d
27 changed files with 999 additions and 229 deletions
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal file
20
app/controllers/concerns/meta_token_verify_concern.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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":{
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
110
app/services/whatsapp/incoming_message_base_service.rb
Normal file
110
app/services/whatsapp/incoming_message_base_service.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
29
app/services/whatsapp/providers/base_service.rb
Normal file
29
app/services/whatsapp/providers/base_service.rb
Normal 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
|
112
app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
Normal file
112
app/services/whatsapp/providers/whatsapp_360_dialog_service.rb
Normal 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
|
112
app/services/whatsapp/providers/whatsapp_cloud_service.rb
Normal file
112
app/services/whatsapp/providers/whatsapp_cloud_service.rb
Normal 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
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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|
|
||||
|
|
91
spec/jobs/webhooks/whatsapp_events_job_spec.rb
Normal file
91
spec/jobs/webhooks/whatsapp_events_job_spec.rb
Normal 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
|
23
spec/models/channel/whatsapp_spec.rb
Normal file
23
spec/models/channel/whatsapp_spec.rb
Normal 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
|
|
@ -0,0 +1 @@
|
|||
## the specs are covered in send in spec/services/whatsapp/send_on_whatsapp_service_spec.rb
|
116
spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
Normal file
116
spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb
Normal 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
|
Loading…
Reference in a new issue