Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f3967148 | ||
|
|
9c10fe4eb1 | ||
|
|
6b3800e25b | ||
|
|
b560720e08 | ||
|
|
cc87429903 |
@@ -77,7 +77,8 @@ jobs:
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -118,7 +119,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
@@ -149,7 +151,8 @@ jobs:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-pnpm:
|
||||
version: '11.10.0'
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
override-ci-command: pnpm i
|
||||
|
||||
@@ -124,8 +124,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def reauthorize_and_update_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||
end
|
||||
|
||||
def update_channel_feature_flags
|
||||
|
||||
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
permitted_params.dig(:contact, :phone_number)
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
permitted_params.dig(:contact, :custom_attributes)&.to_h
|
||||
end
|
||||
|
||||
def browser_params
|
||||
{
|
||||
browser_name: browser.name,
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
def process_update_contact
|
||||
@contact = ContactIdentifyAction.new(
|
||||
contact: @contact,
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
|
||||
retain_original_contact_name: true,
|
||||
discard_invalid_attrs: true
|
||||
).perform
|
||||
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
|
||||
@@ -61,10 +61,10 @@ const triggerAction = () => {
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
v-if="actionLabel"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
|
||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
|
||||
:class="buttonClass"
|
||||
@click="triggerAction"
|
||||
>
|
||||
|
||||
@@ -107,6 +107,7 @@ const onClickCancel = () => {
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||
"
|
||||
|
||||
@@ -213,6 +213,7 @@ const renderInstruction = instruction => () =>
|
||||
|
||||
<TextArea
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||
"
|
||||
|
||||
+1
@@ -122,6 +122,7 @@ watch(
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
|
||||
+1
@@ -117,6 +117,7 @@ watch(
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:max-length="500"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
|
||||
@@ -7,6 +7,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
|
||||
@@ -820,6 +820,15 @@
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER": "This inbox is connected through WhatsApp embedded signup.",
|
||||
"WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION": "You can reconfigure this inbox to update your WhatsApp Business settings.",
|
||||
"WHATSAPP_RECONFIGURE_BUTTON": "Reconfigure",
|
||||
"WHATSAPP_MANUAL_TRANSFER_TITLE": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUBHEADER": "This inbox was connected through WhatsApp embedded signup, which is no longer supported. Enter the WhatsApp Cloud API credentials from your own Meta app to switch this inbox to a manual setup. Configure the webhook in your Meta app using the verification token above before switching.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_PHONE_NUMBER_ID_LABEL": "Phone Number ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUSINESS_ACCOUNT_ID_LABEL": "Business Account ID",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_LABEL": "API Key",
|
||||
"WHATSAPP_MANUAL_TRANSFER_API_KEY_PLACEHOLDER": "Enter a permanent access token from your Meta app",
|
||||
"WHATSAPP_MANUAL_TRANSFER_BUTTON": "Switch to Manual Setup",
|
||||
"WHATSAPP_MANUAL_TRANSFER_SUCCESS": "Inbox switched to manual setup successfully.",
|
||||
"WHATSAPP_MANUAL_TRANSFER_ERROR": "Could not switch to manual setup. Please verify the credentials and try again.",
|
||||
"WHATSAPP_CONNECT_TITLE": "Connect to WhatsApp Business",
|
||||
"WHATSAPP_CONNECT_SUBHEADER": "Upgrade to WhatsApp embedded signup for easier management.",
|
||||
"WHATSAPP_CONNECT_DESCRIPTION": "Connect this inbox to WhatsApp Business for enhanced features and easier management.",
|
||||
@@ -837,6 +846,70 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_MANUAL_MIGRATION": {
|
||||
"BANNER": {
|
||||
"TITLE": "WhatsApp setup action required",
|
||||
"DESCRIPTION": "Meta restrictions are affecting WhatsApp setup and management features. Reconnect this inbox manually to keep your WhatsApp configuration up to date.",
|
||||
"START": "Start manual migration",
|
||||
"GUIDE": "View guide"
|
||||
},
|
||||
"DIALOG": {
|
||||
"EYEBROW": "WhatsApp manual migration",
|
||||
"TITLE": "Reconnect WhatsApp inbox",
|
||||
"CLOSE": "Close",
|
||||
"ACTION_REQUIRED_TITLE": "Action required for this WhatsApp inbox",
|
||||
"ACTION_REQUIRED_DESCRIPTION": "Meta restrictions are affecting setup and management features. This guided flow updates the WhatsApp API connection without creating a new inbox.",
|
||||
"GUIDE_LINK": "Open the manual setup guide",
|
||||
"PRESERVED_TITLE": "Preserved",
|
||||
"PRESERVED_DESCRIPTION": "Conversations, contacts, collaborators, routing, business hours, and inbox settings.",
|
||||
"UPDATED_TITLE": "Updated",
|
||||
"UPDATED_DESCRIPTION": "WABA ID, phone number ID, access token, and webhook configuration.",
|
||||
"WABA_ID": "WABA ID",
|
||||
"WABA_PLACEHOLDER": "Enter WABA ID",
|
||||
"WABA_HELP": "The WhatsApp Business Account that owns this phone number.",
|
||||
"PHONE_NUMBER_ID": "Phone Number ID",
|
||||
"PHONE_NUMBER_PLACEHOLDER": "Enter Phone Number ID",
|
||||
"PHONE_NUMBER_HELP": "Meta's unique ID for the WhatsApp number connected to this inbox.",
|
||||
"DISPLAY_PHONE_NUMBER": "Display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_PLACEHOLDER": "Enter display phone number",
|
||||
"DISPLAY_PHONE_NUMBER_HELP": "The customer-facing WhatsApp number. This cannot be changed during migration.",
|
||||
"ACCESS_TOKEN": "Permanent access token or system user token",
|
||||
"ACCESS_TOKEN_PLACEHOLDER": "Paste access token",
|
||||
"TOKEN_HELP_PREFIX": "The token must include",
|
||||
"TOKEN_HELP_MIDDLE": "Add",
|
||||
"TOKEN_HELP_SUFFIX": "for template sync and template management.",
|
||||
"MESSAGING_PERMISSION": "whatsapp_business_messaging.",
|
||||
"MANAGEMENT_PERMISSION": "whatsapp_business_management",
|
||||
"REVIEW_TITLE": "Review before reconnecting",
|
||||
"INBOX": "Inbox",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"NOT_ENTERED": "Not entered",
|
||||
"VERIFY_NOTICE": "Chatwoot will verify these credentials with Meta before applying any changes. If verification fails, the current configuration is left untouched.",
|
||||
"BACK": "Back",
|
||||
"CANCEL": "Cancel",
|
||||
"CONTINUE": "Continue",
|
||||
"REVIEW_MIGRATION": "Review migration",
|
||||
"RECONNECT": "Reconnect WhatsApp inbox"
|
||||
},
|
||||
"STEPS": {
|
||||
"BEFORE_YOU_START": {
|
||||
"TITLE": "Before you start",
|
||||
"DESCRIPTION": "This reconnects the WhatsApp API details for this inbox. Conversations, collaborators, routing, business hours, CSAT, and bot settings will be preserved."
|
||||
},
|
||||
"BUSINESS_DETAILS": {
|
||||
"TITLE": "Business details",
|
||||
"DESCRIPTION": "Enter the WhatsApp assets from the customer Meta Business account."
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access token",
|
||||
"DESCRIPTION": "Paste a permanent access token or system user token with WhatsApp permissions."
|
||||
},
|
||||
"REVIEW_MIGRATION": {
|
||||
"TITLE": "Review migration",
|
||||
"DESCRIPTION": "Confirm the connection details before reconnecting this inbox. Chatwoot will verify the token, WABA, and phone number with Meta before applying changes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WHATSAPP_CALLING_ENABLED": {
|
||||
"LABEL": "Enable voice calling",
|
||||
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
|
||||
|
||||
@@ -29,6 +29,8 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
|
||||
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
@@ -78,6 +80,8 @@ export default {
|
||||
ColorPicker,
|
||||
SelectInput,
|
||||
AccountHealth,
|
||||
WhatsappManualMigrationDialog,
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
@@ -112,6 +116,7 @@ export default {
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
isRegisteringWebhook: false,
|
||||
isTransferringWhatsAppToManual: false,
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubbleLauncherTitle: '',
|
||||
@@ -382,10 +387,12 @@ export default {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappUnauthorized() {
|
||||
// The manual migration banner supersedes the embedded-signup reauthorize flow when the feature is enabled.
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.inbox.reauthorization_required
|
||||
this.inbox.reauthorization_required &&
|
||||
!this.showWhatsAppManualMigration
|
||||
);
|
||||
},
|
||||
whatsappRegistrationIncomplete() {
|
||||
@@ -402,6 +409,16 @@ export default {
|
||||
this.healthData.throughput?.level === 'NOT_APPLICABLE'
|
||||
);
|
||||
},
|
||||
showWhatsAppManualMigration() {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_MANUAL_TRANSFER
|
||||
)
|
||||
);
|
||||
},
|
||||
widgetBuilderStorageKey() {
|
||||
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
|
||||
},
|
||||
@@ -413,6 +430,7 @@ export default {
|
||||
if (inboxChanged) {
|
||||
this.syncInboxData();
|
||||
this.setTabFromRouteParam();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -423,6 +441,7 @@ export default {
|
||||
this.fetchHealthData();
|
||||
this.$nextTick(() => {
|
||||
this.setTabFromRouteParam();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
});
|
||||
} else {
|
||||
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
|
||||
@@ -433,8 +452,52 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.fetchSharedData();
|
||||
this.openWhatsAppManualMigrationIfRequested();
|
||||
},
|
||||
methods: {
|
||||
openWhatsAppManualMigrationDialog() {
|
||||
this.$refs.whatsappManualMigrationDialog?.open();
|
||||
},
|
||||
openWhatsAppManualMigrationIfRequested() {
|
||||
if (
|
||||
this.showWhatsAppManualMigration &&
|
||||
this.$route.query.migration === 'whatsapp_manual'
|
||||
) {
|
||||
this.$nextTick(() => {
|
||||
this.openWhatsAppManualMigrationDialog();
|
||||
});
|
||||
}
|
||||
},
|
||||
async transferWhatsAppToManualSetup(form) {
|
||||
this.isTransferringWhatsAppToManual = true;
|
||||
try {
|
||||
const providerConfig = { ...(this.inbox.provider_config || {}) };
|
||||
delete providerConfig.source;
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...providerConfig,
|
||||
phone_number_id: form.phoneNumberId,
|
||||
business_account_id: form.wabaId,
|
||||
api_key: form.accessToken,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_SUCCESS')
|
||||
);
|
||||
this.$refs.whatsappManualMigrationDialog?.close();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_TRANSFER_ERROR')
|
||||
);
|
||||
} finally {
|
||||
this.isTransferringWhatsAppToManual = false;
|
||||
}
|
||||
},
|
||||
async copyWebhookSecret(value) {
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(
|
||||
@@ -748,6 +811,12 @@ export default {
|
||||
class="mx-6 mb-4"
|
||||
:class="bannerMaxWidth"
|
||||
/>
|
||||
<WhatsappManualMigrationBanner
|
||||
v-if="showWhatsAppManualMigration"
|
||||
class="mx-6 mb-6"
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
@@ -1339,6 +1408,13 @@ export default {
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappManualMigrationDialog
|
||||
v-if="showWhatsAppManualMigration"
|
||||
ref="whatsappManualMigrationDialog"
|
||||
:inbox="inbox"
|
||||
:is-loading="isTransferringWhatsAppToManual"
|
||||
@reconnect="transferWhatsAppToManualSetup"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['start']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
|
||||
const copy = computed(() => ({
|
||||
title: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.TITLE'),
|
||||
description: t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.DESCRIPTION'
|
||||
),
|
||||
start: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.START'),
|
||||
guide: t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.BANNER.GUIDE'),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner color="amber" :action-label="copy.start" @action="emit('start')">
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 mt-0.5 size-4 text-n-amber-11"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-medium text-n-amber-12">{{ copy.title }}</span>
|
||||
<span>
|
||||
{{ copy.description }}
|
||||
<a
|
||||
:href="WHATSAPP_MANUAL_MIGRATION_GUIDE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline link underline-offset-2"
|
||||
>
|
||||
{{ copy.guide }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
</template>
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['reconnect']);
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const WHATSAPP_MANUAL_MIGRATION_GUIDE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1756799850-how-to-setup-a-whats_app-channel-manual-flow';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const currentStep = ref(0);
|
||||
|
||||
const buildForm = () => ({
|
||||
wabaId: props.inbox.provider_config?.business_account_id || '',
|
||||
phoneNumberId: props.inbox.provider_config?.phone_number_id || '',
|
||||
displayPhoneNumber: props.inbox.phone_number || '',
|
||||
accessToken: '',
|
||||
});
|
||||
|
||||
const form = ref(buildForm());
|
||||
|
||||
const copy = computed(() => ({
|
||||
eyebrow: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.EYEBROW`
|
||||
),
|
||||
title: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TITLE`),
|
||||
close: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CLOSE`),
|
||||
actionRequiredTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_TITLE`
|
||||
),
|
||||
actionRequiredDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACTION_REQUIRED_DESCRIPTION`
|
||||
),
|
||||
guideLink: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.GUIDE_LINK`
|
||||
),
|
||||
preservedTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_TITLE`
|
||||
),
|
||||
preservedDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PRESERVED_DESCRIPTION`
|
||||
),
|
||||
updatedTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_TITLE`
|
||||
),
|
||||
updatedDescription: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.UPDATED_DESCRIPTION`
|
||||
),
|
||||
wabaId: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_ID`
|
||||
),
|
||||
wabaPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_PLACEHOLDER`
|
||||
),
|
||||
wabaHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.WABA_HELP`
|
||||
),
|
||||
phoneNumberId: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_ID`
|
||||
),
|
||||
phoneNumberPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_PLACEHOLDER`
|
||||
),
|
||||
phoneNumberHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER_HELP`
|
||||
),
|
||||
displayPhoneNumber: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER`
|
||||
),
|
||||
displayPhoneNumberPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_PLACEHOLDER`
|
||||
),
|
||||
displayPhoneNumberHelp: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.DISPLAY_PHONE_NUMBER_HELP`
|
||||
),
|
||||
accessToken: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN`
|
||||
),
|
||||
accessTokenPlaceholder: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.ACCESS_TOKEN_PLACEHOLDER`
|
||||
),
|
||||
tokenHelpPrefix: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_PREFIX`
|
||||
),
|
||||
tokenHelpMiddle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_MIDDLE`
|
||||
),
|
||||
tokenHelpSuffix: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.TOKEN_HELP_SUFFIX`
|
||||
),
|
||||
messagingPermission: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MESSAGING_PERMISSION`
|
||||
),
|
||||
managementPermission: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.MANAGEMENT_PERMISSION`
|
||||
),
|
||||
reviewTitle: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_TITLE`
|
||||
),
|
||||
inbox: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.INBOX`),
|
||||
phoneNumber: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.PHONE_NUMBER`
|
||||
),
|
||||
notEntered: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.NOT_ENTERED`
|
||||
),
|
||||
verifyNotice: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.VERIFY_NOTICE`
|
||||
),
|
||||
back: t(`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.BACK`),
|
||||
cancel: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CANCEL`
|
||||
),
|
||||
continue: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.CONTINUE`
|
||||
),
|
||||
reviewMigration: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.REVIEW_MIGRATION`
|
||||
),
|
||||
reconnect: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.DIALOG.RECONNECT`
|
||||
),
|
||||
}));
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BEFORE_YOU_START.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.BUSINESS_DETAILS.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.ACCESS_TOKEN.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.TITLE`
|
||||
),
|
||||
description: t(
|
||||
`INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANUAL_MIGRATION.STEPS.REVIEW_MIGRATION.DESCRIPTION`
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const currentStepDetails = computed(() => steps.value[currentStep.value]);
|
||||
const isFirstStep = computed(() => currentStep.value === 0);
|
||||
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
|
||||
const guideUrl = WHATSAPP_MANUAL_MIGRATION_GUIDE_URL;
|
||||
const hasBusinessDetails = computed(
|
||||
() => form.value.wabaId.trim() && form.value.phoneNumberId.trim()
|
||||
);
|
||||
const hasAccessToken = computed(() => form.value.accessToken.trim());
|
||||
const canContinue = computed(() => {
|
||||
if (currentStep.value === 1) return hasBusinessDetails.value;
|
||||
if (currentStep.value === 2) return hasAccessToken.value;
|
||||
if (isLastStep.value) {
|
||||
return hasBusinessDetails.value && hasAccessToken.value;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const open = () => {
|
||||
currentStep.value = 0;
|
||||
form.value = buildForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
const goBack = () => {
|
||||
if (!isFirstStep.value) currentStep.value -= 1;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!isLastStep.value) currentStep.value += 1;
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
if (!canContinue.value) return;
|
||||
|
||||
emit('reconnect', {
|
||||
wabaId: form.value.wabaId.trim(),
|
||||
phoneNumberId: form.value.phoneNumberId.trim(),
|
||||
accessToken: form.value.accessToken.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
overflow-y-auto
|
||||
>
|
||||
<div class="flex flex-col gap-5 h-[24rem]">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-sm font-medium text-n-slate-11">
|
||||
{{ copy.eyebrow }}
|
||||
</p>
|
||||
<h3 class="m-0 text-xl font-semibold text-n-slate-12">
|
||||
{{ copy.title }}
|
||||
</h3>
|
||||
<p class="mt-2 mb-0 text-sm text-n-slate-11 min-h-[2.5rem]">
|
||||
{{ replaceInstallationName(currentStepDetails.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<NextButton
|
||||
v-tooltip="copy.close"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="copy.close"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid flex-1 gap-6 overflow-y-auto min-h-0 grid-cols-[11rem,1fr]"
|
||||
>
|
||||
<ol class="flex flex-col p-0 m-0 list-none">
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.title"
|
||||
class="relative flex gap-3"
|
||||
>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<span
|
||||
class="z-10 grid text-xs font-semibold transition-colors rounded-full size-6 place-content-center"
|
||||
:class="
|
||||
index < currentStep
|
||||
? 'bg-n-teal-9 text-white'
|
||||
: index === currentStep
|
||||
? 'bg-n-blue-9 text-white'
|
||||
: 'bg-n-alpha-2 text-n-slate-11 outline outline-1 outline-n-container -outline-offset-1'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
v-if="index < currentStep"
|
||||
icon="i-lucide-check"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="flex-1 my-1 rounded-full w-0.5"
|
||||
:class="index < currentStep ? 'bg-n-teal-9' : 'bg-n-slate-4'"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-sm leading-6 truncate min-w-0"
|
||||
:class="[
|
||||
index < steps.length - 1 ? 'pb-6' : '',
|
||||
index === currentStep
|
||||
? 'font-medium text-n-slate-12'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="min-w-0">
|
||||
<section v-if="currentStep === 0" class="flex flex-col gap-5">
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-xl border-n-weak bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="grid flex-shrink-0 rounded-lg size-8 place-content-center bg-n-amber-3 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-triangle-alert" class="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="mt-0 mb-1 text-base font-medium text-n-slate-12">
|
||||
{{ copy.actionRequiredTitle }}
|
||||
</h4>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.actionRequiredDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="i-lucide-check" class="size-4 text-n-teal-11" />
|
||||
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ copy.preservedTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.preservedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-1 p-3 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-refresh-cw"
|
||||
class="size-4 text-n-blue-11"
|
||||
/>
|
||||
<p class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ copy.updatedTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-n-slate-11">
|
||||
{{ copy.updatedDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="guideUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ copy.guideLink }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3.5" />
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentStep === 1" class="grid gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.wabaId"
|
||||
:label="copy.wabaId"
|
||||
:placeholder="copy.wabaPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.wabaHelp }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.phoneNumberId"
|
||||
:label="copy.phoneNumberId"
|
||||
:placeholder="copy.phoneNumberPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.phoneNumberHelp }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Input
|
||||
v-model="form.displayPhoneNumber"
|
||||
disabled
|
||||
:label="copy.displayPhoneNumber"
|
||||
:placeholder="copy.displayPhoneNumberPlaceholder"
|
||||
/>
|
||||
<span class="text-xs leading-5 text-n-slate-11">
|
||||
{{ copy.displayPhoneNumberHelp }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="currentStep === 2" class="grid gap-4">
|
||||
<TextArea
|
||||
v-model="form.accessToken"
|
||||
:label="copy.accessToken"
|
||||
:placeholder="copy.accessTokenPlaceholder"
|
||||
auto-height
|
||||
min-height="6rem"
|
||||
max-height="12rem"
|
||||
/>
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-xl bg-n-blue-3 border-n-blue-4 text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4 mt-0.5" />
|
||||
<p class="m-0 text-sm">
|
||||
{{ copy.tokenHelpPrefix }}
|
||||
<code>{{ copy.messagingPermission }}</code>
|
||||
{{ copy.tokenHelpMiddle }}
|
||||
<code>{{ copy.managementPermission }}</code>
|
||||
{{ copy.tokenHelpSuffix }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="flex flex-col gap-4 p-4 rounded-xl bg-n-solid-2 outline outline-1 outline-n-container -outline-offset-1"
|
||||
>
|
||||
<h4 class="m-0 text-base font-medium text-n-slate-12">
|
||||
{{ copy.reviewTitle }}
|
||||
</h4>
|
||||
<dl class="grid gap-3 m-0 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">{{ copy.inbox }}</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ inbox.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">
|
||||
{{ copy.phoneNumber }}
|
||||
</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.displayPhoneNumber || inbox.phone_number }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">{{ copy.wabaId }}</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.wabaId || copy.notEntered }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-n-slate-11">
|
||||
{{ copy.phoneNumberId }}
|
||||
</dt>
|
||||
<dd class="m-0 text-sm font-medium text-n-slate-12">
|
||||
{{ form.phoneNumberId || copy.notEntered }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div
|
||||
class="flex gap-3 p-3 border rounded-lg bg-n-alpha-2 border-n-weak text-n-slate-11"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-shield-check"
|
||||
class="flex-shrink-0 size-4 mt-0.5 text-n-teal-11"
|
||||
/>
|
||||
<p class="m-0 text-sm">
|
||||
{{ replaceInstallationName(copy.verifyNotice) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:disabled="isFirstStep"
|
||||
@click="goBack"
|
||||
>
|
||||
{{ copy.back }}
|
||||
</NextButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<NextButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
@click="close"
|
||||
>
|
||||
{{ copy.cancel }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-if="!isLastStep"
|
||||
type="button"
|
||||
:disabled="!canContinue"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ currentStep === 2 ? copy.reviewMigration : copy.continue }}
|
||||
</NextButton>
|
||||
<NextButton
|
||||
v-else
|
||||
type="button"
|
||||
color="teal"
|
||||
:disabled="!canContinue"
|
||||
:is-loading="isLoading"
|
||||
@click="reconnect"
|
||||
>
|
||||
{{ copy.reconnect }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+5
-27
@@ -10,7 +10,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
|
||||
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
@@ -22,7 +21,6 @@ export default {
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
TextArea,
|
||||
WhatsappReauthorize,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -39,7 +37,6 @@ export default {
|
||||
hmacMandatory: false,
|
||||
allowMobileWebview: false,
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
@@ -53,9 +50,6 @@ export default {
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
},
|
||||
whatsappAppId() {
|
||||
return window.chatwootConfig?.whatsappAppId;
|
||||
},
|
||||
isForwardingEnabled() {
|
||||
return !!this.inbox.forwarding_enabled;
|
||||
},
|
||||
@@ -166,11 +160,6 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async handleReconfigure() {
|
||||
if (this.$refs.whatsappReauth) {
|
||||
await this.$refs.whatsappReauth.requestAuthorization();
|
||||
}
|
||||
},
|
||||
async syncTemplates() {
|
||||
this.isSyncingTemplates = true;
|
||||
try {
|
||||
@@ -362,22 +351,17 @@ export default {
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
v-if="whatsappAppId"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
|
||||
"
|
||||
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<NextButton @click="handleReconfigure">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
<template v-else>
|
||||
<template v-else-if="!isEmbeddedSignupWhatsApp">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:help-text="
|
||||
@@ -435,12 +419,6 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<WhatsappReauthorize
|
||||
v-if="isEmbeddedSignupWhatsApp"
|
||||
ref="whatsappReauth"
|
||||
:inbox="inbox"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const createConversation = params => {
|
||||
name: params.fullName,
|
||||
email: params.emailAddress,
|
||||
phone_number: params.phoneNumber,
|
||||
custom_attributes: params.contactCustomAttributes,
|
||||
},
|
||||
message: {
|
||||
content: params.message,
|
||||
|
||||
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createConversation', () => {
|
||||
it('includes contact custom attributes in the payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.createConversation({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
phoneNumber: '+919745313456',
|
||||
message: 'hey',
|
||||
customAttributes: { order_id: '12345' },
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
url: `/api/v1/widget/conversations?param=1&locale=ar`,
|
||||
params: {
|
||||
contact: {
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
phone_number: '+919745313456',
|
||||
custom_attributes: { cpf: '123.456.789-09' },
|
||||
},
|
||||
message: {
|
||||
content: 'hey',
|
||||
timestamp: 'mock date',
|
||||
referer_url: '',
|
||||
},
|
||||
custom_attributes: { order_id: '12345' },
|
||||
},
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessage with pending metadata', () => {
|
||||
it('includes custom_attributes and labels in payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mapActions } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PreChatForm from '../components/PreChat/Form.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import { ON_CONVERSATION_CREATED } from '../constants/widgetBusEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
@@ -42,6 +41,10 @@ export default {
|
||||
contactCustomAttributes,
|
||||
conversationCustomAttributes,
|
||||
}) {
|
||||
// Contact custom attributes are sent within the same request that
|
||||
// identifies the contact. A separate update call would race the contact
|
||||
// merge on the server (matching email/phone) and write the values to
|
||||
// the destroyed contact, silently losing them.
|
||||
if (activeCampaignId) {
|
||||
emitter.emit('execute-campaign', {
|
||||
campaignId: activeCampaignId,
|
||||
@@ -52,6 +55,7 @@ export default {
|
||||
email: emailAddress,
|
||||
name: fullName,
|
||||
phone_number: phoneNumber,
|
||||
custom_attributes: contactCustomAttributes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -63,14 +67,9 @@ export default {
|
||||
message: message,
|
||||
phoneNumber: phoneNumber,
|
||||
customAttributes: conversationCustomAttributes,
|
||||
contactCustomAttributes: contactCustomAttributes,
|
||||
});
|
||||
}
|
||||
if (!isEmptyObject(contactCustomAttributes)) {
|
||||
this.$store.dispatch(
|
||||
'contacts/setCustomAttributes',
|
||||
contactCustomAttributes
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import PreChatFormView from '../PreChatForm.vue';
|
||||
|
||||
global.chatwootWebChannel = {
|
||||
preChatFormEnabled: true,
|
||||
preChatFormOptions: { pre_chat_fields: [], pre_chat_message: '' },
|
||||
};
|
||||
|
||||
describe('PreChatForm view', () => {
|
||||
let createConversation;
|
||||
let setCustomAttributes;
|
||||
let updateContact;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
createConversation = vi.fn();
|
||||
setCustomAttributes = vi.fn();
|
||||
updateContact = vi.fn();
|
||||
store = createStore({
|
||||
modules: {
|
||||
conversation: {
|
||||
namespaced: true,
|
||||
actions: { createConversation, clearConversations: vi.fn() },
|
||||
},
|
||||
conversationAttributes: {
|
||||
namespaced: true,
|
||||
actions: { clearConversationAttributes: vi.fn() },
|
||||
},
|
||||
contacts: {
|
||||
namespaced: true,
|
||||
actions: { setCustomAttributes, update: updateContact },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mountView = () =>
|
||||
shallowMount(PreChatFormView, { global: { plugins: [store] } });
|
||||
|
||||
it('sends contact custom attributes with the conversation create request', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.vm.onSubmit({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
message: 'hey',
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
conversationCustomAttributes: { order_id: '12345' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(createConversation).toHaveBeenCalledWith(expect.anything(), {
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
message: 'hey',
|
||||
phoneNumber: undefined,
|
||||
customAttributes: { order_id: '12345' },
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
});
|
||||
// attributes ride along in the create request itself; a separate call
|
||||
// would race the contact merge on the server and write to a destroyed
|
||||
// contact
|
||||
expect(setCustomAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends contact custom attributes along with the contact update for campaigns', async () => {
|
||||
const wrapper = mountView();
|
||||
wrapper.vm.onSubmit({
|
||||
fullName: 'John',
|
||||
emailAddress: 'john@example.com',
|
||||
phoneNumber: null,
|
||||
activeCampaignId: 42,
|
||||
contactCustomAttributes: { cpf: '123.456.789-09' },
|
||||
conversationCustomAttributes: {},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(updateContact).toHaveBeenCalledWith(expect.anything(), {
|
||||
user: {
|
||||
email: 'john@example.com',
|
||||
name: 'John',
|
||||
phone_number: null,
|
||||
custom_attributes: { cpf: '123.456.789-09' },
|
||||
},
|
||||
});
|
||||
expect(createConversation).not.toHaveBeenCalled();
|
||||
expect(setCustomAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@
|
||||
# index_assignment_policies_on_enabled (enabled)
|
||||
#
|
||||
class AssignmentPolicy < ApplicationRecord
|
||||
DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168
|
||||
|
||||
belongs_to :account
|
||||
has_many :inbox_assignment_policies, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_assignment_policies
|
||||
|
||||
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validate :validate_provider_config
|
||||
|
||||
after_create :sync_templates
|
||||
after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
|
||||
before_destroy :teardown_webhooks
|
||||
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
|
||||
|
||||
@@ -129,6 +130,15 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
# Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
|
||||
def log_credentials_transfer
|
||||
before, after = saved_change_to_provider_config
|
||||
keys = %w[api_key phone_number_id business_account_id]
|
||||
return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
|
||||
|
||||
Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
webhook_setup_service.perform
|
||||
end
|
||||
|
||||
@@ -35,9 +35,9 @@ class AutoAssignment::AssignmentService
|
||||
def unassigned_conversations(limit)
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
|
||||
# Skip stale backlog with no activity beyond the age threshold
|
||||
policy = inbox.assignment_policy
|
||||
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
||||
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
|
||||
|
||||
# Apply conversation priority using assignment policy if available
|
||||
scope = if policy&.longest_waiting?
|
||||
@@ -49,6 +49,12 @@ class AutoAssignment::AssignmentService
|
||||
scope.limit(limit)
|
||||
end
|
||||
|
||||
def age_exclusion_hours(policy)
|
||||
return policy.exclude_older_than_hours if policy
|
||||
|
||||
AssignmentPolicy::DEFAULT_EXCLUDE_OLDER_THAN_HOURS
|
||||
end
|
||||
|
||||
def apply_age_exclusions(scope, hours_threshold)
|
||||
return scope if hours_threshold.blank?
|
||||
|
||||
|
||||
@@ -54,8 +54,17 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
|
||||
response.success?
|
||||
config = whatsapp_channel.provider_config
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}")
|
||||
return log_transfer_failure('waba_or_token_check', response) unless response.success?
|
||||
# The templates check only proves the WABA/token pair, so verify the phone_number_id belongs to this WABA when it changes.
|
||||
return true unless whatsapp_channel.provider_config_changed?
|
||||
|
||||
phone_response = HTTParty.get("#{business_account_path}/phone_numbers?fields=id&limit=100&access_token=#{config['api_key']}")
|
||||
ids = phone_response.parsed_response.is_a?(Hash) ? Array(phone_response.parsed_response['data']) : []
|
||||
return true if phone_response.success? && ids.any? { |number| number['id'] == config['phone_number_id'].to_s }
|
||||
|
||||
log_transfer_failure('phone_number_id_check', phone_response)
|
||||
end
|
||||
|
||||
def api_headers
|
||||
@@ -81,6 +90,16 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
private
|
||||
|
||||
# Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
|
||||
def log_transfer_failure(check, response)
|
||||
return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
|
||||
|
||||
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
|
||||
Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
|
||||
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
|
||||
false
|
||||
end
|
||||
|
||||
def csat_template_service
|
||||
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
|
||||
end
|
||||
|
||||
@@ -249,3 +249,7 @@
|
||||
display_name: Advanced Assignment
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: whatsapp_manual_transfer
|
||||
display_name: WhatsApp Manual Transfer
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
type: secret
|
||||
- name: CAPTAIN_OPEN_AI_MODEL
|
||||
display_title: 'OpenAI Model'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-5.6-luna'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
|
||||
locked: false
|
||||
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
||||
display_title: 'OpenAI API Endpoint (optional)'
|
||||
|
||||
+12
-67
@@ -35,18 +35,6 @@ models:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.2'
|
||||
credit_multiplier: 3
|
||||
gpt-5.6-luna:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Luna'
|
||||
credit_multiplier: 2
|
||||
gpt-5.6-terra:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Terra'
|
||||
credit_multiplier: 3
|
||||
gpt-5.6-sol:
|
||||
provider: openai
|
||||
display_name: 'GPT-5.6 Sol'
|
||||
credit_multiplier: 5
|
||||
claude-haiku-4.5:
|
||||
provider: anthropic
|
||||
display_name: 'Claude Haiku 4.5'
|
||||
@@ -90,14 +78,11 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
claude-haiku-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
default: gpt-4.1-mini
|
||||
assistant:
|
||||
models:
|
||||
[
|
||||
@@ -106,16 +91,12 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
default: gpt-4.1
|
||||
copilot:
|
||||
models:
|
||||
[
|
||||
@@ -124,28 +105,16 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
default: gpt-4.1
|
||||
label_suggestion:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-nano,
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-5.6-luna,
|
||||
gemini-3-flash,
|
||||
claude-haiku-4.5,
|
||||
]
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
|
||||
default: gpt-4.1-mini
|
||||
document_faq_generation:
|
||||
models:
|
||||
[
|
||||
@@ -154,16 +123,12 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
default: gpt-4.1-mini
|
||||
conversation_faq_generation:
|
||||
models:
|
||||
[
|
||||
@@ -172,20 +137,15 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
default: gpt-5.2
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
reasoning_effort: medium
|
||||
help_center_article_generation:
|
||||
models:
|
||||
[
|
||||
@@ -194,34 +154,19 @@ features:
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
default: gpt-5.2
|
||||
onboarding_content_generation:
|
||||
models:
|
||||
[
|
||||
gpt-4.1,
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
gpt-5.6-luna,
|
||||
gpt-5.6-terra,
|
||||
gpt-5.6-sol,
|
||||
]
|
||||
default: gpt-5.6-terra
|
||||
reasoning_effort: medium
|
||||
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1
|
||||
help_center_query_translation:
|
||||
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gpt-5.6-luna]
|
||||
default: gpt-5.6-luna
|
||||
reasoning_effort: low
|
||||
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
|
||||
default: gpt-4.1-nano
|
||||
audio_transcription:
|
||||
models: [gpt-4o-mini-transcribe, whisper-1]
|
||||
default: gpt-4o-mini-transcribe
|
||||
|
||||
+1
-160
@@ -27970,165 +27970,6 @@
|
||||
"owned_by": "system"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-luna",
|
||||
"name": "GPT-5.6 Luna",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 1,
|
||||
"output_per_million": 6
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 1,
|
||||
"output": 6
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-terra",
|
||||
"name": "GPT-5.6 Terra",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 2.5,
|
||||
"output_per_million": 15
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 2.5,
|
||||
"output": 15
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"provider": "openai",
|
||||
"family": "gpt",
|
||||
"created_at": null,
|
||||
"context_window": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"knowledge_cutoff": "2026-02-16",
|
||||
"modalities": {
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"output": [
|
||||
"text"
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"function_calling",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"vision"
|
||||
],
|
||||
"pricing": {
|
||||
"text_tokens": {
|
||||
"standard": {
|
||||
"input_per_million": 5,
|
||||
"output_per_million": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"source": "openai_docs",
|
||||
"provider_id": "openai",
|
||||
"open_weights": false,
|
||||
"attachment": true,
|
||||
"temperature": false,
|
||||
"last_updated": "2026-07-10",
|
||||
"cost": {
|
||||
"input": 5,
|
||||
"output": 30
|
||||
},
|
||||
"limit": {
|
||||
"context": 1050000,
|
||||
"input": 922000,
|
||||
"output": 128000
|
||||
},
|
||||
"knowledge": "2026-02-16"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gpt-audio",
|
||||
"name": "gpt-audio",
|
||||
@@ -61668,4 +61509,4 @@
|
||||
"owned_by": "xai"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
class ChangeCaptainAssistantDescriptionToText < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
change_column :captain_assistants, :description, :text
|
||||
end
|
||||
|
||||
def down
|
||||
change_column :captain_assistants, :description, :string
|
||||
end
|
||||
end
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -343,7 +343,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
|
||||
create_table "captain_assistants", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "description"
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "config", default: {}, null: false
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# description :text
|
||||
# guardrails :jsonb
|
||||
# name :string not null
|
||||
# response_guidelines :jsonb
|
||||
@@ -17,6 +17,8 @@
|
||||
# index_captain_assistants_on_account_id (account_id)
|
||||
#
|
||||
class Captain::Assistant < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
@@ -39,7 +41,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :account_id, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
# index_captain_scenarios_on_enabled (enabled)
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
DESCRIPTION_LENGTH_LIMIT = 500
|
||||
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
@@ -43,7 +45,7 @@ class Captain::Scenario < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :title, presence: true
|
||||
validates :description, presence: true
|
||||
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
|
||||
validates :instruction, presence: true
|
||||
validates :assistant_id, presence: true
|
||||
validates :account_id, presence: true
|
||||
|
||||
@@ -97,7 +97,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
Guidelines:
|
||||
- business_name: Extract the actual company/brand name from the content
|
||||
- suggested_assistant_name: Create a friendly, professional name that customers would want to interact with
|
||||
- description: Provide context about the business and what the assistant can help with. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
|
||||
- description: Provide context about the business and what the assistant can help with in no more than 500 characters. Keep it general and adaptable rather than overly specific. For example: "You specialize in helping customers with their orders and product questions" or "You assist customers with their account needs and general inquiries"
|
||||
|
||||
Website content:
|
||||
#{@website_content}
|
||||
|
||||
@@ -60,7 +60,7 @@ module Enterprise::AutoAssignment::AssignmentService
|
||||
scope = inbox.conversations.unassigned.open
|
||||
|
||||
# First apply the assignment policy's age exclusion (defaults to 7 days)
|
||||
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
|
||||
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
|
||||
|
||||
# Then apply the capacity policy's exclusion rules (labels and age)
|
||||
scope = apply_exclusion_rules(scope)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::Config
|
||||
DEFAULT_MODEL = 'gpt-5.6-luna'.freeze
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||
|
||||
class << self
|
||||
def initialized?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Llm::FeatureRouter
|
||||
class UnknownFeatureError < StandardError; end
|
||||
|
||||
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.6-terra'.freeze
|
||||
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
|
||||
|
||||
class << self
|
||||
def resolve(feature:, account: nil)
|
||||
@@ -17,7 +17,6 @@ module Llm::FeatureRouter
|
||||
feature: feature_key,
|
||||
provider: Llm::Models.provider_for(model),
|
||||
model: model,
|
||||
reasoning_effort: Llm::Models.reasoning_effort_for(feature_key),
|
||||
source: source
|
||||
}
|
||||
end
|
||||
|
||||
+1
-6
@@ -15,10 +15,6 @@ module Llm::Models
|
||||
features.dig(feature.to_s, 'default')
|
||||
end
|
||||
|
||||
def reasoning_effort_for(feature)
|
||||
features.dig(feature.to_s, 'reasoning_effort')
|
||||
end
|
||||
|
||||
def models_for(feature)
|
||||
features.dig(feature.to_s, 'models') || []
|
||||
end
|
||||
@@ -50,8 +46,7 @@ module Llm::Models
|
||||
credit_multiplier: model['credit_multiplier']
|
||||
}
|
||||
end,
|
||||
default: feature['default'],
|
||||
reasoning_effort: feature['reasoning_effort']
|
||||
default: feature['default']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LlmConstants
|
||||
DEFAULT_MODEL = 'gpt-5.6-luna'
|
||||
DEFAULT_MODEL = 'gpt-4.1'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
|
||||
|
||||
@@ -44,12 +44,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
expect(json_response).to have_key(:providers)
|
||||
expect(json_response).to have_key(:models)
|
||||
expect(json_response).to have_key(:features)
|
||||
expect(json_response[:models].keys).to include(:'gpt-5.6-luna', :'gpt-5.6-terra', :'gpt-5.6-sol')
|
||||
expect(json_response.dig(:features, :assistant, :models).pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
end
|
||||
|
||||
it 'returns effective model provider and source for each feature' do
|
||||
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
|
||||
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
headers: admin.create_new_auth_token,
|
||||
@@ -57,17 +55,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response.dig(:features, :editor)).to include(
|
||||
model: 'gpt-5.6-terra',
|
||||
selected: 'gpt-5.6-terra',
|
||||
model: 'gpt-4.1',
|
||||
selected: 'gpt-4.1',
|
||||
provider: 'openai',
|
||||
reasoning_effort: 'low',
|
||||
source: 'account_override'
|
||||
)
|
||||
expect(json_response.dig(:features, :label_suggestion)).to include(
|
||||
model: Llm::Models.default_model_for('label_suggestion'),
|
||||
selected: Llm::Models.default_model_for('label_suggestion'),
|
||||
provider: 'openai',
|
||||
reasoning_effort: 'low',
|
||||
source: 'default'
|
||||
)
|
||||
end
|
||||
@@ -85,7 +81,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the Captain V2 assistant default for V2 accounts' do
|
||||
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||
|
||||
@@ -140,6 +140,51 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
||||
expect(json_response['messages'][0]['content']).to eq 'This is a test message'
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the widget contact' do
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: 'contact-email@chatwoot.com',
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'saves contact custom attributes on the surviving contact when merged into an existing contact' do
|
||||
existing_contact = create(:contact, account: account, email: 'contact-email@chatwoot.com', custom_attributes: { 'cpf' => 'old-value' })
|
||||
|
||||
post '/api/v1/widget/conversations',
|
||||
headers: { 'X-Auth-Token' => token },
|
||||
params: {
|
||||
website_token: web_widget.website_token,
|
||||
contact: {
|
||||
name: 'contact-name',
|
||||
email: existing_contact.email,
|
||||
custom_attributes: { cpf: '123.456.789-09' }
|
||||
},
|
||||
message: {
|
||||
content: 'This is a test message'
|
||||
}
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
# the widget contact is merged into the existing contact; the freshly
|
||||
# submitted value must land on the surviving contact and win over stale data
|
||||
expect(Contact.exists?(contact.id)).to be(false)
|
||||
expect(existing_contact.reload.custom_attributes['cpf']).to eq('123.456.789-09')
|
||||
end
|
||||
|
||||
it 'doesnt not add phone number if the invalid phone number is provided' do
|
||||
existing_contact = create(:contact, account: account)
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ RSpec.describe Account, type: :model do
|
||||
|
||||
expect(account).to be_feature_enabled('captain_integration')
|
||||
expect(account).to be_feature_enabled('captain_integration_v2')
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
|
||||
expect(account.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ RSpec.describe Concerns::Agentable do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ RSpec.describe Llm::BaseAiService do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
|
||||
|
||||
@@ -12,27 +12,25 @@ RSpec.describe Llm::FeatureRouter do
|
||||
expect(resolved).to eq(
|
||||
feature: 'editor',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
model: 'gpt-4.1-mini',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses a valid account model override' do
|
||||
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
|
||||
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
|
||||
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
feature: 'editor',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-terra',
|
||||
reasoning_effort: 'low',
|
||||
model: 'gpt-4.1',
|
||||
source: :account_override
|
||||
)
|
||||
end
|
||||
|
||||
it 'resolves the Captain V2 assistant default without storing an account override' do
|
||||
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
resolved = described_class.resolve(feature: 'assistant', account: account)
|
||||
@@ -40,8 +38,7 @@ RSpec.describe Llm::FeatureRouter do
|
||||
expect(resolved).to include(
|
||||
feature: 'assistant',
|
||||
provider: 'openai',
|
||||
model: described_class::CAPTAIN_V2_ASSISTANT_MODEL,
|
||||
reasoning_effort: 'medium',
|
||||
model: 'gpt-5.2',
|
||||
source: :default
|
||||
)
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
@@ -65,8 +62,7 @@ RSpec.describe Llm::FeatureRouter do
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
model: 'gpt-4.1-mini',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
@@ -77,8 +73,7 @@ RSpec.describe Llm::FeatureRouter do
|
||||
resolved = described_class.resolve(feature: 'editor', account: account)
|
||||
|
||||
expect(resolved).to include(
|
||||
model: 'gpt-5.6-luna',
|
||||
reasoning_effort: 'low',
|
||||
model: 'gpt-4.1-mini',
|
||||
source: :default
|
||||
)
|
||||
end
|
||||
|
||||
@@ -27,33 +27,12 @@ RSpec.describe Llm::Models do
|
||||
end
|
||||
|
||||
it 'routes document and conversation FAQ generation independently' do
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-5.6-terra')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.6-terra')
|
||||
end
|
||||
|
||||
it 'sets reasoning effort for every text generation feature' do
|
||||
features_without_reasoning = %w[audio_transcription help_center_search]
|
||||
|
||||
described_class.features.each_key do |feature_key|
|
||||
if features_without_reasoning.include?(feature_key)
|
||||
expect(described_class.reasoning_effort_for(feature_key)).to be_nil
|
||||
else
|
||||
expect(described_class.reasoning_effort_for(feature_key)).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'exposes GPT-5.6 models for Captain V2 workflows without changing the legacy PDF path' do
|
||||
expect(described_class.models_for('assistant')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
expect(described_class.models_for('pdf_faq_generation')).not_to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini')
|
||||
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.models' do
|
||||
it 'includes the GPT-5.6 model family' do
|
||||
expect(described_class.models.keys).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol')
|
||||
end
|
||||
|
||||
it 'references existing providers from every model' do
|
||||
missing_providers = described_class.models.filter_map do |model_name, config|
|
||||
provider = config['provider']
|
||||
@@ -70,9 +49,7 @@ RSpec.describe Llm::Models do
|
||||
it 'returns model metadata for a feature' do
|
||||
config = described_class.feature_config('editor')
|
||||
|
||||
expect(config[:default]).to eq('gpt-5.6-luna')
|
||||
expect(config[:reasoning_effort]).to eq('low')
|
||||
expect(config[:models].pluck(:id)).to include('gpt-5.6-luna', 'gpt-5.6-terra')
|
||||
expect(config[:default]).to eq('gpt-4.1-mini')
|
||||
expect(config[:models].first).to include(
|
||||
id: 'gpt-4.1-mini',
|
||||
display_name: 'GPT-4.1 Mini',
|
||||
|
||||
@@ -108,7 +108,6 @@ RSpec.describe Account do
|
||||
|
||||
it 'configures the account feature flag extension column' do
|
||||
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({})
|
||||
end
|
||||
|
||||
it 'keeps existing feature flags on the original column' do
|
||||
@@ -393,10 +392,10 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns the Captain V2 default for assistant when Captain V2 is enabled' do
|
||||
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
|
||||
account.enable_features!('captain_integration_v2')
|
||||
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
|
||||
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
|
||||
expect(account.reload.captain_models).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,8 +42,18 @@ RSpec.describe Channel::Whatsapp do
|
||||
body: { data: [{
|
||||
id: '123456789', name: 'test_template'
|
||||
}] }.to_json)
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [{ id: 'random_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
expect(channel.save).to be(true)
|
||||
end
|
||||
|
||||
it 'validates false when phone number id is wrong' do
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [] }.to_json)
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//phone_numbers?fields=id&limit=100&access_token=test_key')
|
||||
.to_return(status: 200, body: { data: [{ id: 'another_phone_id' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
expect(channel.save).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'webhook_verify_token' do
|
||||
|
||||
@@ -239,6 +239,24 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(old_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
|
||||
context 'when the inbox has no assignment policy' do
|
||||
before do
|
||||
inbox.inbox_assignment_policy.destroy!
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
it 'falls back to the default threshold and skips stale conversations' do
|
||||
stale_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 8.days.ago)
|
||||
recent_conversation = create(:conversation, inbox: inbox, assignee: nil, last_activity_at: 6.days.ago)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
|
||||
expect(assigned_count).to eq(1)
|
||||
expect(stale_conversation.reload.assignee).to be_nil
|
||||
expect(recent_conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fair distribution' do
|
||||
|
||||
Reference in New Issue
Block a user