feat: add Whatsapp embedded signup API
This commit is contained in:
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
class Whatsapp::EmbeddedController < ApplicationController
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
|
||||
def new
|
||||
# Configuration endpoint for embedded signup initialization
|
||||
render json: {
|
||||
status: 'ready',
|
||||
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
partner_id: GlobalConfigService.load('WHATSAPP_PARTNER_ID', ''),
|
||||
graph_api_version: GlobalConfigService.load('WHATSAPP_GRAPH_API_VERSION', 'v21.0')
|
||||
}
|
||||
end
|
||||
|
||||
def embedded_signup
|
||||
# Complete embedded signup using auth code + business info from frontend
|
||||
validate_authorization_code!
|
||||
return if performed?
|
||||
|
||||
validate_required_parameters!
|
||||
return if performed?
|
||||
|
||||
channel = process_signup
|
||||
@inbox = channel.inbox
|
||||
|
||||
# Return the inbox object using the standard jbuilder template
|
||||
render 'embedded_signup', formats: [:json]
|
||||
rescue StandardError => e
|
||||
handle_signup_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_authorization_code!
|
||||
return if params[:code].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing authorization code',
|
||||
message: 'Authorization code is required for embedded signup'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def validate_required_parameters!
|
||||
return if params[:business_id].present? && params[:waba_id].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing required parameters: business_id and waba_id are required'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def process_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_id]
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
def handle_signup_error(error)
|
||||
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
render json: {
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Current.account, :update?)
|
||||
end
|
||||
end
|
||||
@@ -222,6 +222,7 @@
|
||||
"DESC": "Start supporting your customers via WhatsApp.",
|
||||
"PROVIDERS": {
|
||||
"LABEL": "API Provider",
|
||||
"WHATSAPP_EMBEDDED": "WhatsApp Business (Embedded Signup)",
|
||||
"TWILIO": "Twilio",
|
||||
"WHATSAPP_CLOUD": "WhatsApp Cloud",
|
||||
"360_DIALOG": "360Dialog"
|
||||
@@ -264,6 +265,28 @@
|
||||
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "Connect your WhatsApp Business Account directly through Meta's secure signup flow.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "One-click setup with no manual configuration required",
|
||||
"SECURE_AUTH": "Secure OAuth-based authentication with Meta",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta...",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account...",
|
||||
"PROCESSING_DESC": "Please wait while we configure your WhatsApp Business Account. This may take a few moments.",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp signup was cancelled",
|
||||
"STEP_AUTH": "Step 1: Authenticating with Meta",
|
||||
"STEP_BUSINESS": "Step 2: Selecting business account",
|
||||
"STEP_CREATING": "Step 3: Creating WhatsApp channel",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"SUCCESS_DESC": "Your WhatsApp Business Account has been successfully connected. Please provide a name for your inbox to complete the setup."
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
|
||||
+3
-77
@@ -1,12 +1,10 @@
|
||||
<script>
|
||||
/* eslint-env browser */
|
||||
/* global FB */
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import router from '../../../../index';
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -37,73 +35,8 @@ export default {
|
||||
phoneNumberId: { required, isNumber },
|
||||
businessAccountId: { required, isNumber },
|
||||
},
|
||||
mounted() {
|
||||
this.initializeFacebookSDK();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async initializeFacebookSDK() {
|
||||
try {
|
||||
await this.loadFBsdk();
|
||||
this.runFBInit();
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
|
||||
}
|
||||
},
|
||||
|
||||
runFBInit() {
|
||||
FB.init({
|
||||
appId: window.chatwootConfig.fbAppId,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig.fbApiVersion,
|
||||
status: true,
|
||||
});
|
||||
window.fbSDKLoaded = true;
|
||||
FB.AppEvents.logPageView();
|
||||
},
|
||||
|
||||
async loadFBsdk() {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
id: 'facebook-jssdk',
|
||||
});
|
||||
},
|
||||
|
||||
fbLoginCallback(response) {
|
||||
console.log('fbLoginCallback', response);
|
||||
if (response.authResponse) {
|
||||
const code = response.authResponse.code;
|
||||
// Handle the code here
|
||||
this.handleWhatsAppCode(code);
|
||||
} else {
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||
}
|
||||
},
|
||||
|
||||
handleWhatsAppCode(code) {
|
||||
// TODO: Implement code handling logic
|
||||
console.log('Received WhatsApp code:', code);
|
||||
},
|
||||
|
||||
async launchWhatsAppSignup() {
|
||||
if (!window.fbSDKLoaded) {
|
||||
await this.initializeFacebookSDK();
|
||||
}
|
||||
|
||||
try {
|
||||
FB.login(this.fbLoginCallback, {
|
||||
config_id: window.chatwootConfig.fbAppId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
|
||||
}
|
||||
},
|
||||
|
||||
async createChannel() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
@@ -146,14 +79,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NextButton
|
||||
type="submit"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
|
||||
@click="launchWhatsAppSignup"
|
||||
/>
|
||||
<!-- <form class="flex flex-col flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||
<form class="flex flex-col flex-wrap mx-0" @submit.prevent="createChannel()">
|
||||
<div class="flex-grow-0 flex-shrink-0">
|
||||
<label :class="{ error: v$.inboxName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
|
||||
@@ -248,5 +174,5 @@ export default {
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form> -->
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -10,10 +11,11 @@ export default {
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
WhatsappEmbeddedSignup,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: 'whatsapp_cloud',
|
||||
provider: 'whatsapp_embedded',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -31,6 +33,9 @@ export default {
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
|
||||
<select v-model="provider">
|
||||
<option value="whatsapp_embedded">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_EMBEDDED') }}
|
||||
</option>
|
||||
<option value="whatsapp_cloud">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
|
||||
</option>
|
||||
@@ -41,7 +46,8 @@ export default {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
|
||||
<WhatsappEmbeddedSignup v-if="provider === 'whatsapp_embedded'" />
|
||||
<Twilio v-else-if="provider === 'twilio'" type="whatsapp" />
|
||||
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
|
||||
<CloudWhatsapp v-else />
|
||||
</div>
|
||||
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import router from '../../../../index';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fbSdkLoaded: false,
|
||||
isProcessing: false,
|
||||
processingMessage: '',
|
||||
authCodeReceived: false,
|
||||
currentStep: 'initial', // 'initial', 'auth_received', 'processing', 'completed'
|
||||
authCode: null,
|
||||
businessData: null, // Store business data when received
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
|
||||
isLoading() {
|
||||
return this.isProcessing || this.uiFlags.isCreating;
|
||||
},
|
||||
authHeaders() {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadFacebookSdk();
|
||||
window.addEventListener('message', this.handleSignupMessage);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('message', this.handleSignupMessage);
|
||||
},
|
||||
methods: {
|
||||
loadFacebookSdk() {
|
||||
if (window.FB) {
|
||||
this.fbSdkLoaded = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.fbApiVersion || 'v21.0',
|
||||
});
|
||||
this.fbSdkLoaded = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
},
|
||||
|
||||
launchEmbeddedSignup() {
|
||||
if (!window.FB) {
|
||||
this.loadFacebookSdk();
|
||||
setTimeout(() => this.launchEmbeddedSignup(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStep = 'auth_processing';
|
||||
this.processingMessage = this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
window.FB.login(this.fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
fbLoginCallback(response) {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
this.authCode = response.authResponse.code;
|
||||
this.authCodeReceived = true;
|
||||
this.currentStep = 'auth_received';
|
||||
this.processingMessage = this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (this.businessData) {
|
||||
this.completeSignupFlow(this.businessData);
|
||||
}
|
||||
} else if (response.error) {
|
||||
this.handleSignupError({ error: response.error });
|
||||
} else {
|
||||
this.currentStep = 'initial';
|
||||
this.isProcessing = false;
|
||||
useAlert(this.$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
}
|
||||
},
|
||||
|
||||
handleSignupMessage(event) {
|
||||
// Handle Facebook embedded signup message events
|
||||
if (!event.origin.endsWith('facebook.com')) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
this.handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
},
|
||||
|
||||
async handleEmbeddedSignupData(data) {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessData = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessData) {
|
||||
businessData = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (
|
||||
businessData &&
|
||||
(businessData.business_id || businessData.businessId) &&
|
||||
(businessData.waba_id || businessData.wabaId)
|
||||
) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id: businessData.business_id || businessData.businessId,
|
||||
waba_id: businessData.waba_id || businessData.wabaId,
|
||||
phone_number_id:
|
||||
businessData.phone_number_id ||
|
||||
businessData.phoneNumberId ||
|
||||
businessData.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
this.businessData = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (this.authCodeReceived && this.authCode) {
|
||||
await this.completeSignupFlow(normalizedData);
|
||||
} else {
|
||||
this.currentStep = 'waiting_for_auth';
|
||||
this.processingMessage = 'Waiting for authentication...';
|
||||
}
|
||||
} else {
|
||||
this.handleSignupError({
|
||||
error:
|
||||
'Invalid business data received from Facebook. Please try again.',
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
this.handleSignupCancellation(data);
|
||||
} else if (data.event === 'error') {
|
||||
this.handleSignupError({
|
||||
error: data.error_message || 'Signup error occurred',
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async completeSignupFlow(businessData) {
|
||||
if (!this.authCodeReceived || !this.authCode) {
|
||||
this.handleSignupError({
|
||||
error: 'Authentication not completed. Please restart the process.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStep = 'processing';
|
||||
this.isProcessing = true;
|
||||
this.processingMessage = this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send both auth code and business info together (synchronous flow)
|
||||
const accountId = this.$store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/embedded_signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...this.authHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
code: this.authCode,
|
||||
business_id: businessData.business_id,
|
||||
waba_id: businessData.waba_id,
|
||||
phone_number_id: businessData.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
this.authCode = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
this.handleSignupSuccess(responseData);
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleSignupError({ error: error.message });
|
||||
}
|
||||
},
|
||||
|
||||
handleSignupCancellation(data) {
|
||||
this.currentStep = 'initial';
|
||||
this.isProcessing = false;
|
||||
this.authCodeReceived = false;
|
||||
|
||||
let message = this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'
|
||||
);
|
||||
if (data.data?.current_step) {
|
||||
message += ` (Step: ${data.data.current_step})`;
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
},
|
||||
|
||||
handleSignupSuccess(inboxData) {
|
||||
this.currentStep = 'completed';
|
||||
this.isProcessing = false;
|
||||
|
||||
// Update the store with the new inbox data
|
||||
if (inboxData && inboxData.id) {
|
||||
// Add the new inbox to the store
|
||||
this.$store.commit('inboxes/ADD_INBOXES', inboxData);
|
||||
|
||||
useAlert(this.$t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Fallback if inbox data is not properly formatted
|
||||
useAlert('WhatsApp Business Account has been successfully configured');
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleSignupError(data) {
|
||||
this.currentStep = 'initial';
|
||||
this.isProcessing = false;
|
||||
this.authCodeReceived = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
this.$t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
},
|
||||
|
||||
resetSignupFlow() {
|
||||
this.currentStep = 'initial';
|
||||
this.isProcessing = false;
|
||||
this.authCodeReceived = false;
|
||||
this.processingMessage = '';
|
||||
this.authCode = null;
|
||||
this.businessData = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<!-- Processing State -->
|
||||
<div v-if="isProcessing" class="text-center py-8">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{{ processingMessage }}
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING_DESC') }}
|
||||
</p>
|
||||
|
||||
<!-- Show current step for better UX -->
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<span v-if="currentStep === 'auth_processing'">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_AUTH') }}
|
||||
</span>
|
||||
<span v-else-if="currentStep === 'auth_received'">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_BUSINESS') }}
|
||||
</span>
|
||||
<span v-else-if="currentStep === 'processing'">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STEP_CREATING') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initial Setup State -->
|
||||
<div v-else class="space-y-6">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-blue-900 mb-3">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-blue-800 mb-4">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<h4 class="font-medium text-blue-900">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.TITLE') }}
|
||||
</h4>
|
||||
<ul class="space-y-1 text-blue-800">
|
||||
<li class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-600 rounded-full mr-3" />
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-600 rounded-full mr-3" />
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="w-2 h-2 bg-blue-600 rounded-full mr-3" />
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'
|
||||
)
|
||||
}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<NextButton
|
||||
:disabled="!fbSdkLoaded"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON')"
|
||||
@click="launchEmbeddedSignup"
|
||||
/>
|
||||
|
||||
<p v-if="!fbSdkLoaded" class="text-sm text-gray-500 mt-2">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@
|
||||
# allow_messages_after_resolved :boolean default(TRUE)
|
||||
# auto_assignment_config :jsonb
|
||||
# business_name :string
|
||||
# channel_attributes :jsonb
|
||||
# channel_type :string
|
||||
# csat_config :jsonb not null
|
||||
# csat_survey_enabled :boolean default(FALSE)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
# Validate required parameters
|
||||
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
|
||||
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
|
||||
end
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = exchange_code_for_token
|
||||
|
||||
# Use the provided business info directly (more efficient)
|
||||
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
|
||||
|
||||
# Validate that the token has access to the provided WABA (security check)
|
||||
validate_token_waba_access(access_token, @waba_id)
|
||||
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
create_or_update_channel(waba_info, phone_info, access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def exchange_code_for_token
|
||||
response = Faraday.get(
|
||||
'https://graph.facebook.com/v21.0/oauth/access_token',
|
||||
{
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: @code
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token exchange failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
raise "No access token in response: #{data}" unless data['access_token']
|
||||
|
||||
data['access_token']
|
||||
end
|
||||
|
||||
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
|
||||
# Get all phone numbers for the WABA
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/v21.0/#{waba_id}/phone_numbers",
|
||||
{ access_token: access_token }
|
||||
)
|
||||
|
||||
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
phone_numbers = data['data']
|
||||
|
||||
# Find the specific phone number we're looking for
|
||||
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id }
|
||||
|
||||
phone_data = phone_numbers.first if phone_data.nil?
|
||||
|
||||
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
|
||||
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: phone_data['display_phone_number'],
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def create_or_update_channel(waba_info, phone_info, access_token)
|
||||
existing_channel = find_existing_channel(phone_info[:phone_number])
|
||||
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
|
||||
|
||||
if existing_channel
|
||||
update_existing_channel(existing_channel, channel_attributes, waba_info, phone_info)
|
||||
else
|
||||
create_new_channel(channel_attributes, waba_info, phone_info)
|
||||
end
|
||||
end
|
||||
|
||||
def find_existing_channel(phone_number)
|
||||
Channel::Whatsapp.find_by(
|
||||
account: @account,
|
||||
phone_number: phone_number
|
||||
)
|
||||
end
|
||||
|
||||
def build_channel_attributes(waba_info, phone_info, access_token)
|
||||
{
|
||||
phone_number: phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
api_key: access_token,
|
||||
phone_number_id: phone_info[:phone_number_id],
|
||||
business_account_id: waba_info[:waba_id]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def update_existing_channel(channel, attributes, waba_info, phone_info)
|
||||
channel.update!(attributes)
|
||||
ensure_channel_has_inbox(channel, waba_info, phone_info)
|
||||
channel
|
||||
end
|
||||
|
||||
def create_new_channel(attributes, waba_info, phone_info)
|
||||
channel = Channel::Whatsapp.create!(
|
||||
account: @account,
|
||||
**attributes
|
||||
)
|
||||
|
||||
create_inbox_for_channel(channel, waba_info, phone_info)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
|
||||
def ensure_channel_has_inbox(channel, waba_info, phone_info)
|
||||
return if channel.inbox
|
||||
|
||||
inbox_name = generate_inbox_name(waba_info, phone_info)
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
channel.reload
|
||||
end
|
||||
|
||||
def create_inbox_for_channel(channel, waba_info, phone_info)
|
||||
inbox_name = generate_inbox_name(waba_info, phone_info)
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def generate_inbox_name(waba_info, phone_info)
|
||||
business_name = waba_info[:business_name] || phone_info[:business_name]
|
||||
|
||||
if business_name.present?
|
||||
"#{business_name} WhatsApp"
|
||||
else
|
||||
"WhatsApp (#{phone_info[:phone_number]})"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_token_waba_access(access_token, waba_id)
|
||||
response = Faraday.get(
|
||||
'https://graph.facebook.com/v21.0/debug_token',
|
||||
{
|
||||
input_token: access_token,
|
||||
access_token: "#{GlobalConfigService.load('WHATSAPP_APP_ID', '')}|#{GlobalConfigService.load('WHATSAPP_APP_SECRET', '')}"
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token validation failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
granular_scopes = data.dig('data', 'granular_scopes')
|
||||
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,8 @@
|
||||
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
||||
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
||||
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
||||
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
|
||||
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
|
||||
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
|
||||
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
|
||||
<% if @global_config['IS_ENTERPRISE'] %>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -126,6 +126,26 @@
|
||||
type: boolean
|
||||
# ------- End of Facebook Channel Related Config ------- #
|
||||
|
||||
# ------- WhatsApp Channel Related Config ------- #
|
||||
- name: WHATSAPP_APP_ID
|
||||
display_title: 'WhatsApp App ID'
|
||||
description: 'The Facebook App ID for WhatsApp Business API integration'
|
||||
locked: false
|
||||
- name: WHATSAPP_CONFIGURATION_ID
|
||||
display_title: 'WhatsApp Configuration ID'
|
||||
description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
|
||||
locked: false
|
||||
- name: WHATSAPP_PARTNER_ID
|
||||
display_title: 'WhatsApp Partner ID'
|
||||
description: 'Your Partner ID for WhatsApp Business (required for Solution Partners)'
|
||||
locked: false
|
||||
- name: WHATSAPP_GRAPH_API_VERSION
|
||||
display_title: 'WhatsApp Graph API Version'
|
||||
description: 'The Graph API version to use for WhatsApp Business API'
|
||||
value: 'v21.0'
|
||||
locked: false
|
||||
# ------- End of WhatsApp Channel Related Config ------- #
|
||||
|
||||
# MARK: Microsoft Email Channel Config
|
||||
- name: AZURE_APP_ID
|
||||
display_title: 'Azure App ID'
|
||||
|
||||
@@ -468,6 +468,12 @@ Rails.application.routes.draw do
|
||||
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
|
||||
post 'webhooks/instagram', to: 'webhooks/instagram#events'
|
||||
|
||||
namespace :whatsapp do
|
||||
get 'signup', to: 'embedded#new'
|
||||
get 'signup/callback', to: 'embedded#callback'
|
||||
post 'embedded_signup', to: 'embedded#embedded_signup'
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddChannelAttributesToInboxes < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :inboxes, :channel_attributes, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_05_26_000001) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -730,6 +730,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
|
||||
t.integer "sender_name_type", default: 0, null: false
|
||||
t.string "business_name"
|
||||
t.jsonb "csat_config", default: {}, null: false
|
||||
t.jsonb "channel_attributes", default: {}
|
||||
t.index ["account_id"], name: "index_inboxes_on_account_id"
|
||||
t.index ["channel_id", "channel_type"], name: "index_inboxes_on_channel_id_and_channel_type"
|
||||
t.index ["portal_id"], name: "index_inboxes_on_portal_id"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Embedded API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /whatsapp/signup' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_PARTNER_ID', '').and_return('test_partner_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_GRAPH_API_VERSION', 'v21.0').and_return('v21.0')
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
it 'returns configuration for embedded signup' do
|
||||
get '/whatsapp/signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { account_id: account.id }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response).to include(
|
||||
'status' => 'ready',
|
||||
'app_id' => 'test_app_id',
|
||||
'config_id' => 'test_config_id',
|
||||
'partner_id' => 'test_partner_id',
|
||||
'graph_api_version' => 'v21.0'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get '/whatsapp/signup', params: { account_id: account.id }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /whatsapp/embedded_signup' do
|
||||
let(:params) do
|
||||
{
|
||||
account_id: account.id,
|
||||
code: 'auth_code_123',
|
||||
business_id: '123456789',
|
||||
waba_id: '987654321',
|
||||
phone_number_id: '555444333'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
context 'with missing authorization code' do
|
||||
it 'returns bad request error' do
|
||||
params_without_code = params.except(:code)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_code
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Missing authorization code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing business parameters' do
|
||||
it 'returns bad request when business_id is missing' do
|
||||
params_without_business = params.except(:business_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_business
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
|
||||
it 'returns bad request when waba_id is missing' do
|
||||
params_without_waba = params.except(:waba_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_waba
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post '/whatsapp/embedded_signup', params: params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user