feat: add Whatsapp embedded signup API

This commit is contained in:
Tanmay Deep Sharma
2025-05-28 20:05:57 +05:30
parent 454686ab5d
commit c8a66b9ebb
15 changed files with 832 additions and 80 deletions
+2
View File
@@ -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"
}
@@ -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>
@@ -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>
+1
View File
@@ -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
+2
View File
@@ -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
+20
View File
@@ -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'
+6
View File
@@ -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
View File
@@ -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