diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 6a4ce2461..4a2df5ee5 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 771f9f28c..3972d5a28 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -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]) diff --git a/app/controllers/whatsapp/embedded_controller.rb b/app/controllers/whatsapp/embedded_controller.rb new file mode 100644 index 000000000..625a11f3e --- /dev/null +++ b/app/controllers/whatsapp/embedded_controller.rb @@ -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 diff --git a/app/javascript/dashboard/assets/images/twilio.png b/app/javascript/dashboard/assets/images/twilio.png new file mode 100644 index 000000000..627a8e9d4 Binary files /dev/null and b/app/javascript/dashboard/assets/images/twilio.png differ diff --git a/app/javascript/dashboard/assets/images/whatsapp.png b/app/javascript/dashboard/assets/images/whatsapp.png new file mode 100644 index 000000000..7bee740d9 Binary files /dev/null and b/app/javascript/dashboard/assets/images/whatsapp.png differ diff --git a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js new file mode 100644 index 000000000..dc8da1fe8 --- /dev/null +++ b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js @@ -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, + }; +} diff --git a/app/javascript/dashboard/helper/specs/fixtures/automationFixtures.js b/app/javascript/dashboard/helper/specs/fixtures/automationFixtures.js index e8f1b3ad9..cd55a5668 100644 --- a/app/javascript/dashboard/helper/specs/fixtures/automationFixtures.js +++ b/app/javascript/dashboard/helper/specs/fixtures/automationFixtures.js @@ -473,7 +473,7 @@ export const inboxes = [ welcome_title: '', welcome_tagline: '', web_widget_script: - '\n \n ', + '\n \n ', website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1', selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'], reply_time: 'in_a_few_minutes', diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index be7a6f2f9..eb999a0e5 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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" } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue index f6d0707af..ac09448f6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue @@ -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" /> -
-

+

+

{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}

diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index 68a9d8bcd..fd7eb2db7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -1,48 +1,140 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue new file mode 100644 index 000000000..948cc76a7 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/javascript/entrypoints/sdk.js b/app/javascript/entrypoints/sdk.js index a2cf297f1..2a919f42c 100755 --- a/app/javascript/entrypoints/sdk.js +++ b/app/javascript/entrypoints/sdk.js @@ -72,6 +72,13 @@ const runSDK = ({ baseUrl, websiteToken }) => { widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard', resetTriggered: false, darkMode: getDarkMode(chatwootSettings.darkMode), + welcomeTitle: chatwootSettings.welcomeTitle || '', + welcomeDescription: chatwootSettings.welcomeDescription || '', + availableMessage: chatwootSettings.availableMessage || '', + unavailableMessage: chatwootSettings.unavailableMessage || '', + enableFileUpload: chatwootSettings.enableFileUpload ?? true, + enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true, + enableEndConversation: chatwootSettings.enableEndConversation ?? true, toggle(state) { IFrameHelper.events.toggleBubble(state); diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 469fbe8de..3ab1ba1f5 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -166,6 +166,13 @@ export const IFrameHelper = { darkMode: window.$chatwoot.darkMode, showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog, campaignsSnoozedTill, + welcomeTitle: window.$chatwoot.welcomeTitle, + welcomeDescription: window.$chatwoot.welcomeDescription, + availableMessage: window.$chatwoot.availableMessage, + unavailableMessage: window.$chatwoot.unavailableMessage, + enableFileUpload: window.$chatwoot.enableFileUpload, + enableEmojiPicker: window.$chatwoot.enableEmojiPicker, + enableEndConversation: window.$chatwoot.enableEndConversation, }); IFrameHelper.onLoad({ widgetColor: message.config.channelConfig.widgetColor, diff --git a/app/javascript/widget/components/ChatAttachment.vue b/app/javascript/widget/components/ChatAttachment.vue index ac5a54127..64a20bcc7 100755 --- a/app/javascript/widget/components/ChatAttachment.vue +++ b/app/javascript/widget/components/ChatAttachment.vue @@ -24,7 +24,10 @@ export default { return { isUploading: false }; }, computed: { - ...mapGetters({ globalConfig: 'globalConfig/get' }), + ...mapGetters({ + globalConfig: 'globalConfig/get', + shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', + }), fileUploadSizeLimit() { return MAXIMUM_FILE_UPLOAD_SIZE; }, @@ -40,6 +43,9 @@ export default { }, methods: { handleClipboardPaste(e) { + // If file picker is not enabled, do not allow paste + if (!this.shouldShowFilePicker) return; + const items = (e.clipboardData || e.originalEvent.clipboardData).items; // items is a DataTransferItemList object which does not have forEach method const itemsArray = Array.from(items); diff --git a/app/javascript/widget/components/ChatHeaderExpanded.vue b/app/javascript/widget/components/ChatHeaderExpanded.vue index 6d8ac4a96..e89b1aef0 100755 --- a/app/javascript/widget/components/ChatHeaderExpanded.vue +++ b/app/javascript/widget/components/ChatHeaderExpanded.vue @@ -47,11 +47,11 @@ const containerClasses = computed(() => [

diff --git a/app/javascript/widget/components/ChatInputWrap.vue b/app/javascript/widget/components/ChatInputWrap.vue index 64997eed8..c423ab220 100755 --- a/app/javascript/widget/components/ChatInputWrap.vue +++ b/app/javascript/widget/components/ChatInputWrap.vue @@ -41,9 +41,15 @@ export default { ...mapGetters({ widgetColor: 'appConfig/getWidgetColor', isWidgetOpen: 'appConfig/getIsWidgetOpen', + shouldShowFilePicker: 'appConfig/getShouldShowFilePicker', + shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker', }), showAttachment() { - return this.hasAttachmentsEnabled && this.userInput.length === 0; + return ( + this.shouldShowFilePicker && + this.hasAttachmentsEnabled && + this.userInput.length === 0 + ); }, showSendButton() { return this.userInput.length > 0; @@ -143,7 +149,7 @@ export default { :on-attach="onSendAttachment" /> 0; @@ -71,12 +78,8 @@ export default { >

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