Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c5d40b2c | ||
|
|
d696fa2b17 | ||
|
|
358ec8120b | ||
|
|
5ebe8c71ec | ||
|
|
c73f33619d | ||
|
|
8c78573d9d | ||
|
|
c553997af8 | ||
|
|
c8f0cbbfa1 | ||
|
|
30a3a35281 | ||
|
|
340fcb6152 | ||
|
|
cacd192714 | ||
|
|
b531dd0461 | ||
|
|
cb3721a425 | ||
|
|
23ed1cc17f | ||
|
|
d32d47b41b | ||
|
|
a1a3f62d1b | ||
|
|
ccff83e253 | ||
|
|
fdbf8b52f3 | ||
|
|
8544c87cfd | ||
|
|
b04ae3e251 | ||
|
|
f0f3e615b3 | ||
|
|
a1ce805fa5 | ||
|
|
e326a7a8c8 | ||
|
|
be8a441bfc | ||
|
|
212f07711d | ||
|
|
e6012d7e86 | ||
|
|
9d86792468 | ||
|
|
6e1c3ee176 | ||
|
|
2f8214eb87 | ||
|
|
48293001ed | ||
|
|
83cd7b6788 | ||
|
|
65bb584f48 | ||
|
|
9ad1b219a3 | ||
|
|
f812ca3b99 | ||
|
|
c6df54cc29 | ||
|
|
384e616c1b | ||
|
|
34a2eff135 | ||
|
|
1c09cc5aa5 | ||
|
|
5bf5a0e0d0 | ||
|
|
e3d76fa622 | ||
|
|
4f123e68a8 | ||
|
|
27284dd69e | ||
|
|
a5321376e7 | ||
|
|
20f63cf957 | ||
|
|
712054a8b7 | ||
|
|
7a4dfd5d4b | ||
|
|
a17b7815e3 | ||
|
|
8025cd5aab | ||
|
|
7319d822fd | ||
|
|
23a06739fc | ||
|
|
0771d0f269 | ||
|
|
eb6c4a258a | ||
|
|
317a8ee4d5 | ||
|
|
4a7a68e6e7 | ||
|
|
4c21a8d7b4 | ||
|
|
71ddd6c2c2 | ||
|
|
34852fccc1 | ||
|
|
149085e3d8 | ||
|
|
e0b52c3fc2 | ||
|
|
806ddaa9ab | ||
|
|
f24514433b | ||
|
|
700ce0540a | ||
|
|
7cc082e586 | ||
|
|
392fa50aee | ||
|
|
3582d47299 | ||
|
|
06e4708e12 | ||
|
|
9e40a79958 | ||
|
|
947e4b59e8 | ||
|
|
8816e8651b | ||
|
|
f429db89b4 | ||
|
|
203c010846 | ||
|
|
e1fdc30c31 | ||
|
|
1eca659b93 | ||
|
|
7a70cea36b | ||
|
|
7c4e8346f8 | ||
|
|
e4cd585513 | ||
|
|
9e5e90c258 | ||
|
|
c8a66b9ebb | ||
|
|
454686ab5d |
@@ -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 |
@@ -133,7 +133,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'multi_select'"
|
||||
@@ -152,7 +156,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="inputType === 'email'"
|
||||
|
||||
@@ -203,7 +203,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
multiple
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
@@ -211,7 +211,11 @@ export default {
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="inputType === 'search_select'"
|
||||
@@ -221,7 +225,7 @@ export default {
|
||||
v-model="values"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Select"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
@@ -229,7 +233,11 @@ export default {
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
:option-height="104"
|
||||
/>
|
||||
>
|
||||
<template #noOptions>
|
||||
{{ $t('FORMS.MULTISELECT.NO_OPTIONS') }}
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<div v-else-if="inputType === 'date'" class="multiselect-wrap--small">
|
||||
<input
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
teams,
|
||||
labels,
|
||||
statusFilterOptions,
|
||||
messageTypeOptions,
|
||||
priorityOptions,
|
||||
campaigns,
|
||||
contacts,
|
||||
inboxes,
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
countries,
|
||||
slaPolicies,
|
||||
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
|
||||
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
@@ -71,7 +72,9 @@ describe('useAutomation', () => {
|
||||
case 'country_code':
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return MESSAGE_CONDITION_VALUES;
|
||||
return messageTypeOptions;
|
||||
case 'priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -93,6 +96,8 @@ describe('useAutomation', () => {
|
||||
return [];
|
||||
case 'add_sla':
|
||||
return slaPolicies;
|
||||
case 'change_priority':
|
||||
return priorityOptions;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -218,8 +223,9 @@ describe('useAutomation', () => {
|
||||
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
|
||||
expect(getConditionDropdownValues('country_code')).toEqual(countries);
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
MESSAGE_CONDITION_VALUES
|
||||
messageTypeOptions
|
||||
);
|
||||
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('gets action dropdown values correctly', () => {
|
||||
@@ -231,6 +237,7 @@ describe('useAutomation', () => {
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
|
||||
});
|
||||
|
||||
it('handles event change correctly', () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
describe('useMacros', () => {
|
||||
const mockLabels = [
|
||||
@@ -148,9 +151,11 @@ describe('useMacros', () => {
|
||||
|
||||
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(
|
||||
PRIORITY_CONDITION_VALUES
|
||||
);
|
||||
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
|
||||
});
|
||||
|
||||
it('returns an empty array for unknown types', () => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
getActionOptions,
|
||||
getConditionOptions,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||
@@ -60,6 +64,20 @@ export default function useAutomationValues() {
|
||||
];
|
||||
});
|
||||
|
||||
const messageTypeOptions = computed(() =>
|
||||
MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a translated "None" option to the beginning of a list
|
||||
* @param {Array} list - The list to add "None" to
|
||||
@@ -87,6 +105,8 @@ export default function useAutomationValues() {
|
||||
customAttributes: getters['attributes/getAttributes'].value,
|
||||
inboxes: inboxes.value,
|
||||
statusFilterOptions: statusFilterOptions.value,
|
||||
priorityOptions: priorityOptions.value,
|
||||
messageTypeOptions: messageTypeOptions.value,
|
||||
teams: teams.value,
|
||||
languages,
|
||||
countries,
|
||||
@@ -108,6 +128,7 @@ export default function useAutomationValues() {
|
||||
languages,
|
||||
type,
|
||||
addNoneToListFn: addNoneToList,
|
||||
priorityOptions: priorityOptions.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,6 +136,8 @@ export default function useAutomationValues() {
|
||||
booleanFilterOptions,
|
||||
statusFilterItems,
|
||||
statusFilterOptions,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
getConditionDropdownValues,
|
||||
getActionDropdownValues,
|
||||
agents,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useMacros = () => {
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
@@ -32,7 +34,10 @@ export const useMacros = () => {
|
||||
name: i.title,
|
||||
}));
|
||||
case 'change_priority':
|
||||
return PRIORITY_CONDITION_VALUES;
|
||||
return PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
|
||||
}));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export function useWhatsappEmbeddedSignup() {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const authHeaders = computed(() => {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
key: 'EASY_SETUP',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
|
||||
},
|
||||
{
|
||||
key: 'SECURE_AUTH',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
|
||||
},
|
||||
{
|
||||
key: 'AUTO_CONFIG',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
|
||||
},
|
||||
]);
|
||||
|
||||
const showLoader = computed(
|
||||
() => isAuthenticating.value || isProcessing.value
|
||||
);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
let message = t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED');
|
||||
if (data.data?.current_step) {
|
||||
message += ` (Step: ${data.data.current_step})`;
|
||||
}
|
||||
|
||||
useAlert(message);
|
||||
};
|
||||
|
||||
const handleSignupSuccess = inboxData => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
// Update the store with the new inbox data
|
||||
if (inboxData && inboxData.id) {
|
||||
// Add the new inbox to the store
|
||||
store.commit('inboxes/ADD_INBOXES', inboxData);
|
||||
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Fallback if inbox data is not properly formatted
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send both auth code and business info together (synchronous flow)
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/embedded_signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
authCode.value = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
handleSignupSuccess(responseData);
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleSignupError({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
(businessDataLocal.business_id || businessDataLocal.businessId) &&
|
||||
(businessDataLocal.waba_id || businessDataLocal.wabaId)
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessDataLocal = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessDataLocal) {
|
||||
businessDataLocal = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id:
|
||||
businessDataLocal.business_id || businessDataLocal.businessId,
|
||||
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
|
||||
phone_number_id:
|
||||
businessDataLocal.phone_number_id ||
|
||||
businessDataLocal.phoneNumberId ||
|
||||
businessDataLocal.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
businessData.value = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(normalizedData);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation(data);
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleSignupError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Handle Facebook embedded signup message events
|
||||
try {
|
||||
const originUrl = new URL(event.origin);
|
||||
const allowedHosts = ['facebook.com', 'www.facebook.com'];
|
||||
if (!allowedHosts.includes(originUrl.hostname)) return;
|
||||
} catch (error) {
|
||||
// Invalid origin URL, reject the event
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
};
|
||||
|
||||
// Facebook SDK
|
||||
const loadFacebookSdk = () => {
|
||||
if (window.FB) {
|
||||
fbSdkLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = () => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
if (!window.FB) {
|
||||
loadFacebookSdk();
|
||||
setTimeout(() => launchEmbeddedSignup(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = () => {
|
||||
loadFacebookSdk();
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fbSdkLoaded,
|
||||
isProcessing,
|
||||
processingMessage,
|
||||
authCodeReceived,
|
||||
authCode,
|
||||
businessData,
|
||||
isAuthenticating,
|
||||
|
||||
// Computed
|
||||
benefits,
|
||||
showLoader,
|
||||
|
||||
// Methods
|
||||
launchEmbeddedSignup,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export function useWhatsappReauthorization(inboxId) {
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const authHeaders = computed(() => {
|
||||
if (Auth.hasAuthCookie()) {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData();
|
||||
return {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const showLoader = computed(
|
||||
() => isAuthenticating.value || isProcessing.value
|
||||
);
|
||||
|
||||
// Error handling
|
||||
const handleReauthorizationError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleReauthorizationCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
|
||||
};
|
||||
|
||||
const handleReauthorizationSuccess = async () => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
// Refresh the inbox to update reauthorization status
|
||||
await store.dispatch('inboxes/get');
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.SUCCESS'));
|
||||
|
||||
// Reload the page to reflect the changes
|
||||
router.go(0);
|
||||
};
|
||||
|
||||
// Reauthorization flow
|
||||
const completeReauthorizationFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleReauthorizationError({
|
||||
error: t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
// Send reauthorization request
|
||||
const accountId = store.getters.getCurrentAccountId;
|
||||
const response = await fetch('/whatsapp/reauthorize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content'),
|
||||
...authHeaders.value,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
inbox_id: inboxId,
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Clear the stored auth code for security
|
||||
authCode.value = null;
|
||||
|
||||
// Handle synchronous success response
|
||||
handleReauthorizationSuccess();
|
||||
} else {
|
||||
throw new Error(responseData.message || responseData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleReauthorizationError({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
(businessDataLocal.business_id || businessDataLocal.businessId) &&
|
||||
(businessDataLocal.waba_id || businessDataLocal.wabaId)
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
// Handle different embedded signup events per Facebook documentation
|
||||
if (data.event === 'FINISH') {
|
||||
// Facebook might send business data in different structures
|
||||
let businessDataLocal = data.data;
|
||||
|
||||
// If data.data doesn't exist, try other possible structures
|
||||
if (!businessDataLocal) {
|
||||
businessDataLocal = data.business_data || data.details || data;
|
||||
}
|
||||
|
||||
// Validate we have the required business information
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
// Normalize the data structure to match our backend expectations
|
||||
const normalizedData = {
|
||||
business_id:
|
||||
businessDataLocal.business_id || businessDataLocal.businessId,
|
||||
waba_id: businessDataLocal.waba_id || businessDataLocal.wabaId,
|
||||
phone_number_id:
|
||||
businessDataLocal.phone_number_id ||
|
||||
businessDataLocal.phoneNumberId ||
|
||||
businessDataLocal.phone_id,
|
||||
};
|
||||
|
||||
// Store business data
|
||||
businessData.value = normalizedData;
|
||||
// Check if we already have auth code and process immediately
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeReauthorizationFlow(normalizedData);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleReauthorizationError({
|
||||
error: t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleReauthorizationCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleReauthorizationError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.ERROR'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
// Authorization code received from Facebook
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
// Check if we already have business data and process immediately
|
||||
if (businessData.value) {
|
||||
completeReauthorizationFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleReauthorizationError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Handle Facebook embedded signup message events
|
||||
try {
|
||||
const originUrl = new URL(event.origin);
|
||||
const allowedHosts = ['facebook.com', 'www.facebook.com'];
|
||||
if (!allowedHosts.includes(originUrl.hostname)) return;
|
||||
} catch (error) {
|
||||
// Invalid origin URL, reject the event
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle non-JSON messages silently
|
||||
}
|
||||
};
|
||||
|
||||
// Facebook SDK
|
||||
const loadFacebookSdk = () => {
|
||||
if (window.FB) {
|
||||
fbSdkLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://connect.facebook.net/en_US/sdk.js';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
status: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const launchReauthorization = () => {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.SETTINGS.WHATSAPP_REAUTHORIZE.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
if (!window.FB) {
|
||||
loadFacebookSdk();
|
||||
setTimeout(() => launchReauthorization(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating.value = true;
|
||||
|
||||
// Following Facebook's embedded signup documentation
|
||||
// Using auth_type: 'reauthorize' to force reauthorization
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
auth_type: 'reauthorize',
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '', // Leave empty for default flow
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = () => {
|
||||
loadFacebookSdk();
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
fbSdkLoaded,
|
||||
isProcessing,
|
||||
processingMessage,
|
||||
isAuthenticating,
|
||||
|
||||
// Computed
|
||||
showLoader,
|
||||
|
||||
// Methods
|
||||
launchReauthorization,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
};
|
||||
}
|
||||
@@ -38,11 +38,13 @@ export const DEFAULT_ACTIONS = [
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
name: 'Incoming',
|
||||
i18nKey: 'INCOMING',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
name: 'Outgoing',
|
||||
i18nKey: 'OUTGOING',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,21 +52,26 @@ export const PRIORITY_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
i18nKey: 'NONE',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'Low',
|
||||
i18nKey: 'LOW',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
i18nKey: 'MEDIUM',
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
name: 'High',
|
||||
i18nKey: 'HIGH',
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
name: 'Urgent',
|
||||
i18nKey: 'URGENT',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
import filterQueryGenerator from './filterQueryGenerator';
|
||||
import actionQueryGenerator from './actionQueryGenerator';
|
||||
@@ -103,6 +101,7 @@ export const getActionOptions = ({
|
||||
slaPolicies,
|
||||
type,
|
||||
addNoneToListFn,
|
||||
priorityOptions,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
@@ -110,7 +109,7 @@ export const getActionOptions = ({
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
change_priority: PRIORITY_CONDITION_VALUES,
|
||||
change_priority: priorityOptions,
|
||||
add_sla: slaPolicies,
|
||||
};
|
||||
return actionsMap[type];
|
||||
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
|
||||
statusFilterOptions,
|
||||
teams,
|
||||
type,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
}) => {
|
||||
if (isCustomAttributeCheckbox(customAttributes, type)) {
|
||||
return booleanFilterOptions;
|
||||
@@ -147,8 +148,8 @@ export const getConditionOptions = ({
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: MESSAGE_CONDITION_VALUES,
|
||||
priority: PRIORITY_CONDITION_VALUES,
|
||||
message_type: messageTypeOptions,
|
||||
priority: priorityOptions,
|
||||
};
|
||||
|
||||
return conditionFilterMaps[type];
|
||||
|
||||
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
|
||||
|
||||
import allCountries from 'shared/constants/countries.js';
|
||||
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
export const customAttributes = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -468,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',
|
||||
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
|
||||
];
|
||||
export const languages = allLanguages;
|
||||
export const countries = allCountries;
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
|
||||
id: item.id,
|
||||
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
|
||||
}));
|
||||
|
||||
export const automationToSubmit = {
|
||||
name: 'Fayaz',
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
"NUMBER": "Number",
|
||||
"LINK": "Link",
|
||||
"DATE": "Date",
|
||||
"LIST": "List",
|
||||
"CHECKBOX": "Checkbox"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add Custom Attribute",
|
||||
"SUBMIT": "Create",
|
||||
|
||||
@@ -150,6 +150,17 @@
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
"OUTGOING": "Outgoing Message"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "None",
|
||||
"LOW": "Low",
|
||||
"MEDIUM": "Medium",
|
||||
"HIGH": "High",
|
||||
"URGENT": "Urgent"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"MESSAGE_TYPE": "Message Type",
|
||||
"MESSAGE_CONTAINS": "Message Contains",
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
"MULTISELECT": {
|
||||
"ENTER_TO_SELECT": "Press enter to select",
|
||||
"ENTER_TO_REMOVE": "Press enter to remove",
|
||||
"NO_OPTIONS": "List is empty",
|
||||
"SELECT_ONE": "Select one",
|
||||
"SELECT": "Select"
|
||||
}
|
||||
|
||||
@@ -222,10 +222,17 @@
|
||||
"DESC": "Start supporting your customers via WhatsApp.",
|
||||
"PROVIDERS": {
|
||||
"LABEL": "API Provider",
|
||||
"WHATSAPP_EMBEDDED": "WhatsApp Business",
|
||||
"TWILIO": "Twilio",
|
||||
"WHATSAPP_CLOUD": "WhatsApp Cloud",
|
||||
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"360_DIALOG": "360Dialog"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
|
||||
},
|
||||
"INBOX_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
@@ -264,6 +271,28 @@
|
||||
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "No manual configuration required",
|
||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"SIGNUP_ERROR": "Signup error occurred",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
@@ -571,7 +600,24 @@
|
||||
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
|
||||
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
|
||||
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
"WHATSAPP_RECONFIGURE_TITLE": "Reconfigure WhatsApp",
|
||||
"WHATSAPP_RECONFIGURE_SUBHEADER": "Update your WhatsApp Business account configuration using the embedded signup flow.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure with WhatsApp",
|
||||
"WHATSAPP_RECONFIGURE_ERROR": "Failed to initiate WhatsApp reconfiguration. Please try again.",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
|
||||
"WHATSAPP_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize WhatsApp Connection",
|
||||
"DESCRIPTION": "Your WhatsApp Business account needs to be reauthorized to continue receiving messages. Click the button below to reconnect your account.",
|
||||
"AUTH_PROCESSING": "Processing WhatsApp reauthorization...",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Waiting for business information...",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"PROCESSING": "Updating your WhatsApp configuration...",
|
||||
"SUCCESS": "WhatsApp successfully reauthorized!",
|
||||
"ERROR": "Failed to reauthorize WhatsApp. Please try again.",
|
||||
"CANCELLED": "WhatsApp reauthorization was cancelled.",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please try again.",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received. Please try again."
|
||||
}
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
"LABEL": "Help Center",
|
||||
@@ -591,6 +637,12 @@
|
||||
"MESSAGE_SUCCESS": "Reconnection successful",
|
||||
"MESSAGE_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"WHATSAPP_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize WhatsApp",
|
||||
"SUBTITLE": "Your WhatsApp connection has expired, please reconnect your WhatsApp Business account to continue services",
|
||||
"MESSAGE_SUCCESS": "WhatsApp reconnection successful",
|
||||
"MESSAGE_ERROR": "There was an error reconnecting WhatsApp, please try again"
|
||||
},
|
||||
"PRE_CHAT_FORM": {
|
||||
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
|
||||
"SET_FIELDS": "Pre chat form fields",
|
||||
|
||||
@@ -99,6 +99,13 @@
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"SEND_WEBHOOK_EVENT": "Send Webhook Event"
|
||||
},
|
||||
"PRIORITY_TYPES": {
|
||||
"NONE": "None",
|
||||
"LOW": "Low",
|
||||
"MEDIUM": "Medium",
|
||||
"HIGH": "High",
|
||||
"URGENT": "Urgent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
@@ -53,6 +51,18 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'getUIFlags',
|
||||
}),
|
||||
models() {
|
||||
return ATTRIBUTE_MODELS.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_MODELS.${item.key}`),
|
||||
}));
|
||||
},
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
|
||||
@@ -113,7 +113,11 @@ const tableHeaders = computed(() => {
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_type }}
|
||||
{{
|
||||
$t(
|
||||
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
|
||||
@@ -32,7 +32,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
@@ -59,6 +58,12 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
@@ -84,9 +89,9 @@ export default {
|
||||
selectedAttributeType() {
|
||||
return this.types.find(
|
||||
item =>
|
||||
item.option.toLowerCase() ===
|
||||
item.key.toLowerCase() ===
|
||||
this.selectedAttribute.attribute_display_type
|
||||
).id;
|
||||
)?.id;
|
||||
},
|
||||
keyErrorMessage() {
|
||||
if (!this.v$.attributeKey.isKey) {
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
export const ATTRIBUTE_MODELS = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Conversation',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Contact',
|
||||
},
|
||||
{ id: 0, key: 'CONVERSATION' },
|
||||
{ id: 1, key: 'CONTACT' },
|
||||
];
|
||||
|
||||
export const ATTRIBUTE_TYPES = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Text',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Number',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
option: 'Link',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
option: 'Date',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
option: 'List',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
option: 'Checkbox',
|
||||
},
|
||||
{ id: 0, key: 'TEXT' },
|
||||
{ id: 1, key: 'NUMBER' },
|
||||
{ id: 4, key: 'LINK' },
|
||||
{ id: 5, key: 'DATE' },
|
||||
{ id: 6, key: 'LIST' },
|
||||
{ id: 7, key: 'CHECKBOX' },
|
||||
];
|
||||
|
||||
@@ -47,6 +47,13 @@ export default {
|
||||
this.currentInbox.provider === 'whatsapp_cloud'
|
||||
);
|
||||
},
|
||||
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
|
||||
shouldShowWhatsAppWebhookDetails() {
|
||||
return (
|
||||
this.isWhatsAppCloudInbox &&
|
||||
this.currentInbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
},
|
||||
message() {
|
||||
if (this.isATwilioInbox) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
@@ -66,7 +73,7 @@ export default {
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (this.isWhatsAppCloudInbox) {
|
||||
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
|
||||
)}`;
|
||||
@@ -113,8 +120,11 @@ export default {
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<p class="mt-8 font-medium text-n-slate-11">
|
||||
<div
|
||||
v-if="shouldShowWhatsAppWebhookDetails"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
|
||||
</p>
|
||||
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
||||
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
||||
import WhatsappReauthorize from './channels/whatsapp/Reauthorize.vue';
|
||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
@@ -45,6 +46,7 @@ export default {
|
||||
NextButton,
|
||||
InstagramReauthorize,
|
||||
DuplicateInboxBanner,
|
||||
WhatsappReauthorize,
|
||||
Editor,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
@@ -231,6 +233,11 @@ export default {
|
||||
facebookUnauthorized() {
|
||||
return this.isAFacebookInbox && this.inbox.reauthorization_required;
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel && this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
googleUnauthorized() {
|
||||
const isLegacyInbox = ['imap.gmail.com', 'imap.google.com'].includes(
|
||||
this.inbox.imap_address
|
||||
@@ -410,6 +417,7 @@ export default {
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
||||
<WhatsappReauthorize v-if="whatsappUnauthorized" :inbox="inbox" />
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
|
||||
@@ -1,48 +1,140 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import whatsappIcon from 'dashboard/assets/images/whatsapp.png';
|
||||
import twilioIcon from 'dashboard/assets/images/twilio.png';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP_CLOUD: 'whatsapp_cloud',
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProvider = computed(() => route.query.provider);
|
||||
|
||||
const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
value: PROVIDER_TYPES.WHATSAPP,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: whatsappIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: 'whatsapp_cloud',
|
||||
};
|
||||
{
|
||||
value: PROVIDER_TYPES.TWILIO,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: twilioIcon,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: { provider: providerValue },
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
return provider === PROVIDER_TYPES.WHATSAPP && !hasWhatsappAppId.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
||||
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
|
||||
/>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
|
||||
<select v-model="provider">
|
||||
<option value="whatsapp_cloud">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
|
||||
</option>
|
||||
<option value="twilio">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Provider Selection View -->
|
||||
<div v-if="showProviderSelection">
|
||||
<div class="mb-10 text-left">
|
||||
<h1 class="mb-2 text-lg font-medium text-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
|
||||
</h1>
|
||||
<p class="text-sm leading-relaxed text-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Provider Cards -->
|
||||
<div class="flex justify-start gap-6">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.value"
|
||||
class="gap-6 px-5 py-6 transition-all duration-200 border cursor-pointer w-96 border-n-weak rounded-2xl hover:bg-n-slate-3"
|
||||
@click="selectProvider(provider.value)"
|
||||
>
|
||||
<!-- Provider Icon -->
|
||||
<div class="flex justify-start mb-5">
|
||||
<div
|
||||
class="flex items-center justify-center size-10 bg-n-alpha-2 rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="provider.icon"
|
||||
:alt="provider.label"
|
||||
class="object-contain size-[26px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content -->
|
||||
<div class="text-start">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
|
||||
{{ provider.label }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-11">
|
||||
{{ provider.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
|
||||
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
|
||||
<CloudWhatsapp v-else />
|
||||
<!-- Configuration View -->
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="py-5 px-6 border bg-n-solid-2 border-n-weak rounded-2xl">
|
||||
<!-- Provider Configuration Forms -->
|
||||
<WhatsappEmbeddedSignup
|
||||
v-if="shouldShowEmbeddedSignup(selectedProvider)"
|
||||
/>
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
type="whatsapp"
|
||||
/>
|
||||
<ThreeSixtyDialogWhatsapp
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
|
||||
/>
|
||||
<CloudWhatsapp v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+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>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const {
|
||||
processingMessage,
|
||||
showLoader,
|
||||
launchReauthorization,
|
||||
initialize,
|
||||
cleanupMessageListener,
|
||||
} = useWhatsappReauthorization(props.inbox.id);
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="launchReauthorization"
|
||||
/>
|
||||
<LoadingState v-if="showLoader" :message="processingMessage" />
|
||||
</div>
|
||||
</template>
|
||||
+41
@@ -7,6 +7,7 @@ import SmtpSettings from '../SmtpSettings.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useWhatsappReauthorization } from 'dashboard/composables/useWhatsappReauthorization';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -34,6 +35,20 @@ export default {
|
||||
validations: {
|
||||
whatsAppInboxAPIKey: { required },
|
||||
},
|
||||
computed: {
|
||||
isWhatsAppCloudChannel() {
|
||||
return this.inbox.provider === 'whatsapp_cloud';
|
||||
},
|
||||
hasWhatsappAppId() {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
},
|
||||
shouldShowReconfigureButton() {
|
||||
return this.isWhatsAppCloudChannel && this.hasWhatsappAppId;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
@@ -83,6 +98,18 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
reconfigureWhatsApp() {
|
||||
// Initialize the WhatsApp reauthorization flow
|
||||
const { launchReauthorization, initialize } = useWhatsappReauthorization(
|
||||
this.inbox.id
|
||||
);
|
||||
|
||||
// Initialize and launch
|
||||
initialize();
|
||||
launchReauthorization();
|
||||
|
||||
// Note: cleanup will happen when component unmounts or user navigates away
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -208,7 +235,21 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
<!-- Show reconfigure button for WhatsApp Cloud with embedded signup and app ID configured -->
|
||||
<SettingsSection
|
||||
v-if="shouldShowReconfigureButton"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<NextButton class="mt-2" @click="reconfigureWhatsApp">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsSection>
|
||||
<!-- Show API key update for non-embedded signup WhatsApp or when app ID is missing -->
|
||||
<SettingsSection
|
||||
v-else
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
|
||||
@@ -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(){
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ class Inbox < ApplicationRecord
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -482,6 +482,12 @@ Rails.application.routes.draw do
|
||||
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
|
||||
post 'webhooks/instagram', to: 'webhooks/instagram#events'
|
||||
|
||||
namespace :whatsapp do
|
||||
get 'signup', to: 'embedded#new'
|
||||
get 'signup/callback', to: 'embedded#callback'
|
||||
post 'embedded_signup', to: 'embedded#embedded_signup'
|
||||
end
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
end
|
||||
|
||||
@@ -103,6 +103,12 @@ slack:
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Embedded API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /whatsapp/signup' do
|
||||
before do
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
|
||||
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
it 'returns configuration for embedded signup' do
|
||||
get '/whatsapp/signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { account_id: account.id }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response).to include(
|
||||
'status' => 'ready',
|
||||
'app_id' => 'test_app_id',
|
||||
'config_id' => 'test_config_id',
|
||||
'app_secret' => 'test_app_secret'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get '/whatsapp/signup', params: { account_id: account.id }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /whatsapp/embedded_signup' do
|
||||
let(:params) do
|
||||
{
|
||||
account_id: account.id,
|
||||
code: 'auth_code_123',
|
||||
business_id: '123456789',
|
||||
waba_id: '987654321',
|
||||
phone_number_id: '555444333'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when user is authenticated' do
|
||||
context 'with missing authorization code' do
|
||||
it 'returns bad request error' do
|
||||
params_without_code = params.except(:code)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_code
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to eq('Missing authorization code')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing business parameters' do
|
||||
it 'returns bad request when business_id is missing' do
|
||||
params_without_business = params.except(:business_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_business
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
|
||||
it 'returns bad request when waba_id is missing' do
|
||||
params_without_waba = params.except(:waba_id)
|
||||
|
||||
post '/whatsapp/embedded_signup',
|
||||
headers: admin.create_new_auth_token,
|
||||
params: params_without_waba
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post '/whatsapp/embedded_signup', params: params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -262,7 +262,7 @@ RSpec.describe Inbox do
|
||||
describe '#sanitized_name' do
|
||||
context 'when inbox name contains forbidden characters' do
|
||||
it 'removes forbidden and spam-trigger characters' do
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\'Quotes!#$%')
|
||||
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
|
||||
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
|
||||
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