Merge branch 'develop' into feat/scenario-tools-association
This commit is contained in:
+1
-1
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/TrailingEmptyLines:
|
||||
Enabled: false
|
||||
Enabled: true
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
# Handles the embedded signup callback data from the Facebook SDK
|
||||
def create
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
render_success_response(channel.inbox)
|
||||
rescue StandardError => e
|
||||
render_error_response(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_embedded_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 render_success_response(inbox)
|
||||
render json: {
|
||||
success: true,
|
||||
id: inbox.id,
|
||||
name: inbox.name,
|
||||
channel_type: 'whatsapp'
|
||||
}
|
||||
end
|
||||
|
||||
def render_error_response(error)
|
||||
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
render json: {
|
||||
success: false,
|
||||
error: error.message
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_feature_enabled!
|
||||
return if Current.account.feature_whatsapp_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
error: 'WhatsApp embedded signup is not enabled for this account'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_embedded_signup_params!
|
||||
missing_params = []
|
||||
missing_params << 'code' if params[:code].blank?
|
||||
missing_params << 'business_id' if params[:business_id].blank?
|
||||
missing_params << 'waba_id' if params[:waba_id].blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
|
||||
def notion_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class WhatsappChannel extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp', { accountScoped: true });
|
||||
}
|
||||
|
||||
createEmbeddedSignup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappChannel();
|
||||
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -86,6 +86,12 @@ export default {
|
||||
selectedTabKey() {
|
||||
return this.tabs[this.selectedTabIndex]?.key;
|
||||
},
|
||||
shouldShowWhatsAppConfiguration() {
|
||||
return !!(
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.inbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
},
|
||||
whatsAppAPIProviderName() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
|
||||
@@ -137,7 +143,7 @@ export default {
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
@@ -383,7 +389,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
|
||||
class="overflow-auto flex-grow flex-shrink pr-0 pl-0 w-full min-w-0 settings"
|
||||
>
|
||||
<SettingIntroBanner
|
||||
:header-image="inbox.avatarUrl"
|
||||
@@ -405,7 +411,7 @@ export default {
|
||||
/>
|
||||
</woot-tabs>
|
||||
</SettingIntroBanner>
|
||||
<section class="w-full max-w-6xl mx-auto">
|
||||
<section class="mx-auto w-full max-w-6xl">
|
||||
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
@@ -735,7 +741,7 @@ export default {
|
||||
:business-name="businessName"
|
||||
@update="toggleSenderNameType"
|
||||
/>
|
||||
<div class="flex flex-col items-start gap-2 mt-2">
|
||||
<div class="flex flex-col gap-2 items-start mt-2">
|
||||
<NextButton
|
||||
ghost
|
||||
blue
|
||||
|
||||
@@ -1,48 +1,156 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
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 isWhatsappEmbeddedSignupEnabled = computed(() => {
|
||||
const accountId = route.params.accountId;
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
|
||||
);
|
||||
});
|
||||
|
||||
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: '/assets/images/dashboard/channels/whatsapp.png',
|
||||
},
|
||||
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: '/assets/images/dashboard/channels/twilio.png',
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: { provider: providerValue },
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
// Check if the feature is enabled for the account
|
||||
if (!isWhatsappEmbeddedSignupEnabled.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
|
||||
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show cloud whatsapp when:
|
||||
// 1. Provider is whatsapp AND
|
||||
// 2. Either no app ID is configured OR embedded signup feature is disabled
|
||||
return provider === PROVIDER_TYPES.WHATSAPP;
|
||||
};
|
||||
</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="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 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>
|
||||
<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>
|
||||
|
||||
<div class="flex gap-6 justify-start">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.value"
|
||||
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
|
||||
@click="selectProvider(provider.value)"
|
||||
>
|
||||
<div class="flex justify-start mb-5">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
|
||||
>
|
||||
<img
|
||||
:src="provider.icon"
|
||||
:alt="provider.label"
|
||||
class="object-contain size-[26px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
|
||||
<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>
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
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 whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
|
||||
|
||||
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 = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
const handleSignupSuccess = inboxData => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
if (inboxData && inboxData.id) {
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
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 {
|
||||
const params = {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
'inboxes/createWhatsAppEmbeddedSignup',
|
||||
params
|
||||
);
|
||||
|
||||
authCode.value = null;
|
||||
handleSignupSuccess(responseData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
handleSignupError({ error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
businessDataLocal.business_id &&
|
||||
businessDataLocal.waba_id
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (data.event === 'FINISH') {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
businessData.value = businessDataLocal;
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(businessDataLocal);
|
||||
} 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();
|
||||
} 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) {
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
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 => {
|
||||
// Validate origin for security - following Facebook documentation
|
||||
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
|
||||
if (!event.origin.endsWith('facebook.com')) return;
|
||||
|
||||
// Parse and handle WhatsApp embedded signup events
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or irrelevant messages
|
||||
}
|
||||
};
|
||||
|
||||
const runFBInit = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
autoLogAppEvents: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadFacebookSdk = async () => {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
async: true,
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
};
|
||||
|
||||
const tryWhatsAppLogin = () => {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
// Load SDK first if not loaded, following Facebook.vue pattern exactly
|
||||
await loadFacebookSdk();
|
||||
runFBInit(); // Initialize FB after loading
|
||||
|
||||
// Now proceed with login
|
||||
tryWhatsAppLogin();
|
||||
} catch (error) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
window.fbAsyncInit = runFBInit;
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
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 justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<img
|
||||
:src="whatsappIconPath"
|
||||
: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 gap-2 items-center 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="isAuthenticating"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
class="w-full"
|
||||
@click="launchEmbeddedSignup"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import InboxesAPI from '../../api/inboxes';
|
||||
import WebChannel from '../../api/channel/webChannel';
|
||||
import FBChannel from '../../api/channel/fbChannel';
|
||||
import TwilioChannel from '../../api/channel/twilioChannel';
|
||||
import WhatsappChannel from '../../api/channel/whatsappChannel';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
@@ -198,6 +199,19 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||
const response = await WhatsappChannel.createEmbeddedSignup(params);
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('whatsapp');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
...channelActions,
|
||||
// TODO: Extract other create channel methods to separate files to reduce file size
|
||||
// - createChannel
|
||||
|
||||
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validate :validate_provider_config
|
||||
|
||||
after_create :sync_templates
|
||||
after_create_commit :setup_webhooks
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@@ -67,4 +68,41 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
def validate_provider_config
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
def setup_webhooks
|
||||
return unless should_setup_webhooks?
|
||||
|
||||
perform_webhook_setup
|
||||
rescue StandardError => e
|
||||
handle_webhook_setup_error(e)
|
||||
end
|
||||
|
||||
def should_setup_webhooks?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
provider_config['business_account_id'].present? && provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
business_account_id = provider_config['business_account_id']
|
||||
api_key = provider_config['api_key']
|
||||
|
||||
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
|
||||
end
|
||||
|
||||
def handle_webhook_setup_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel creation from failing
|
||||
# Webhooks can be retried later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,4 +17,4 @@ class MessageContentPresenter < SimpleDelegator
|
||||
def survey_url(conversation_uuid)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class Whatsapp::ChannelCreationService
|
||||
def initialize(account, waba_info, phone_info, access_token)
|
||||
@account = account
|
||||
@waba_info = waba_info
|
||||
@phone_info = phone_info
|
||||
@access_token = access_token
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
existing_channel = find_existing_channel
|
||||
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
|
||||
|
||||
create_channel_with_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Account is required' if @account.blank?
|
||||
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
|
||||
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_existing_channel
|
||||
Channel::Whatsapp.find_by(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number]
|
||||
)
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
create_inbox(channel)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel
|
||||
Channel::Whatsapp.create!(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: build_provider_config
|
||||
)
|
||||
end
|
||||
|
||||
def build_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_inbox(channel)
|
||||
inbox_name = build_inbox_name
|
||||
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def build_inbox_name
|
||||
business_name = @phone_info[:business_name] || @waba_info[:business_name]
|
||||
"#{business_name} WhatsApp"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
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_parameters!
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = Whatsapp::TokenExchangeService.new(@code).perform
|
||||
|
||||
# Fetch phone information
|
||||
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
|
||||
# Validate token has access to the WABA
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
|
||||
# Create channel
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
# Webhook setup is now handled in the channel after_create_commit callback
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
missing_params << 'business_id' if @business_id.blank?
|
||||
missing_params << 'waba_id' if @waba_id.blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token(code)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/oauth/access_token",
|
||||
query: {
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: code
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token exchange failed')
|
||||
end
|
||||
|
||||
def fetch_phone_numbers(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
query: { access_token: @access_token }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
query: {
|
||||
input_token: input_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token validation failed')
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, pin)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
|
||||
headers: request_headers,
|
||||
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
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 handle_response(response, error_message)
|
||||
raise "#{error_message}: #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::PhoneInfoService
|
||||
def initialize(waba_id, phone_number_id, access_token)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
fetch_and_process_phone_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def fetch_and_process_phone_info
|
||||
response = @api_client.fetch_phone_numbers(@waba_id)
|
||||
phone_numbers = response['data']
|
||||
|
||||
phone_data = find_phone_data(phone_numbers)
|
||||
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
|
||||
|
||||
build_phone_info(phone_data)
|
||||
end
|
||||
|
||||
def find_phone_data(phone_numbers)
|
||||
return nil if phone_numbers.blank?
|
||||
|
||||
if @phone_number_id.present?
|
||||
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
|
||||
else
|
||||
phone_numbers.first
|
||||
end
|
||||
end
|
||||
|
||||
def build_phone_info(phone_data)
|
||||
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 sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::TokenExchangeService
|
||||
def initialize(code)
|
||||
@code = code
|
||||
@api_client = Whatsapp::FacebookApiClient.new
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_code!
|
||||
exchange_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_code!
|
||||
raise ArgumentError, 'Authorization code is required' if @code.blank?
|
||||
end
|
||||
|
||||
def exchange_token
|
||||
response = @api_client.exchange_code_for_token(@code)
|
||||
access_token = response['access_token']
|
||||
|
||||
raise "No access token in response: #{response}" if access_token.blank?
|
||||
|
||||
access_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
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)
|
||||
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
|
||||
@@ -0,0 +1,67 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
register_phone_number
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
pin = fetch_or_create_pin
|
||||
|
||||
@api_client.register_phone_number(phone_number_id, pin)
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
# Check if we have a stored PIN for this phone number
|
||||
existing_pin = @channel.provider_config['verification_pin']
|
||||
return existing_pin.to_i if existing_pin.present?
|
||||
|
||||
# Generate a new 6-digit PIN if none exists
|
||||
SecureRandom.random_number(900_000) + 100_000
|
||||
end
|
||||
|
||||
def store_pin(pin)
|
||||
# Store the PIN in provider_config for future use
|
||||
@channel.provider_config['verification_pin'] = pin
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
end
|
||||
@@ -4,4 +4,4 @@ json.payload do
|
||||
json.partial! 'article', formats: [:json], article: article
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -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'] %>
|
||||
|
||||
@@ -180,3 +180,6 @@
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: whatsapp_embedded_signup
|
||||
display_name: WhatsApp Embedded Signup
|
||||
enabled: false
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -236,6 +236,10 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :whatsapp do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
|
||||
@@ -13,4 +13,4 @@ class CreateChannelVoice < ActiveRecord::Migration[7.0]
|
||||
add_index :channel_voice, :phone_number, unique: true
|
||||
add_index :channel_voice, :account_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,4 +44,4 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
|
||||
def scenario_params
|
||||
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
|
||||
end
|
||||
end
|
||||
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'
|
||||
|
||||
@@ -61,4 +61,3 @@ class Channel::Voice < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,4 +18,4 @@ class Captain::ScenarioPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,4 +57,4 @@ class Captain::OpenAiMessageBuilderService
|
||||
result[:success] ? result[:transcriptions] : ''
|
||||
end.join
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -2,4 +2,4 @@ json.data do
|
||||
json.array! @scenarios do |scenario|
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -13,4 +13,4 @@ if scenario.assistant.present?
|
||||
json.id scenario.assistant.id
|
||||
json.name scenario.assistant.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 13 KiB |
@@ -50,4 +50,4 @@ RSpec.describe 'Notion Authorization API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
context 'when feature is not enabled' do
|
||||
before do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature is enabled' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when code is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('code')
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when business_id is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('business_id')
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when waba_id is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('waba_id')
|
||||
end
|
||||
|
||||
it 'creates whatsapp channel successfully' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service to prevent HTTP calls
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['success']).to be true
|
||||
expect(response_data['id']).to eq(inbox.id)
|
||||
expect(response_data['name']).to eq(inbox.name)
|
||||
expect(response_data['channel_type']).to eq('whatsapp')
|
||||
end
|
||||
|
||||
it 'calls the embedded signup service with correct parameters' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
).and_return(embedded_signup_service)
|
||||
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: 'test_phone_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
it 'accepts phone_number_id as optional parameter' do
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
|
||||
expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
|
||||
account: account,
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id',
|
||||
phone_number_id: nil
|
||||
).and_return(embedded_signup_service)
|
||||
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity when service fails' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['success']).to be false
|
||||
expect(response_data['error']).to eq('Service error')
|
||||
end
|
||||
|
||||
it 'logs error when service fails' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(/\[WHATSAPP AUTHORIZATION\] Embedded signup error: Service error/)
|
||||
expect(Rails.logger).to receive(:error).with(/authorizations_controller/)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
it 'handles token exchange errors' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new)
|
||||
.and_raise(StandardError, 'Invalid authorization code')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'invalid_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid authorization code')
|
||||
end
|
||||
|
||||
it 'handles channel already exists error' do
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new)
|
||||
.and_raise(StandardError, 'Channel already exists')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Channel already exists')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authorized for the account' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is an administrator' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'allows channel creation' do
|
||||
embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
|
||||
inbox = create(:inbox, account: account, channel: whatsapp_channel)
|
||||
|
||||
allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
|
||||
allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
|
||||
allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
|
||||
|
||||
# Stub webhook setup service
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
|
||||
params: {
|
||||
code: 'test_code',
|
||||
business_id: 'test_business_id',
|
||||
waba_id: 'test_waba_id'
|
||||
},
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -255,4 +255,4 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,4 +61,65 @@ RSpec.describe Channel::Whatsapp do
|
||||
expect(channel.provider_config['webhook_verify_token']).to eq '123'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'webhook setup after creation' do
|
||||
let(:account) { create(:account) }
|
||||
let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when channel is created through embedded signup' do
|
||||
it 'does not raise error if webhook setup fails' do
|
||||
allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
|
||||
|
||||
expect do
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is created through manual setup' do
|
||||
it 'does not setup webhooks' do
|
||||
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
||||
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'business_account_id' => 'test_waba_id',
|
||||
'api_key' => 'test_access_token'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is created with different provider' do
|
||||
it 'does not setup webhooks for 360dialog provider' do
|
||||
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
||||
|
||||
create(:channel_whatsapp,
|
||||
account: account,
|
||||
provider: 'default',
|
||||
provider_config: {
|
||||
'source' => 'embedded_signup',
|
||||
'api_key' => 'test_360dialog_key'
|
||||
},
|
||||
validate_provider_config: false,
|
||||
sync_templates: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Featurable do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'WhatsApp embedded signup feature' do
|
||||
it 'is disabled by default' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
|
||||
end
|
||||
|
||||
describe '#enable_features!' do
|
||||
it 'enables the whatsapp embedded signup feature' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
|
||||
end
|
||||
|
||||
it 'enables multiple features at once' do
|
||||
account.enable_features!(:whatsapp_embedded_signup, :help_center)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
expect(account.feature_help_center?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_features!' do
|
||||
before do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
end
|
||||
|
||||
it 'disables the whatsapp embedded signup feature' do
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be true
|
||||
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.feature_whatsapp_embedded_signup?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enabled_features' do
|
||||
it 'includes whatsapp_embedded_signup when enabled' do
|
||||
account.enable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
|
||||
it 'does not include whatsapp_embedded_signup when disabled' do
|
||||
account.disable_features!(:whatsapp_embedded_signup)
|
||||
expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#all_features' do
|
||||
it 'includes whatsapp_embedded_signup in all features list' do
|
||||
expect(account.all_features).to have_key('whatsapp_embedded_signup')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,4 +63,4 @@ RSpec.describe MessageContentPresenter do
|
||||
expect(presenter.conversation).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,4 +88,4 @@ describe CsatSurveyService do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -171,4 +171,4 @@ RSpec.describe Linear::ActivityMessageService, type: :service do
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::ChannelCreationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
|
||||
# Stub the webhook setup service to prevent HTTP calls during tests
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
|
||||
# Stub the provider validation and sync_templates
|
||||
allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
|
||||
channel = method.call(*args)
|
||||
allow(channel).to receive(:validate_provider_config)
|
||||
allow(channel).to receive(:sync_templates)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel does not exist' do
|
||||
it 'creates a new channel' do
|
||||
expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates channel with correct attributes' do
|
||||
channel = service.perform
|
||||
expect(channel.phone_number).to eq('+1234567890')
|
||||
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('test_phone_id')
|
||||
expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
|
||||
expect(channel.provider_config['source']).to eq('embedded_signup')
|
||||
end
|
||||
|
||||
it 'creates an inbox for the channel' do
|
||||
channel = service.perform
|
||||
inbox = channel.inbox
|
||||
expect(inbox).not_to be_nil
|
||||
expect(inbox.name).to eq('Test Business WhatsApp')
|
||||
expect(inbox.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel already exists' do
|
||||
before do
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Channel already exists/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when account is nil' do
|
||||
service = described_class.new(nil, waba_info, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
|
||||
end
|
||||
|
||||
it 'raises error when waba_info is nil' do
|
||||
service = described_class.new(account, nil, phone_info, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
|
||||
end
|
||||
|
||||
it 'raises error when phone_info is nil' do
|
||||
service = described_class.new(account, waba_info, nil, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
|
||||
end
|
||||
|
||||
it 'raises error when access_token is blank' do
|
||||
service = described_class.new(account, waba_info, phone_info, '')
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is in different places' do
|
||||
context 'when business_name is only in phone_info' do
|
||||
let(:waba_info) { { waba_id: 'test_waba_id' } }
|
||||
|
||||
it 'uses business_name from phone_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when business_name is only in waba_info' do
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: 'test_phone_id',
|
||||
phone_number: '+1234567890',
|
||||
verified: true
|
||||
}
|
||||
end
|
||||
|
||||
it 'uses business_name from waba_info' do
|
||||
channel = service.perform
|
||||
expect(channel.inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
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(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:channel) { instance_double(Channel::Whatsapp) }
|
||||
|
||||
let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
|
||||
let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
|
||||
let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
|
||||
let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
|
||||
|
||||
before do
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
|
||||
allow(token_exchange_service).to receive(:perform).and_return(access_token)
|
||||
|
||||
allow(Whatsapp::PhoneInfoService).to receive(:new)
|
||||
.with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
|
||||
allow(phone_info_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, waba_id).and_return(token_validation_service)
|
||||
allow(token_validation_service).to receive(:perform)
|
||||
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
|
||||
.and_return(channel_creation_service)
|
||||
allow(channel_creation_service).to receive(:perform).and_return(channel)
|
||||
|
||||
# Webhook setup is now handled in the channel after_create callback
|
||||
# So we stub it at the model level
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'orchestrates all services in the correct order' do
|
||||
expect(token_exchange_service).to receive(:perform).ordered
|
||||
expect(phone_info_service).to receive(:perform).ordered
|
||||
expect(token_validation_service).to receive(:perform).ordered
|
||||
expect(channel_creation_service).to receive(:perform).ordered
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when code is blank' 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, /Required parameters are missing: code/)
|
||||
end
|
||||
|
||||
it 'raises error when business_id is blank' 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, /Required parameters are missing: business_id/)
|
||||
end
|
||||
|
||||
it 'raises error when waba_id is blank' 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, /Required parameters are missing: waba_id/)
|
||||
end
|
||||
|
||||
it 'raises error when multiple parameters are blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: '',
|
||||
business_id: '',
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when any service fails' do
|
||||
it 'logs and re-raises the error' do
|
||||
allow(token_exchange_service).to receive(:perform).and_raise('Token error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
|
||||
expect { service.perform }.to raise_error('Token error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::FacebookApiClient do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:api_client) { described_class.new(access_token) }
|
||||
let(:api_version) { 'v22.0' }
|
||||
let(:app_id) { 'test_app_id' }
|
||||
let(:app_secret) { 'test_app_secret' }
|
||||
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
|
||||
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)
|
||||
end
|
||||
|
||||
describe '#exchange_code_for_token' do
|
||||
let(:code) { 'test_code' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: { client_id: app_id, client_secret: app_secret, code: code })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: 'new_token' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the response data' do
|
||||
result = api_client.exchange_code_for_token(code)
|
||||
expect(result['access_token']).to eq('new_token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: { 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 { api_client.exchange_code_for_token(code) }.to raise_error(/Token exchange failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_phone_numbers' do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: { access_token: access_token })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [{ id: '123', display_phone_number: '1234567890' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the phone numbers data' do
|
||||
result = api_client.fetch_phone_numbers(waba_id)
|
||||
expect(result['data']).to be_an(Array)
|
||||
expect(result['data'].first['id']).to eq('123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: { access_token: access_token })
|
||||
.to_return(status: 403, body: { error: 'Access denied' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.fetch_phone_numbers(waba_id) }.to raise_error(/WABA phone numbers fetch failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#debug_token' do
|
||||
let(:input_token) { 'test_input_token' }
|
||||
let(:app_access_token) { "#{app_id}|#{app_secret}" }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: { input_token: input_token, access_token: app_access_token })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: { app_id: app_id, is_valid: true } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the debug token data' do
|
||||
result = api_client.debug_token(input_token)
|
||||
expect(result['data']['is_valid']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: { input_token: input_token, access_token: app_access_token })
|
||||
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.debug_token(input_token) }.to raise_error(/Token validation failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#register_phone_number' do
|
||||
let(:phone_number_id) { 'test_phone_id' }
|
||||
let(:pin) { '123456' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: pin }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
result = api_client.register_phone_number(phone_number_id, pin)
|
||||
expect(result['success']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: pin }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Registration failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.register_phone_number(phone_number_id, pin) }.to raise_error(/Phone registration failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#subscribe_waba_webhook' do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:callback_url) { 'https://example.com/webhook' }
|
||||
let(:verify_token) { 'test_verify_token' }
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
expect(result['success']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when failed' do
|
||||
before do
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
|
||||
)
|
||||
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,147 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::PhoneInfoService do
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:phone_number_id) { 'test_phone_number_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(waba_id, phone_number_id, access_token) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => phone_number_id,
|
||||
'display_phone_number' => '1234567890',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when all parameters are valid' do
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'returns formatted phone info' do
|
||||
result = service.perform
|
||||
expect(result).to eq({
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone_number_id is not provided' do
|
||||
let(:phone_number_id) { nil }
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => 'first_phone_id',
|
||||
'display_phone_number' => '1234567890',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number_id]).to eq('first_phone_id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when specific phone_number_id is not found' do
|
||||
let(:phone_number_id) { 'different_id' }
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => 'available_phone_id',
|
||||
'display_phone_number' => '9876543210',
|
||||
'verified_name' => 'Different Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number as fallback' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number_id]).to eq('available_phone_id')
|
||||
expect(result[:phone_number]).to eq('+9876543210')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no phone numbers are available' do
|
||||
let(:phone_response) { { 'data' => [] } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/No phone numbers found for WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone number has special characters' do
|
||||
let(:phone_response) do
|
||||
{
|
||||
'data' => [
|
||||
{
|
||||
'id' => phone_number_id,
|
||||
'display_phone_number' => '+1 (234) 567-8900',
|
||||
'verified_name' => 'Test Business',
|
||||
'code_verification_status' => 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
|
||||
end
|
||||
|
||||
it 'sanitizes the phone number' do
|
||||
result = service.perform
|
||||
expect(result[:phone_number]).to eq('+12345678900')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenExchangeService do
|
||||
let(:code) { 'test_authorization_code' }
|
||||
let(:service) { described_class.new(code) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when code is valid' do
|
||||
let(:token_response) { { 'access_token' => 'new_access_token' } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
|
||||
end
|
||||
|
||||
it 'returns the access token' do
|
||||
expect(service.perform).to eq('new_access_token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when code is blank' do
|
||||
let(:service) { described_class.new('') }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Authorization code is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response has no access token' do
|
||||
let(:token_response) { { 'error' => 'Invalid code' } }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/No access token in response/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::TokenValidationService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:service) { described_class.new(access_token, waba_id) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when token has access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => [waba_id, 'another_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'validates successfully' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'whatsapp_business_management',
|
||||
'target_ids' => ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
let(:debug_response) do
|
||||
{
|
||||
'data' => {
|
||||
'granular_scopes' => [
|
||||
{
|
||||
'scope' => 'some_other_scope',
|
||||
'target_ids' => ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error('No WABA scope found in token')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when access_token is blank' do
|
||||
let(:access_token) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when waba_id is blank' do
|
||||
let(:waba_id) { '' }
|
||||
|
||||
it 'raises ArgumentError' do
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::WebhookSetupService do
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp,
|
||||
phone_number: '+1234567890',
|
||||
provider_config: {
|
||||
'phone_number_id' => 'test_phone_id',
|
||||
'webhook_verify_token' => 'test_verify_token'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
end
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(channel, waba_id, access_token) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when all operations succeed' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token')
|
||||
.and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'registers the phone number' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets up webhook subscription' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone registration fails' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
.and_raise('Registration failed')
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'continues with webhook setup' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup fails' do
|
||||
before do
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.and_raise('Webhook failed')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect { service.perform }.to raise_error(/Webhook setup failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when channel is nil' do
|
||||
service = described_class.new(nil, waba_id, access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Channel is required')
|
||||
end
|
||||
|
||||
it 'raises error when waba_id is blank' do
|
||||
service = described_class.new(channel, '', access_token)
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
|
||||
end
|
||||
|
||||
it 'raises error when access_token is blank' do
|
||||
service = described_class.new(channel, waba_id, '')
|
||||
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when PIN already exists' do
|
||||
before do
|
||||
channel.provider_config['verification_pin'] = 123_456
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'reuses existing PIN' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 123_456)
|
||||
expect(SecureRandom).not_to receive(:random_number)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user