Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub d98eae0b2d Merge branch 'feat/whatsapp-embedded-signup' into feat/whatsapp-es-reauth-flow 2025-07-10 12:02:21 +05:30
66ce599e4e feat: Whatsapp es reauth flow fe (#11915)
# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-10 11:52:25 +05:30
Tanmay Deep Sharma 50d023caed remove extra committed file 2025-07-10 09:06:05 +07:00
Tanmay Deep Sharma 9007bf1ecf feat: Add WhatsApp reauthorization bankend flow 2025-07-10 03:09:16 +07:00
11 changed files with 670 additions and 132 deletions
@@ -32,6 +32,28 @@ class Whatsapp::EmbeddedController < ApplicationController
handle_signup_error(e)
end
def reauthorize
# Reauthorize existing WhatsApp inbox using embedded signup flow
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
validate_inbox_id!
return if performed?
channel = process_reauthorization
@inbox = channel.inbox
# Clear reauthorization required flag
channel.reauthorized!
render json: { message: 'WhatsApp channel reauthorized successfully' }, status: :ok
rescue StandardError => e
handle_signup_error(e)
end
private
def validate_authorization_code!
@@ -51,18 +73,43 @@ class Whatsapp::EmbeddedController < ApplicationController
}, status: :bad_request
end
def validate_inbox_id!
return if params[:inbox_id].present?
render json: {
error: 'Missing inbox_id parameter'
}, 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]
{
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 process_reauthorization
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],
inbox_id: params[:inbox_id]
}
)
service.perform_reauthorization
end
def handle_signup_error(error)
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
@@ -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,
};
}
@@ -600,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",
@@ -620,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",
@@ -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')"
@@ -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')
+97
View File
@@ -0,0 +1,97 @@
# Handles WhatsApp API communication
module Whatsapp::ApiService
extend ActiveSupport::Concern
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)
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 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 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
private
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
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
end
+48 -126
View File
@@ -1,12 +1,15 @@
class Whatsapp::EmbeddedSignupService
include Rails.application.routes.url_helpers
include Whatsapp::ApiService
include Whatsapp::TokenValidator
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
def initialize(params)
@account = params[:account]
@code = params[:code]
@business_id = params[:business_id]
@waba_id = params[:waba_id]
@phone_number_id = params[:phone_number_id]
@inbox_id = params[:inbox_id]
end
def perform
@@ -33,52 +36,42 @@ class Whatsapp::EmbeddedSignupService
raise e
end
def perform_reauthorization
validate_reauthorization_params!
channel = find_and_validate_channel
GlobalConfig.clear_cache
access_token = exchange_code_for_token
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
validate_token_waba_access(access_token, @waba_id)
update_channel_for_reauthorization(channel, phone_info, access_token)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(@waba_id, channel, access_token)
channel
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Reauthorization failed: #{e.message}")
raise e
end
private
def whatsapp_api_version
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
def validate_reauthorization_params!
return if @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present? && @inbox_id.present?
raise ArgumentError, 'Code, business_id, waba_id, phone_number_id, and inbox_id are all required for reauthorization'
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
}
)
def find_and_validate_channel
inbox = @account.inboxes.find_by(id: @inbox_id)
raise ActiveRecord::RecordNotFound, 'Inbox not found' unless inbox
raise ArgumentError, 'Inbox is not a WhatsApp channel' unless inbox.channel_type == 'Channel::Whatsapp'
raise "Token exchange failed: #{response.body}" unless response.success?
channel = inbox.channel
raise ArgumentError, 'Channel is not WhatsApp Cloud provider' unless channel.provider == 'whatsapp_cloud'
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']
}
channel
end
def create_or_update_channel(waba_info, phone_info, access_token)
@@ -96,16 +89,6 @@ class Whatsapp::EmbeddedSignupService
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
@@ -138,76 +121,15 @@ class Whatsapp::EmbeddedSignupService
)
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
}
def update_channel_for_reauthorization(channel, phone_info, access_token)
# Update channel with new access token and configuration
channel.update!(
provider_config: channel.provider_config.merge(
'api_key' => access_token,
'phone_number_id' => phone_info[:phone_number_id],
'business_account_id' => @waba_id,
'reauthorized_at' => Time.current.iso8601
)
)
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
+43
View File
@@ -0,0 +1,43 @@
# Handles WhatsApp token validation
module Whatsapp::TokenValidator
extend ActiveSupport::Concern
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
private
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
end
@@ -116,4 +116,5 @@ json.provider resource.channel.try(:provider)
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
+1
View File
@@ -486,6 +486,7 @@ Rails.application.routes.draw do
get 'signup', to: 'embedded#new'
get 'signup/callback', to: 'embedded#callback'
post 'embedded_signup', to: 'embedded#embedded_signup'
post 'reauthorize', to: 'embedded#reauthorize'
end
namespace :twitter do