Merge remote-tracking branch 'origin/feat/whatsapp-embedded-signup' into test-pdf-support-captain
This commit is contained in:
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
class Whatsapp::EmbeddedController < ApplicationController
|
||||
include EnsureCurrentAccountHelper
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
|
||||
def new
|
||||
# Configuration endpoint for embedded signup initialization
|
||||
render json: {
|
||||
status: 'ready',
|
||||
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
app_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
}
|
||||
end
|
||||
|
||||
def embedded_signup
|
||||
# Complete embedded signup using auth code + business info from frontend
|
||||
validate_authorization_code!
|
||||
return if performed?
|
||||
|
||||
validate_required_parameters!
|
||||
return if performed?
|
||||
|
||||
channel = process_signup
|
||||
@inbox = channel.inbox
|
||||
|
||||
# Return the inbox object using the standard jbuilder template
|
||||
render 'embedded_signup', formats: [:json]
|
||||
rescue StandardError => e
|
||||
handle_signup_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_authorization_code!
|
||||
return if params[:code].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing authorization code',
|
||||
message: 'Authorization code is required for embedded signup'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def validate_required_parameters!
|
||||
return if params[:business_id].present? && params[:waba_id].present?
|
||||
|
||||
render json: {
|
||||
error: 'Missing required parameters: business_id and waba_id are required'
|
||||
}, status: :bad_request
|
||||
end
|
||||
|
||||
def process_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_id]
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
def handle_signup_error(error)
|
||||
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
render json: {
|
||||
error: 'Internal server error',
|
||||
message: error.message
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Current.account, :update?)
|
||||
end
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,355 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const authHeaders = computed(() => {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
key: 'EASY_SETUP',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
|
||||
},
|
||||
{
|
||||
key: 'SECURE_AUTH',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
|
||||
},
|
||||
{
|
||||
key: 'AUTO_CONFIG',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
|
||||
},
|
||||
]);
|
||||
|
||||
const showLoader = computed(
|
||||
() => isAuthenticating.value || isProcessing.value
|
||||
);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
|
||||
if (data.data?.current_step) {
|
||||
message += ` (Step: ${data.data.current_step})`;
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
};
|
||||
|
||||
const handleSignupSuccess = inboxData => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
// Update the store with the new inbox data
|
||||
if (inboxData && inboxData.id) {
|
||||
// Add the new inbox to the store
|
||||
store.commit('inboxes/ADD_INBOXES', inboxData);
|
||||
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Fallback if inbox data is not properly formatted
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send both auth code and business info together (synchronous flow)
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/embedded_signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
authCode.value = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
handleSignupSuccess(responseData);
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleSignupError({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
(businessDataLocal.business_id || businessDataLocal.businessId) &&
|
||||
(businessDataLocal.waba_id || businessDataLocal.wabaId)
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessDataLocal = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessDataLocal) {
|
||||
businessDataLocal = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id:
|
||||
businessDataLocal.business_id || businessDataLocal.businessId,
|
||||
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
|
||||
phone_number_id:
|
||||
businessDataLocal.phone_number_id ||
|
||||
businessDataLocal.phoneNumberId ||
|
||||
businessDataLocal.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
businessData.value = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(normalizedData);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation(data);
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleSignupError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Handle Facebook embedded signup message events
|
||||
try {
|
||||
const originUrl = new URL(event.origin);
|
||||
const allowedHosts = ['facebook.com', 'www.facebook.com'];
|
||||
if (!allowedHosts.includes(originUrl.hostname)) return;
|
||||
} catch (error) {
|
||||
// Invalid origin URL, reject the event
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
};
|
||||
|
||||
// Facebook SDK
|
||||
const loadFacebookSdk = () => {
|
||||
if (window.FB) {
|
||||
fbSdkLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = () => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
if (!window.FB) {
|
||||
loadFacebookSdk();
|
||||
setTimeout(() => launchEmbeddedSignup(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = () => {
|
||||
loadFacebookSdk();
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fbSdkLoaded,
|
||||
isProcessing,
|
||||
processingMessage,
|
||||
authCodeReceived,
|
||||
authCode,
|
||||
businessData,
|
||||
isAuthenticating,
|
||||
|
||||
// Computed
|
||||
benefits,
|
||||
showLoader,
|
||||
|
||||
// Methods
|
||||
launchEmbeddedSignup,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
};
|
||||
}
|
||||
@@ -473,7 +473,7 @@ export const inboxes = [
|
||||
welcome_title: '',
|
||||
welcome_tagline: '',
|
||||
web_widget_script:
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.defer = true;\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
|
||||
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
|
||||
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
|
||||
reply_time: 'in_a_few_minutes',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,48 +1,140 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
|
||||
import twilioIcon from 'dashboard/assets/images/twilio.png';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP_CLOUD: 'whatsapp_cloud',
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProvider = computed(() => route.query.provider);
|
||||
|
||||
const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
value: PROVIDER_TYPES.WHATSAPP,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: whatsappIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: 'whatsapp_cloud',
|
||||
};
|
||||
{
|
||||
value: PROVIDER_TYPES.TWILIO,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: twilioIcon,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: { provider: providerValue },
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
||||
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
|
||||
/>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
|
||||
<select v-model="provider">
|
||||
<option value="whatsapp_cloud">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
|
||||
</option>
|
||||
<option value="twilio">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Provider Selection View -->
|
||||
<div v-if="showProviderSelection">
|
||||
<div class="mb-10 text-left">
|
||||
<h1 class="mb-2 text-lg font-medium text-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
|
||||
</h1>
|
||||
<p class="text-sm leading-relaxed text-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Provider Cards -->
|
||||
<div class="flex justify-start gap-6">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.value"
|
||||
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
|
||||
@click="selectProvider(provider.value)"
|
||||
>
|
||||
<!-- Provider Icon -->
|
||||
<div class="flex justify-start mb-5">
|
||||
<div
|
||||
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="provider.icon"
|
||||
:alt="provider.label"
|
||||
class="object-contain size-[26px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="text-start">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
|
||||
{{ provider.label }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-11">
|
||||
{{ provider.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
|
||||
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
|
||||
<CloudWhatsapp v-else />
|
||||
<!-- Configuration View -->
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
|
||||
<!-- Provider Configuration Forms -->
|
||||
<WhatsappEmbeddedSignup
|
||||
v-if="shouldShowEmbeddedSignup(selectedProvider)"
|
||||
/>
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
type="whatsapp"
|
||||
/>
|
||||
<ThreeSixtyDialogWhatsapp
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
|
||||
/>
|
||||
<CloudWhatsapp v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
|
||||
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||
|
||||
const {
|
||||
fbSdkLoaded,
|
||||
processingMessage,
|
||||
isAuthenticating,
|
||||
benefits,
|
||||
showLoader,
|
||||
launchEmbeddedSignup,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
} = useWhatsappEmbeddedSignup();
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<LoadingState v-if="showLoader" :message="processingMessage" />
|
||||
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-start mb-6 text-start">
|
||||
<div class="flex justify-start mb-6">
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 bg-n-alpha-2 rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="whatsappIcon"
|
||||
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
|
||||
class="object-contain w-8 h-8"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm leading-[24px] text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<div
|
||||
v-for="benefit in benefits"
|
||||
:key="benefit.key"
|
||||
class="flex items-center gap-2 text-sm text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
|
||||
{{ benefit.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="!fbSdkLoaded || isAuthenticating"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
class="w-full"
|
||||
@click="launchEmbeddedSignup"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
|
||||
<p v-if="!fbSdkLoaded" class="mt-3 text-xs text-start text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.LOADING_SDK') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,6 +72,13 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
widgetStyle: getWidgetStyle(chatwootSettings.widgetStyle) || 'standard',
|
||||
resetTriggered: false,
|
||||
darkMode: getDarkMode(chatwootSettings.darkMode),
|
||||
welcomeTitle: chatwootSettings.welcomeTitle || '',
|
||||
welcomeDescription: chatwootSettings.welcomeDescription || '',
|
||||
availableMessage: chatwootSettings.availableMessage || '',
|
||||
unavailableMessage: chatwootSettings.unavailableMessage || '',
|
||||
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
|
||||
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
|
||||
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
|
||||
|
||||
toggle(state) {
|
||||
IFrameHelper.events.toggleBubble(state);
|
||||
|
||||
@@ -166,6 +166,13 @@ export const IFrameHelper = {
|
||||
darkMode: window.$chatwoot.darkMode,
|
||||
showUnreadMessagesDialog: window.$chatwoot.showUnreadMessagesDialog,
|
||||
campaignsSnoozedTill,
|
||||
welcomeTitle: window.$chatwoot.welcomeTitle,
|
||||
welcomeDescription: window.$chatwoot.welcomeDescription,
|
||||
availableMessage: window.$chatwoot.availableMessage,
|
||||
unavailableMessage: window.$chatwoot.unavailableMessage,
|
||||
enableFileUpload: window.$chatwoot.enableFileUpload,
|
||||
enableEmojiPicker: window.$chatwoot.enableEmojiPicker,
|
||||
enableEndConversation: window.$chatwoot.enableEndConversation,
|
||||
});
|
||||
IFrameHelper.onLoad({
|
||||
widgetColor: message.config.channelConfig.widgetColor,
|
||||
|
||||
@@ -24,7 +24,10 @@ export default {
|
||||
return { isUploading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
}),
|
||||
fileUploadSizeLimit() {
|
||||
return MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
},
|
||||
@@ -40,6 +43,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
handleClipboardPaste(e) {
|
||||
// If file picker is not enabled, do not allow paste
|
||||
if (!this.shouldShowFilePicker) return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
// items is a DataTransferItemList object which does not have forEach method
|
||||
const itemsArray = Array.from(items);
|
||||
|
||||
@@ -47,11 +47,11 @@ const containerClasses = computed(() => [
|
||||
</div>
|
||||
<h2
|
||||
v-dompurify-html="introHeading"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
|
||||
/>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(introBody)"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -41,9 +41,15 @@ export default {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
|
||||
}),
|
||||
showAttachment() {
|
||||
return this.hasAttachmentsEnabled && this.userInput.length === 0;
|
||||
return (
|
||||
this.shouldShowFilePicker &&
|
||||
this.hasAttachmentsEnabled &&
|
||||
this.userInput.length === 0
|
||||
);
|
||||
},
|
||||
showSendButton() {
|
||||
return this.userInput.length > 0;
|
||||
@@ -143,7 +149,7 @@ export default {
|
||||
:on-attach="onSendAttachment"
|
||||
/>
|
||||
<button
|
||||
v-if="hasEmojiPickerEnabled"
|
||||
v-if="shouldShowEmojiPicker && hasEmojiPickerEnabled"
|
||||
class="flex items-center justify-center min-h-8 min-w-8"
|
||||
:aria-label="$t('EMOJI.ARIA_LABEL')"
|
||||
@click="toggleEmojiPicker"
|
||||
@@ -158,7 +164,7 @@ export default {
|
||||
/>
|
||||
</button>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-if="shouldShowEmojiPicker && showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
|
||||
@@ -23,6 +23,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
canUserEndConversation: 'appConfig/getCanUserEndConversation',
|
||||
}),
|
||||
canLeaveConversation() {
|
||||
return [
|
||||
@@ -82,6 +83,7 @@ export default {
|
||||
<button
|
||||
v-if="
|
||||
canLeaveConversation &&
|
||||
canUserEndConversation &&
|
||||
hasEndConversationEnabled &&
|
||||
showEndConversationButton
|
||||
"
|
||||
|
||||
@@ -29,6 +29,8 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
availableMessage: 'appConfig/getAvailableMessage',
|
||||
unavailableMessage: 'appConfig/getUnavailableMessage',
|
||||
}),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
@@ -40,6 +42,11 @@ export default {
|
||||
id: agent.id,
|
||||
}));
|
||||
},
|
||||
headerMessage() {
|
||||
return this.isOnline
|
||||
? this.availableMessage || this.$t('TEAM_AVAILABILITY.ONLINE')
|
||||
: this.unavailableMessage || this.$t('TEAM_AVAILABILITY.OFFLINE');
|
||||
},
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
@@ -71,12 +78,8 @@ export default {
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-n-slate-12">
|
||||
{{
|
||||
isOnline
|
||||
? $t('TEAM_AVAILABILITY.ONLINE')
|
||||
: $t('TEAM_AVAILABILITY.OFFLINE')
|
||||
}}
|
||||
<div class="font-medium text-n-slate-12 line-clamp-2">
|
||||
{{ headerMessage }}
|
||||
</div>
|
||||
<div class="text-n-slate-11">
|
||||
{{ replyWaitMessage }}
|
||||
|
||||
@@ -119,8 +119,10 @@ export default {
|
||||
>
|
||||
<ChatHeaderExpanded
|
||||
v-if="!isHeaderCollapsed"
|
||||
:intro-heading="channelConfig.welcomeTitle"
|
||||
:intro-body="channelConfig.welcomeTagline"
|
||||
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
|
||||
:intro-body="
|
||||
appConfig.welcomeDescription || channelConfig.welcomeTagline
|
||||
"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,13 @@ const state = {
|
||||
widgetStyle: 'standard',
|
||||
darkMode: 'light',
|
||||
isUpdatingRoute: false,
|
||||
welcomeTitle: '',
|
||||
welcomeDescription: '',
|
||||
availableMessage: '',
|
||||
unavailableMessage: '',
|
||||
enableFileUpload: true,
|
||||
enableEmojiPicker: true,
|
||||
enableEndConversation: true,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -34,6 +41,13 @@ export const getters = {
|
||||
darkMode: $state => $state.darkMode,
|
||||
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
|
||||
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
|
||||
getWelcomeHeading: $state => $state.welcomeTitle,
|
||||
getWelcomeTagline: $state => $state.welcomeDescription,
|
||||
getAvailableMessage: $state => $state.availableMessage,
|
||||
getUnavailableMessage: $state => $state.unavailableMessage,
|
||||
getShouldShowFilePicker: $state => $state.enableFileUpload,
|
||||
getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
|
||||
getCanUserEndConversation: $state => $state.enableEndConversation,
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -46,6 +60,13 @@ export const actions = {
|
||||
showUnreadMessagesDialog,
|
||||
widgetStyle = 'rounded',
|
||||
darkMode = 'light',
|
||||
welcomeTitle = '',
|
||||
welcomeDescription = '',
|
||||
availableMessage = '',
|
||||
unavailableMessage = '',
|
||||
enableFileUpload = true,
|
||||
enableEmojiPicker = true,
|
||||
enableEndConversation = true,
|
||||
}
|
||||
) {
|
||||
commit(SET_WIDGET_APP_CONFIG, {
|
||||
@@ -55,6 +76,13 @@ export const actions = {
|
||||
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
|
||||
widgetStyle,
|
||||
darkMode,
|
||||
welcomeTitle,
|
||||
welcomeDescription,
|
||||
availableMessage,
|
||||
unavailableMessage,
|
||||
enableFileUpload,
|
||||
enableEmojiPicker,
|
||||
enableEndConversation,
|
||||
});
|
||||
},
|
||||
toggleWidgetOpen({ commit }, isWidgetOpen) {
|
||||
@@ -90,6 +118,13 @@ export const mutations = {
|
||||
$state.darkMode = data.darkMode;
|
||||
$state.locale = data.locale || $state.locale;
|
||||
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
|
||||
$state.welcomeTitle = data.welcomeTitle;
|
||||
$state.welcomeDescription = data.welcomeDescription;
|
||||
$state.availableMessage = data.availableMessage;
|
||||
$state.unavailableMessage = data.unavailableMessage;
|
||||
$state.enableFileUpload = data.enableFileUpload;
|
||||
$state.enableEmojiPicker = data.enableEmojiPicker;
|
||||
$state.enableEndConversation = data.enableEndConversation;
|
||||
},
|
||||
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
|
||||
$state.isWidgetOpen = isWidgetOpen;
|
||||
|
||||
@@ -19,6 +19,48 @@ describe('#getters', () => {
|
||||
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getAvailableMessage', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { availableMessage: 'We reply quickly' };
|
||||
expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
|
||||
});
|
||||
});
|
||||
describe('#getWelcomeHeading', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { welcomeTitle: 'Hello!' };
|
||||
expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
|
||||
});
|
||||
});
|
||||
describe('#getWelcomeTagline', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { welcomeDescription: 'Welcome to our site' };
|
||||
expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
|
||||
});
|
||||
});
|
||||
describe('#getShouldShowFilePicker', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableFileUpload: true };
|
||||
expect(getters.getShouldShowFilePicker(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getShouldShowEmojiPicker', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableEmojiPicker: true };
|
||||
expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getCanUserEndConversation', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { enableEndConversation: true };
|
||||
expect(getters.getCanUserEndConversation(state)).toEqual(true);
|
||||
});
|
||||
});
|
||||
describe('#getUnavailableMessage', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { unavailableMessage: 'We are offline' };
|
||||
expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
|
||||
});
|
||||
});
|
||||
describe('#getIsUpdatingRoute', () => {
|
||||
it('returns correct value', () => {
|
||||
const state = { isUpdatingRoute: true };
|
||||
|
||||
@@ -5,6 +5,7 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
|
||||
if channel_is_inactive?(channel)
|
||||
Rails.logger.info("Channel is inactive: #{channel.inspect}")
|
||||
Rails.logger.warn("Inactive WhatsApp channel: #{channel&.phone_number || "unknown - #{params[:phone_number]}"}")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -66,7 +66,6 @@ class Channel::WebWidget < ApplicationRecord
|
||||
var BASE_URL=\"#{ENV.fetch('FRONTEND_URL', '')}\";
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src=BASE_URL+\"/packs/js/sdk.js\";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
# Validate required parameters
|
||||
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
|
||||
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
|
||||
end
|
||||
|
||||
GlobalConfig.clear_cache
|
||||
# Exchange code for user access token
|
||||
access_token = exchange_code_for_token
|
||||
|
||||
# Use the provided business info directly (more efficient)
|
||||
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
|
||||
|
||||
# Validate that the token has access to the provided WABA (security check)
|
||||
validate_token_waba_access(access_token, @waba_id)
|
||||
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
create_or_update_channel(waba_info, phone_info, access_token)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def whatsapp_api_version
|
||||
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/oauth/access_token",
|
||||
{
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: @code
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token exchange failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
raise "No access token in response: #{data}" unless data['access_token']
|
||||
|
||||
data['access_token']
|
||||
end
|
||||
|
||||
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
|
||||
# Get all phone numbers for the WABA
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/phone_numbers",
|
||||
{ access_token: access_token }
|
||||
)
|
||||
|
||||
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
phone_numbers = data['data']
|
||||
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
|
||||
|
||||
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
|
||||
|
||||
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: "+#{display_phone_number}",
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def create_or_update_channel(waba_info, phone_info, access_token)
|
||||
existing_channel = find_existing_channel(phone_info[:phone_number])
|
||||
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
|
||||
|
||||
if existing_channel
|
||||
Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
|
||||
raise "Channel already exists: #{existing_channel.phone_number}"
|
||||
else
|
||||
channel = create_new_channel(channel_attributes, phone_info)
|
||||
register_phone_number(phone_info[:phone_number_id], access_token)
|
||||
override_waba_webhook(waba_info[:waba_id], channel, access_token)
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, access_token)
|
||||
HTTParty.post(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
|
||||
{
|
||||
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
|
||||
body: { messaging_product: 'whatsapp', pin: '212834' }.to_json
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def find_existing_channel(phone_number)
|
||||
Channel::Whatsapp.find_by(account: @account, phone_number: phone_number)
|
||||
end
|
||||
|
||||
def build_channel_attributes(waba_info, phone_info, access_token)
|
||||
{
|
||||
phone_number: phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
api_key: access_token,
|
||||
phone_number_id: phone_info[:phone_number_id],
|
||||
business_account_id: waba_info[:waba_id],
|
||||
source: 'embedded_signup'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def create_new_channel(attributes, phone_info)
|
||||
channel = Channel::Whatsapp.create!(account: @account, **attributes)
|
||||
create_inbox_for_channel(channel, phone_info)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
|
||||
def create_inbox_for_channel(channel, phone_info)
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: "#{phone_info[:business_name]} WhatsApp",
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
|
||||
def validate_token_waba_access(access_token, waba_id)
|
||||
token_debug_data = fetch_token_debug_data(access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope, waba_id)
|
||||
end
|
||||
|
||||
def fetch_token_debug_data(access_token)
|
||||
response = Faraday.get(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
|
||||
{
|
||||
input_token: access_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
raise "Token validation failed: #{response.body}" unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope, waba_id)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
|
||||
def build_app_access_token
|
||||
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
|
||||
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
"#{app_id}|#{app_secret}"
|
||||
end
|
||||
|
||||
def override_waba_webhook(waba_id, channel, access_token)
|
||||
callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
|
||||
verify_token = channel.provider_config['webhook_verify_token']
|
||||
|
||||
response = HTTParty.post(
|
||||
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
|
||||
{
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
},
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
}
|
||||
)
|
||||
|
||||
return if response.success?
|
||||
|
||||
Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
|
||||
raise "Webhook override failed: #{response.body}"
|
||||
end
|
||||
end
|
||||
@@ -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'] %>
|
||||
|
||||
@@ -29,7 +29,6 @@ window.chatwootSettings = {
|
||||
var BASE_URL = '';
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src= BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -30,7 +30,6 @@ window.chatwootSettings = {
|
||||
var BASE_URL = '';
|
||||
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
|
||||
g.src= BASE_URL + "/packs/js/sdk.js";
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
s.parentNode.insertBefore(g,s);
|
||||
g.onload=function(){
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -486,6 +486,12 @@ Rails.application.routes.draw do
|
||||
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
|
||||
post 'webhooks/instagram', to: 'webhooks/instagram#events'
|
||||
|
||||
namespace :whatsapp do
|
||||
get 'signup', to: 'embedded#new'
|
||||
get 'signup/callback', to: 'embedded#callback'
|
||||
post 'embedded_signup', to: 'embedded#embedded_signup'
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
@@ -103,6 +103,12 @@ slack:
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Embedded API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /whatsapp/signup' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
it 'returns configuration for embedded signup' do
|
||||
get '/whatsapp/signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { account_id: account.id }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response).to include(
|
||||
'status' => 'ready',
|
||||
'app_id' => 'test_app_id',
|
||||
'config_id' => 'test_config_id',
|
||||
'app_secret' => 'test_app_secret'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get '/whatsapp/signup', params: { account_id: account.id }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /whatsapp/embedded_signup' do
|
||||
let(:params) do
|
||||
{
|
||||
account_id: account.id,
|
||||
code: 'auth_code_123',
|
||||
business_id: '123456789',
|
||||
waba_id: '987654321',
|
||||
phone_number_id: '555444333'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
context 'with missing authorization code' do
|
||||
it 'returns bad request error' do
|
||||
params_without_code = params.except(:code)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_code
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Missing authorization code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing business parameters' do
|
||||
it 'returns bad request when business_id is missing' do
|
||||
params_without_business = params.except(:business_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_business
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
|
||||
it 'returns bad request when waba_id is missing' do
|
||||
params_without_waba = params.except(:waba_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_waba
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post '/whatsapp/embedded_signup', params: params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,700 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::EmbeddedSignupService do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { 'test_authorization_code' }
|
||||
let(:business_id) { 'test_business_id' }
|
||||
let(:waba_id) { 'test_waba_id' }
|
||||
let(:phone_number_id) { 'test_phone_number_id' }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:app_id) { 'test_app_id' }
|
||||
let(:app_secret) { 'test_app_secret' }
|
||||
let(:api_version) { 'v22.0' }
|
||||
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock global configuration
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
# Mock environment variables - allow any calls to ENV.fetch
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
|
||||
allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
|
||||
|
||||
# Mock ChatwootApp enterprise checks
|
||||
allow(ChatwootApp).to receive(:enterprise?).and_return(false)
|
||||
|
||||
# NOTE: Specific HTTP request stubs are defined in individual test contexts
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when all parameters are valid' do
|
||||
before do
|
||||
# Stub the token exchange
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the phone numbers fetch
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the token validation
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the provider validation request (WhatsApp Cloud)
|
||||
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the phone number registration
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the webhook subscription
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'successfully creates a new WhatsApp channel' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
expect(channel).not_to be_nil
|
||||
expect(channel.provider).to eq('whatsapp_cloud')
|
||||
expect(channel.provider_config['api_key']).to eq(access_token)
|
||||
expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
|
||||
expect(channel.provider_config['business_account_id']).to eq(waba_id)
|
||||
expect(channel.provider_config['source']).to eq('embedded_signup')
|
||||
end
|
||||
|
||||
it 'creates an inbox for the channel' do
|
||||
service.perform
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
inbox = Inbox.find_by(account: account, channel: channel)
|
||||
expect(inbox).not_to be_nil
|
||||
expect(inbox.name).to eq('Test Business WhatsApp')
|
||||
end
|
||||
|
||||
it 'registers the phone number' do
|
||||
service.perform
|
||||
|
||||
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.with(
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
pin: '212834'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets up webhook subscription' do
|
||||
service.perform
|
||||
|
||||
channel = Channel::Whatsapp.find_by(account: account, phone_number: '+1234567890')
|
||||
callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
|
||||
|
||||
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.with(
|
||||
body: hash_including(
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: channel.provider_config['webhook_verify_token']
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises an error when code is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: '',
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when business_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: '',
|
||||
waba_id: waba_id,
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when waba_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: '',
|
||||
phone_number_id: phone_number_id
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
|
||||
it 'raises an error when phone_number_id is missing' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
code: code,
|
||||
business_id: business_id,
|
||||
waba_id: waba_id,
|
||||
phone_number_id: ''
|
||||
)
|
||||
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel already exists' do
|
||||
before do
|
||||
# Stub all the required requests for successful flow
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub 360Dialog provider validation (for existing channel creation)
|
||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { templates: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Create existing channel
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890')
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Channel already exists/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token exchange fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token exchange failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token has no access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone numbers fetch fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook override fails' do
|
||||
before do
|
||||
# Stub all the successful requests
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
# Stub the failing webhook request
|
||||
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
|
||||
.to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.perform }.to raise_error(/Webhook override failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#exchange_code_for_token' do
|
||||
context 'when token exchange is successful' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { access_token: access_token }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the access token' do
|
||||
result = service.send(:exchange_code_for_token)
|
||||
expect(result).to eq(access_token)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response has no access token' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
|
||||
.with(query: hash_including(
|
||||
'client_id' => app_id,
|
||||
'client_secret' => app_secret,
|
||||
'code' => code
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { some_other_field: 'value' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_phone_info_via_waba' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: phone_number_id,
|
||||
display_phone_number: '1234567890',
|
||||
verified_name: 'Test Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns formatted phone info' do
|
||||
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
expect(result).to eq({
|
||||
phone_number_id: phone_number_id,
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
})
|
||||
end
|
||||
|
||||
context 'when specific phone number is not found' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 'different_phone_id',
|
||||
display_phone_number: '9876543210',
|
||||
verified_name: 'Different Business',
|
||||
code_verification_status: 'VERIFIED'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses the first available phone number' do
|
||||
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
expect(result[:phone_number_id]).to eq('different_phone_id')
|
||||
expect(result[:phone_number]).to eq('+9876543210')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no phone numbers are available' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
|
||||
.with(query: hash_including('access_token' => access_token))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: [] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
|
||||
end.to raise_error(/No phone numbers found for WABA/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#validate_token_waba_access' do
|
||||
context 'when token has access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: [waba_id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'validates successfully when token has access' do
|
||||
expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token validation fails' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/Token validation failed/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when token does not have access to WABA' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'whatsapp_business_management',
|
||||
target_ids: ['different_waba_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error when WABA ID is not in target_ids' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/Token does not have access to WABA/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no WABA scope is found' do
|
||||
before do
|
||||
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
|
||||
.with(query: hash_including(
|
||||
'input_token' => access_token,
|
||||
'access_token' => "#{app_id}|#{app_secret}"
|
||||
))
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
granular_scopes: [
|
||||
{
|
||||
scope: 'some_other_scope',
|
||||
target_ids: ['some_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
service.send(:validate_token_waba_access, access_token, waba_id)
|
||||
end.to raise_error(/No WABA scope found in token/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user