Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma 62c5d40b2c update the checks to show the api key input in configuration 2025-07-10 10:46:09 +07:00
Tanmay Deep Sharma d696fa2b17 feat: Add WhatsApp reauthorization frontend flow 2025-07-10 03:05:56 +07:00
Tanmay Deep SharmaandGitHub 358ec8120b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-09 10:01:37 +07:00
Muhsin KelothandGitHub c73f33619d Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-07-08 17:32:22 +05:30
Tanmay Deep Sharma c8f0cbbfa1 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-07-08 11:56:52 +07:00
Muhsin KelothandGitHub 340fcb6152 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-26 11:08:43 +05:30
Sivin VargheseandGitHub cacd192714 Update app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue 2025-06-12 22:57:15 +05:30
iamsivin b531dd0461 chore: Update png 2025-06-12 22:37:44 +05:30
iamsivin cb3721a425 chore: Fix styles 2025-06-12 22:25:59 +05:30
Muhsin KelothandGitHub 23ed1cc17f Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-12 11:28:41 +05:30
Muhsin Keloth d32d47b41b chore: hide webhook details for ES 2025-06-12 11:27:52 +05:30
Muhsin Keloth a1a3f62d1b Update inboxMgmt.json 2025-06-11 17:51:09 +05:30
Muhsin Keloth ccff83e253 chore: remove unused variables 2025-06-11 17:46:48 +05:30
Muhsin Keloth fdbf8b52f3 chore: revert codeclimate.yml 2025-06-11 17:23:43 +05:30
Muhsin Keloth 8544c87cfd Update .codeclimate.yml 2025-06-11 17:19:51 +05:30
Muhsin Keloth b04ae3e251 Update .codeclimate.yml 2025-06-11 17:13:52 +05:30
Muhsin Keloth f0f3e615b3 fix: complex logical expression 2025-06-11 17:12:52 +05:30
Tanmay Deep Sharma a1ce805fa5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-11 15:39:40 +05:30
Tanmay Deep Sharma e326a7a8c8 revert the linting of db file 2025-06-11 15:39:04 +05:30
Muhsin Keloth be8a441bfc chore: revert the changes 2025-06-11 15:26:35 +05:30
Muhsin Keloth 212f07711d Update .codeclimate.yml 2025-06-11 15:21:19 +05:30
Muhsin Keloth e6012d7e86 Update .codeclimate.yml 2025-06-11 15:21:06 +05:30
Muhsin Keloth 9d86792468 chore: final fixes 2025-06-11 15:20:41 +05:30
Muhsin Keloth 6e1c3ee176 chore: add more helpers 2025-06-11 15:07:43 +05:30
Muhsin Keloth 2f8214eb87 chore: more fixes 2025-06-11 15:01:53 +05:30
Muhsin Keloth 48293001ed chore: fix issues 2025-06-11 14:52:48 +05:30
Muhsin Keloth 83cd7b6788 Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:48:32 +05:30
Muhsin Keloth 65bb584f48 chore: more code cleanups 2025-06-11 14:48:15 +05:30
Muhsin KelothandGitHub 9ad1b219a3 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:41:20 +05:30
Muhsin Keloth f812ca3b99 fix: code complexity issues 2025-06-11 14:40:16 +05:30
Muhsin KelothandGitHub c6df54cc29 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-11 14:24:30 +05:30
Muhsin Keloth 384e616c1b Merge branch 'feat/whatsapp-embedded-signup' of https://github.com/chatwoot/chatwoot into feat/whatsapp-embedded-signup 2025-06-11 14:22:58 +05:30
Tanmay Deep Sharma 34a2eff135 make whatsapp embedded signup api version dynamic 2025-06-11 13:00:19 +05:30
Muhsin KelothandTanmay Deep Sharma 1c09cc5aa5 chore: design fixes 2025-06-11 12:30:48 +05:30
Muhsin Keloth 5bf5a0e0d0 chore: design fixes 2025-06-11 10:12:57 +05:30
Muhsin Keloth e3d76fa622 chore: add composable 2025-06-11 00:12:53 +05:30
Tanmay Deep Sharma 4f123e68a8 add spec for embedded signup 2025-06-10 20:31:24 +05:30
Muhsin KelothandGitHub 27284dd69e Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 20:11:07 +05:30
Muhsin Keloth a5321376e7 chore: front end improvments 2025-06-10 16:06:22 +05:30
Muhsin Keloth 20f63cf957 chore: make WhatsApp component composition styles 2025-06-10 15:04:14 +05:30
Muhsin Keloth 712054a8b7 Update WhatsappEmbeddedSignup.vue 2025-06-10 14:48:22 +05:30
Muhsin KelothandGitHub 7a4dfd5d4b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-10 14:29:55 +05:30
Tanmay Deep Sharma a17b7815e3 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-10 14:23:42 +05:30
Tanmay Deep Sharma 8025cd5aab sanitise the phone number before inserting to the database 2025-06-10 14:23:17 +05:30
Muhsin Keloth 7319d822fd chore: minor fixes 2025-06-10 09:54:42 +05:30
Muhsin Keloth 23a06739fc Update WhatsappEmbeddedSignup.vue 2025-06-09 16:00:35 +05:30
Muhsin Keloth 0771d0f269 chore: style fixes 2025-06-09 15:54:58 +05:30
Muhsin Keloth eb6c4a258a chore: code cleanups 2025-06-09 14:53:35 +05:30
Tanmay Deep Sharma 317a8ee4d5 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-09 10:26:22 +05:30
Tanmay Deep Sharma 4a7a68e6e7 fix: update WhatsApp GlobalConfigService.load calls 2025-06-09 10:24:26 +05:30
Tanmay Deep SharmaandGitHub 4c21a8d7b4 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-09 09:47:18 +05:30
Tanmay Deep Sharma 71ddd6c2c2 update the callback url, dynamically 2025-06-09 02:38:19 +05:30
Tanmay Deep Sharma 34852fccc1 update step four content in case of embedded signup 2025-06-09 02:35:13 +05:30
Tanmay Deep Sharma 149085e3d8 add webhook registration to embedded signup 2025-06-09 02:08:32 +05:30
Tanmay Deep Sharma e0b52c3fc2 add webhook registration page as well 2025-06-09 02:02:21 +05:30
Tanmay Deep Sharma 806ddaa9ab remove developer docs files 2025-06-08 18:17:13 +05:30
Tanmay Deep Sharma f24514433b register phone number on cloud, for sending messages 2025-06-08 16:33:14 +05:30
Tanmay Deep Sharma 700ce0540a Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-06-08 16:16:32 +05:30
Tanmay Deep Sharma 7cc082e586 incorporate the new UI designs for whatsapp inbox 2025-06-08 16:11:23 +05:30
Tanmay Deep Sharma 392fa50aee Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-06-06 13:34:39 +05:30
Muhsin KelothandGitHub 3582d47299 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 17:21:39 +05:30
Muhsin KelothandGitHub 06e4708e12 Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 13:27:39 +05:30
Tanmay Deep Sharma 9e40a79958 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-29 13:05:15 +05:30
Tanmay Deep Sharma 947e4b59e8 remove manual whastapp registeration 2025-05-29 13:02:49 +05:30
Muhsin KelothandGitHub 8816e8651b Merge branch 'develop' into feat/whatsapp-embedded-signup 2025-05-29 10:20:04 +05:30
Tanmay Deep Sharma f429db89b4 add mintlify intro docs 2025-05-28 23:42:34 +05:30
Tanmay Deep Sharma 203c010846 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-embedded-signup 2025-05-28 22:23:06 +05:30
Tanmay Deep Sharma e1fdc30c31 fix circleci checks 2025-05-28 20:59:08 +05:30
Tanmay Deep Sharma 1eca659b93 Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into feat/whatsapp-embedded-signup 2025-05-28 20:23:12 +05:30
Tanmay Deep Sharma 7a70cea36b fix circleci checks 2025-05-28 20:22:37 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
7c4e8346f8 Potential fix for code scanning alert no. 97: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:22:03 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
e4cd585513 Potential fix for code scanning alert no. 95: Clear-text storage of sensitive information
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-05-28 20:20:37 +05:30
Tanmay Deep Sharma 9e5e90c258 remove un-necessary changes 2025-05-28 20:10:56 +05:30
Tanmay Deep Sharma c8a66b9ebb feat: add Whatsapp embedded signup API 2025-05-28 20:05:57 +05:30
Muhsin Keloth 454686ab5d chore: add front end changes 2025-05-19 14:12:43 +05:30
23 changed files with 2167 additions and 38 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
@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -0,0 +1,78 @@
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', ''),
app_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
}
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,355 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappEmbeddedSignup() {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const authHeaders = computed(() => {
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 {};
});
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
if (data.data?.current_step) {
message += ` (Step: ${data.data.current_step})`;
}
useAlert(message);
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
// Update the store with the new inbox data
if (inboxData && inboxData.id) {
// Add the new inbox to the store
store.commit('inboxes/ADD_INBOXES', inboxData);
useAlert(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(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
// Send both auth code and business info together (synchronous flow)
const accountId = 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'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleSignupSuccess(responseData);
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleSignupError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation(data);
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = 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?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchEmbeddedSignup = () => {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchEmbeddedSignup(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
authCodeReceived,
authCode,
businessData,
isAuthenticating,
// Computed
benefits,
showLoader,
// Methods
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
};
}
@@ -0,0 +1,317 @@
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Auth from 'dashboard/api/auth';
export function useWhatsappReauthorization(inboxId) {
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const authHeaders = computed(() => {
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 {};
});
const showLoader = computed(
() => isAuthenticating.value || isProcessing.value
);
// Error handling
const handleReauthorizationError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR');
useAlert(errorMessage);
};
const handleReauthorizationCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
};
const handleReauthorizationSuccess = async () => {
isProcessing.value = false;
isAuthenticating.value = false;
// Refresh the inbox to update reauthorization status
await store.dispatch('inboxes/get');
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS'));
// Reload the page to reflect the changes
router.go(0);
};
// Reauthorization flow
const completeReauthorizationFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleReauthorizationError({
error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING'
);
try {
// Send reauthorization request
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/reauthorize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
inbox_id: inboxId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const responseData = await response.json();
if (response.ok) {
// Clear the stored auth code for security
authCode.value = null;
// Handle synchronous success response
handleReauthorizationSuccess();
} else {
throw new Error(responseData.message || responseData.error);
}
} catch (error) {
handleReauthorizationError({ error: error.message });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
(businessDataLocal.business_id || businessDataLocal.businessId) &&
(businessDataLocal.waba_id || businessDataLocal.wabaId)
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
// Handle different embedded signup events per Facebook documentation
if (data.event === 'FINISH') {
// Facebook might send business data in different structures
let businessDataLocal = data.data;
// If data.data doesn't exist, try other possible structures
if (!businessDataLocal) {
businessDataLocal = data.business_data || data.details || data;
}
// Validate we have the required business information
if (isValidBusinessData(businessDataLocal)) {
// Normalize the data structure to match our backend expectations
const normalizedData = {
business_id:
businessDataLocal.business_id || businessDataLocal.businessId,
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
phone_number_id:
businessDataLocal.phone_number_id ||
businessDataLocal.phoneNumberId ||
businessDataLocal.phone_id,
};
// Store business data
businessData.value = normalizedData;
// Check if we already have auth code and process immediately
if (authCodeReceived.value && authCode.value) {
await completeReauthorizationFlow(normalizedData);
} else {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH'
);
}
} else {
handleReauthorizationError({
error: t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleReauthorizationCancellation();
} else if (data.event === 'error') {
handleReauthorizationError({
error:
data.error_message ||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'),
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
// Authorization code received from Facebook
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO'
);
// Check if we already have business data and process immediately
if (businessData.value) {
completeReauthorizationFlow(businessData.value);
}
} else if (response.error) {
handleReauthorizationError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Handle Facebook embedded signup message events
try {
const originUrl = new URL(event.origin);
const allowedHosts = ['facebook.com', 'www.facebook.com'];
if (!allowedHosts.includes(originUrl.hostname)) return;
} catch (error) {
// Invalid origin URL, reject the event
return;
}
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch (error) {
// Handle non-JSON messages silently
}
};
// Facebook SDK
const loadFacebookSdk = () => {
if (window.FB) {
fbSdkLoaded.value = 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?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
document.body.appendChild(script);
};
const launchReauthorization = () => {
processingMessage.value = t(
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING'
);
if (!window.FB) {
loadFacebookSdk();
setTimeout(() => launchReauthorization(), 1000);
return;
}
isAuthenticating.value = true;
// Following Facebook's embedded signup documentation
// Using auth_type: 'reauthorize' to force reauthorization
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
auth_type: 'reauthorize',
extras: {
setup: {},
featureType: '', // Leave empty for default flow
sessionInfoVersion: '3',
},
});
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
// Initialize
const initialize = () => {
loadFacebookSdk();
setupMessageListener();
};
return {
// State
fbSdkLoaded,
isProcessing,
processingMessage,
isAuthenticating,
// Computed
showLoader,
// Methods
launchReauthorization,
initialize,
cleanupMessageListener,
};
}
@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"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",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@@ -571,7 +600,24 @@
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
"WHATSAPP_RECONFIGURE_TITLE": "Reconfigure WhatsApp",
"WHATSAPP_RECONFIGURE_SUBHEADER": "Update your WhatsApp Business account configuration using the embedded signup flow.",
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure with WhatsApp",
"WHATSAPP_RECONFIGURE_ERROR": "Failed to initiate WhatsApp reconfiguration. Please try again.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp Connection",
"DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.",
"AUTH_PROCESSING": "Processing WhatsApp reauthorization...",
"WAITING_FOR_BUSINESS_INFO": "Waiting for business information...",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"PROCESSING": "Updating your WhatsApp configuration...",
"SUCCESS": "WhatsApp successfully reauthorized!",
"ERROR": "Failed to reauthorize WhatsApp. Please try again.",
"CANCELLED": "WhatsApp reauthorization was cancelled.",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.",
"INVALID_BUSINESS_DATA": "Invalid business data received. Please try again."
}
},
"HELP_CENTER": {
"LABEL": "Help Center",
@@ -591,6 +637,12 @@
"MESSAGE_SUCCESS": "Reconnection successful",
"MESSAGE_ERROR": "There was an error, please try again"
},
"WHATSAPP_REAUTHORIZE": {
"TITLE": "Reauthorize WhatsApp",
"SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services",
"MESSAGE_SUCCESS": "WhatsApp reconnection successful",
"MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again"
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
"SET_FIELDS": "Pre chat form fields",
@@ -47,6 +47,13 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -66,7 +73,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox) {
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -113,8 +120,11 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
@@ -45,6 +46,7 @@ export default {
NextButton,
InstagramReauthorize,
DuplicateInboxBanner,
WhatsappReauthorize,
Editor,
},
mixins: [inboxMixin],
@@ -231,6 +233,11 @@ export default {
facebookUnauthorized() {
return this.isAFacebookInbox && this.inbox.reauthorization_required;
},
whatsappUnauthorized() {
return (
this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required
);
},
googleUnauthorized() {
const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes(
this.inbox.imap_address
@@ -410,6 +417,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@@ -1,48 +1,140 @@
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import twilioIcon from 'dashboard/assets/images/twilio.png';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: whatsappIcon,
},
data() {
return {
provider: 'whatsapp_cloud',
};
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: twilioIcon,
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
};
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
<!-- Provider Selection View -->
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<!-- Dynamic Provider Cards -->
<div class="flex justify-start gap-6">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<!-- Provider Icon -->
<div class="flex justify-start mb-5">
<div
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<!-- Card Content -->
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
<!-- Configuration View -->
<div v-else-if="showConfiguration">
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
<!-- Provider Configuration Forms -->
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
const {
fbSdkLoaded,
processingMessage,
isAuthenticating,
benefits,
showLoader,
launchEmbeddedSignup,
initialize,
cleanupMessageListener,
} = useWhatsappEmbeddedSignup();
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
>
<img
:src="whatsappIcon"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex items-center gap-2 text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="!fbSdkLoaded || isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
const props = defineProps({
inbox: {
type: Object,
required: true,
},
});
const {
processingMessage,
showLoader,
launchReauthorization,
initialize,
cleanupMessageListener,
} = useWhatsappReauthorization(props.inbox.id);
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="launchReauthorization"
/>
<LoadingState v-if="showLoader" :message="processingMessage" />
</div>
</template>
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
export default {
components: {
@@ -34,6 +35,20 @@ export default {
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isWhatsAppCloudChannel() {
return this.inbox.provider === 'whatsapp_cloud';
},
hasWhatsappAppId() {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
},
shouldShowReconfigureButton() {
return this.isWhatsAppCloudChannel && this.hasWhatsappAppId;
},
},
watch: {
inbox() {
this.setDefaults();
@@ -83,6 +98,18 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
reconfigureWhatsApp() {
// Initialize the WhatsApp reauthorization flow
const { launchReauthorization, initialize } = useWhatsappReauthorization(
this.inbox.id
);
// Initialize and launch
initialize();
launchReauthorization();
// Note: cleanup will happen when component unmounts or user navigates away
},
},
};
</script>
@@ -208,7 +235,21 @@ export default {
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<!-- Show reconfigure button for WhatsApp Cloud with embedded signup and app ID configured -->
<SettingsSection
v-if="shouldShowReconfigureButton"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUBHEADER')
"
>
<NextButton class="mt-2" @click="reconfigureWhatsApp">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</SettingsSection>
<!-- Show API key update for non-embedded signup WhatsApp or when app ID is missing -->
<SettingsSection
v-else
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
+1
View File
@@ -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
@@ -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
+3
View File
@@ -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'] %>
@@ -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_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'
+6
View File
@@ -482,6 +482,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
@@ -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'
@@ -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
@@ -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