- {{
- isOnline
- ? $t('TEAM_AVAILABILITY.ONLINE')
- : $t('TEAM_AVAILABILITY.OFFLINE')
- }}
+
+ {{ headerMessage }}
{{ replyWaitMessage }}
diff --git a/app/javascript/widget/components/layouts/ViewWithHeader.vue b/app/javascript/widget/components/layouts/ViewWithHeader.vue
index 1b1e1e2fd..70eaf783b 100644
--- a/app/javascript/widget/components/layouts/ViewWithHeader.vue
+++ b/app/javascript/widget/components/layouts/ViewWithHeader.vue
@@ -119,8 +119,10 @@ export default {
>
diff --git a/app/javascript/widget/store/modules/appConfig.js b/app/javascript/widget/store/modules/appConfig.js
index 44a13c4c4..3ad5078b8 100644
--- a/app/javascript/widget/store/modules/appConfig.js
+++ b/app/javascript/widget/store/modules/appConfig.js
@@ -21,6 +21,13 @@ const state = {
widgetStyle: 'standard',
darkMode: 'light',
isUpdatingRoute: false,
+ welcomeTitle: '',
+ welcomeDescription: '',
+ availableMessage: '',
+ unavailableMessage: '',
+ enableFileUpload: true,
+ enableEmojiPicker: true,
+ enableEndConversation: true,
};
export const getters = {
@@ -34,6 +41,13 @@ export const getters = {
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
+ getWelcomeHeading: $state => $state.welcomeTitle,
+ getWelcomeTagline: $state => $state.welcomeDescription,
+ getAvailableMessage: $state => $state.availableMessage,
+ getUnavailableMessage: $state => $state.unavailableMessage,
+ getShouldShowFilePicker: $state => $state.enableFileUpload,
+ getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
+ getCanUserEndConversation: $state => $state.enableEndConversation,
};
export const actions = {
@@ -46,6 +60,13 @@ export const actions = {
showUnreadMessagesDialog,
widgetStyle = 'rounded',
darkMode = 'light',
+ welcomeTitle = '',
+ welcomeDescription = '',
+ availableMessage = '',
+ unavailableMessage = '',
+ enableFileUpload = true,
+ enableEmojiPicker = true,
+ enableEndConversation = true,
}
) {
commit(SET_WIDGET_APP_CONFIG, {
@@ -55,6 +76,13 @@ export const actions = {
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
widgetStyle,
darkMode,
+ welcomeTitle,
+ welcomeDescription,
+ availableMessage,
+ unavailableMessage,
+ enableFileUpload,
+ enableEmojiPicker,
+ enableEndConversation,
});
},
toggleWidgetOpen({ commit }, isWidgetOpen) {
@@ -90,6 +118,13 @@ export const mutations = {
$state.darkMode = data.darkMode;
$state.locale = data.locale || $state.locale;
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
+ $state.welcomeTitle = data.welcomeTitle;
+ $state.welcomeDescription = data.welcomeDescription;
+ $state.availableMessage = data.availableMessage;
+ $state.unavailableMessage = data.unavailableMessage;
+ $state.enableFileUpload = data.enableFileUpload;
+ $state.enableEmojiPicker = data.enableEmojiPicker;
+ $state.enableEndConversation = data.enableEndConversation;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;
diff --git a/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js b/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
index 5d3db77bc..a6020cb84 100644
--- a/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
+++ b/app/javascript/widget/store/modules/specs/appConfig/getters.spec.js
@@ -19,6 +19,48 @@ describe('#getters', () => {
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
+ describe('#getAvailableMessage', () => {
+ it('returns correct value', () => {
+ const state = { availableMessage: 'We reply quickly' };
+ expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
+ });
+ });
+ describe('#getWelcomeHeading', () => {
+ it('returns correct value', () => {
+ const state = { welcomeTitle: 'Hello!' };
+ expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
+ });
+ });
+ describe('#getWelcomeTagline', () => {
+ it('returns correct value', () => {
+ const state = { welcomeDescription: 'Welcome to our site' };
+ expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
+ });
+ });
+ describe('#getShouldShowFilePicker', () => {
+ it('returns correct value', () => {
+ const state = { enableFileUpload: true };
+ expect(getters.getShouldShowFilePicker(state)).toEqual(true);
+ });
+ });
+ describe('#getShouldShowEmojiPicker', () => {
+ it('returns correct value', () => {
+ const state = { enableEmojiPicker: true };
+ expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
+ });
+ });
+ describe('#getCanUserEndConversation', () => {
+ it('returns correct value', () => {
+ const state = { enableEndConversation: true };
+ expect(getters.getCanUserEndConversation(state)).toEqual(true);
+ });
+ });
+ describe('#getUnavailableMessage', () => {
+ it('returns correct value', () => {
+ const state = { unavailableMessage: 'We are offline' };
+ expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
+ });
+ });
describe('#getIsUpdatingRoute', () => {
it('returns correct value', () => {
const state = { isUpdatingRoute: true };
diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb
index cd2dad167..55c0969a2 100644
--- a/app/jobs/webhooks/whatsapp_events_job.rb
+++ b/app/jobs/webhooks/whatsapp_events_job.rb
@@ -5,6 +5,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
channel = find_channel_from_whatsapp_business_payload(params)
if channel_is_inactive?(channel)
+ Rails.logger.info("Channel is inactive: #{channel.inspect}")
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
return
end
diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb
index faa1126f3..9e3016eac 100644
--- a/app/models/channel/web_widget.rb
+++ b/app/models/channel/web_widget.rb
@@ -66,7 +66,6 @@ class Channel::WebWidget < ApplicationRecord
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+\"/packs/js/sdk.js\";
- g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
new file mode 100644
index 000000000..95ecfac2b
--- /dev/null
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -0,0 +1,213 @@
+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
+
+ GlobalConfig.clear_cache
+ # 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 whatsapp_api_version
+ @whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
+ end
+
+ def exchange_code_for_token
+ response = Faraday.get(
+ "https://graph.facebook.com/#{whatsapp_api_version}/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/#{whatsapp_api_version}/#{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']
+ phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
+
+ raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
+
+ display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
+ {
+ phone_number_id: phone_data['id'],
+ phone_number: "+#{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
+ Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
+ raise "Channel already exists: #{existing_channel.phone_number}"
+ else
+ channel = create_new_channel(channel_attributes, phone_info)
+ register_phone_number(phone_info[:phone_number_id], access_token)
+ override_waba_webhook(waba_info[:waba_id], channel, access_token)
+ channel
+ end
+ end
+
+ def register_phone_number(phone_number_id, access_token)
+ HTTParty.post(
+ "https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
+ {
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { messaging_product: 'whatsapp', pin: '212834' }.to_json
+ }
+ )
+ 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],
+ source: 'embedded_signup'
+ }
+ }
+ end
+
+ def create_new_channel(attributes, phone_info)
+ channel = Channel::Whatsapp.create!(account: @account, **attributes)
+ create_inbox_for_channel(channel, phone_info)
+ channel.reload
+ channel
+ end
+
+ def create_inbox_for_channel(channel, phone_info)
+ Inbox.create!(
+ account: @account,
+ name: "#{phone_info[:business_name]} WhatsApp",
+ channel: channel
+ )
+ end
+
+ def sanitize_phone_number(phone_number)
+ return phone_number if phone_number.blank?
+
+ phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
+ end
+
+ def validate_token_waba_access(access_token, waba_id)
+ token_debug_data = fetch_token_debug_data(access_token)
+ waba_scope = extract_waba_scope(token_debug_data)
+ verify_waba_authorization(waba_scope, waba_id)
+ end
+
+ def fetch_token_debug_data(access_token)
+ response = Faraday.get(
+ "https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
+ {
+ input_token: access_token,
+ access_token: build_app_access_token
+ }
+ )
+
+ raise "Token validation failed: #{response.body}" unless response.success?
+
+ JSON.parse(response.body)
+ end
+
+ def extract_waba_scope(token_data)
+ granular_scopes = token_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
+
+ waba_scope
+ end
+
+ def verify_waba_authorization(waba_scope, waba_id)
+ 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
+
+ def build_app_access_token
+ app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
+ app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
+ "#{app_id}|#{app_secret}"
+ end
+
+ def override_waba_webhook(waba_id, channel, access_token)
+ callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
+ verify_token = channel.provider_config['webhook_verify_token']
+
+ response = HTTParty.post(
+ "https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
+ {
+ headers: {
+ 'Authorization' => "Bearer #{access_token}",
+ 'Content-Type' => 'application/json'
+ },
+ body: {
+ override_callback_uri: callback_url,
+ verify_token: verify_token
+ }.to_json
+ }
+ )
+
+ return if response.success?
+
+ Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
+ raise "Webhook override failed: #{response.body}"
+ end
+end
diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb
index 4be41f8b2..8ccac6c48 100644
--- a/app/views/layouts/vueapp.html.erb
+++ b/app/views/layouts/vueapp.html.erb
@@ -39,6 +39,9 @@
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'] %>',
+ whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>
diff --git a/app/views/super_admin/application/_javascript.html.erb b/app/views/super_admin/application/_javascript.html.erb
index a179991ca..d11bc2c97 100644
--- a/app/views/super_admin/application/_javascript.html.erb
+++ b/app/views/super_admin/application/_javascript.html.erb
@@ -29,7 +29,6 @@ window.chatwootSettings = {
var BASE_URL = '';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
- g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
diff --git a/app/views/whatsapp/embedded/embedded_signup.json.jbuilder b/app/views/whatsapp/embedded/embedded_signup.json.jbuilder
new file mode 100644
index 000000000..2ad94ff82
--- /dev/null
+++ b/app/views/whatsapp/embedded/embedded_signup.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
diff --git a/app/views/widget_tests/index.html.erb b/app/views/widget_tests/index.html.erb
index 2809b6944..f08fe0d14 100644
--- a/app/views/widget_tests/index.html.erb
+++ b/app/views/widget_tests/index.html.erb
@@ -30,7 +30,6 @@ window.chatwootSettings = {
var BASE_URL = '';
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src= BASE_URL + "/packs/js/sdk.js";
- g.defer = true;
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 58d593502..b17d3cec0 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -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_APP_SECRET
+ display_title: 'WhatsApp App Secret'
+ description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
+ locked: false
+- name: WHATSAPP_API_VERSION
+ display_title: 'WhatsApp API Version'
+ description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
+ value: 'v22.0'
+ locked: false
+# ------- End of WhatsApp Channel Related Config ------- #
+
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
diff --git a/config/routes.rb b/config/routes.rb
index a977c613e..5f3979462 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -486,6 +486,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
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index e86f66832..f2b2b263c 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -103,6 +103,12 @@ slack:
enabled: true
icon: 'icon-slack'
config_key: 'slack'
+whatsapp_embedded:
+ name: 'WhatsApp Embedded'
+ description: 'Configuration for setting up WhatsApp Embedded Integration'
+ enabled: true
+ icon: 'icon-whatsapp-line'
+ config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
diff --git a/spec/controllers/whatsapp/embedded_controller_spec.rb b/spec/controllers/whatsapp/embedded_controller_spec.rb
new file mode 100644
index 000000000..9d91a6488
--- /dev/null
+++ b/spec/controllers/whatsapp/embedded_controller_spec.rb
@@ -0,0 +1,100 @@
+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_APP_SECRET', '').and_return('test_app_secret')
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.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',
+ 'app_secret' => 'test_app_secret'
+ )
+ 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
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
new file mode 100644
index 000000000..37269e5e1
--- /dev/null
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -0,0 +1,700 @@
+require 'rails_helper'
+
+describe Whatsapp::EmbeddedSignupService do
+ let(:account) { create(:account) }
+ let(:code) { 'test_authorization_code' }
+ let(:business_id) { 'test_business_id' }
+ let(:waba_id) { 'test_waba_id' }
+ let(:phone_number_id) { 'test_phone_number_id' }
+ let(:access_token) { 'test_access_token' }
+ let(:app_id) { 'test_app_id' }
+ let(:app_secret) { 'test_app_secret' }
+ let(:api_version) { 'v22.0' }
+
+ let(:service) do
+ described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ end
+
+ before do
+ # Mock global configuration
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
+ allow(GlobalConfig).to receive(:clear_cache)
+
+ # Mock environment variables - allow any calls to ENV.fetch
+ allow(ENV).to receive(:fetch).and_call_original
+ allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
+ allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
+
+ # Mock ChatwootApp enterprise checks
+ allow(ChatwootApp).to receive(:enterprise?).and_return(false)
+
+ # NOTE: Specific HTTP request stubs are defined in individual test contexts
+ end
+
+ describe '#perform' do
+ context 'when all parameters are valid' do
+ before do
+ # Stub the token exchange
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the phone numbers fetch
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: phone_number_id,
+ display_phone_number: '1234567890',
+ verified_name: 'Test Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the token validation
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: [waba_id]
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the provider validation request (WhatsApp Cloud)
+ stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: { data: [] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the phone number registration
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the webhook subscription
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'successfully creates a new WhatsApp channel' do
+ expect { service.perform }.not_to raise_error
+
+ channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
+ expect(channel).not_to be_nil
+ expect(channel.provider).to eq('whatsapp_cloud')
+ expect(channel.provider_config['api_key']).to eq(access_token)
+ expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
+ expect(channel.provider_config['business_account_id']).to eq(waba_id)
+ expect(channel.provider_config['source']).to eq('embedded_signup')
+ end
+
+ it 'creates an inbox for the channel' do
+ service.perform
+
+ channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
+ inbox = Inbox.find_by(account: account, channel: channel)
+ expect(inbox).not_to be_nil
+ expect(inbox.name).to eq('Test Business WhatsApp')
+ end
+
+ it 'registers the phone number' do
+ service.perform
+
+ expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .with(
+ body: {
+ messaging_product: 'whatsapp',
+ pin: '212834'
+ }.to_json
+ )
+ end
+
+ it 'sets up webhook subscription' do
+ service.perform
+
+ channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
+ callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
+
+ expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ body: hash_including(
+ override_callback_uri: callback_url,
+ verify_token: channel.provider_config['webhook_verify_token']
+ )
+ )
+ end
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises an error when code is missing' do
+ service = described_class.new(
+ account: account,
+ code: '',
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+
+ expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
+ end
+
+ it 'raises an error when business_id is missing' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: '',
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+
+ expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
+ end
+
+ it 'raises an error when waba_id is missing' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: '',
+ phone_number_id: phone_number_id
+ )
+
+ expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
+ end
+
+ it 'raises an error when phone_number_id is missing' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: ''
+ )
+
+ expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
+ end
+ end
+
+ context 'when channel already exists' do
+ before do
+ # Stub all the required requests for successful flow
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: phone_number_id,
+ display_phone_number: '1234567890',
+ verified_name: 'Test Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: [waba_id]
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub 360Dialog provider validation (for existing channel creation)
+ stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
+ .to_return(
+ status: 200,
+ body: { templates: [] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Create existing channel
+ create(:channel_whatsapp, account: account, phone_number: '+1234567890')
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Channel already exists/)
+ end
+ end
+
+ context 'when token exchange fails' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(status: 400, body: { error: 'Invalid code' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Token exchange failed/)
+ end
+ end
+
+ context 'when token has no access to WABA' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: phone_number_id,
+ display_phone_number: '1234567890',
+ verified_name: 'Test Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: ['different_waba_id']
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Token does not have access to WABA/)
+ end
+ end
+
+ context 'when phone numbers fetch fails' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
+ end
+ end
+
+ context 'when webhook override fails' do
+ before do
+ # Stub all the successful requests
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: phone_number_id,
+ display_phone_number: '1234567890',
+ verified_name: 'Test Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: [waba_id]
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: { data: [] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ # Stub the failing webhook request
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Webhook override failed/)
+ end
+ end
+ end
+
+ describe 'private methods' do
+ describe '#exchange_code_for_token' do
+ context 'when token exchange is successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { access_token: access_token }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the access token' do
+ result = service.send(:exchange_code_for_token)
+ expect(result).to eq(access_token)
+ end
+ end
+
+ context 'when response has no access token' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: hash_including(
+ 'client_id' => app_id,
+ 'client_secret' => app_secret,
+ 'code' => code
+ ))
+ .to_return(
+ status: 200,
+ body: { some_other_field: 'value' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error' do
+ expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
+ end
+ end
+ end
+
+ describe '#fetch_phone_info_via_waba' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: phone_number_id,
+ display_phone_number: '1234567890',
+ verified_name: 'Test Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns formatted phone info' do
+ result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
+ expect(result).to eq({
+ phone_number_id: phone_number_id,
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ })
+ end
+
+ context 'when specific phone number is not found' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: {
+ data: [
+ {
+ id: 'different_phone_id',
+ display_phone_number: '9876543210',
+ verified_name: 'Different Business',
+ code_verification_status: 'VERIFIED'
+ }
+ ]
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'uses the first available phone number' do
+ result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
+ expect(result[:phone_number_id]).to eq('different_phone_id')
+ expect(result[:phone_number]).to eq('+9876543210')
+ end
+ end
+
+ context 'when no phone numbers are available' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: hash_including('access_token' => access_token))
+ .to_return(
+ status: 200,
+ body: { data: [] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error' do
+ expect do
+ service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
+ end.to raise_error(/No phone numbers found for WABA/)
+ end
+ end
+ end
+
+ describe '#validate_token_waba_access' do
+ context 'when token has access to WABA' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: [waba_id]
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'validates successfully when token has access' do
+ expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
+ end
+ end
+
+ context 'when token validation fails' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(status: 400, body: { error: 'Invalid token' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect do
+ service.send(:validate_token_waba_access, access_token, waba_id)
+ end.to raise_error(/Token validation failed/)
+ end
+ end
+
+ context 'when token does not have access to WABA' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'whatsapp_business_management',
+ target_ids: ['different_waba_id']
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error when WABA ID is not in target_ids' do
+ expect do
+ service.send(:validate_token_waba_access, access_token, waba_id)
+ end.to raise_error(/Token does not have access to WABA/)
+ end
+ end
+
+ context 'when no WABA scope is found' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: hash_including(
+ 'input_token' => access_token,
+ 'access_token' => "#{app_id}|#{app_secret}"
+ ))
+ .to_return(
+ status: 200,
+ body: {
+ data: {
+ granular_scopes: [
+ {
+ scope: 'some_other_scope',
+ target_ids: ['some_id']
+ }
+ ]
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises an error' do
+ expect do
+ service.send(:validate_token_waba_access, access_token, waba_id)
+ end.to raise_error(/No WABA scope found in token/)
+ end
+ end
+ end
+ end
+end
\ No newline at end of file