Compare commits

..
Author SHA1 Message Date
aakashb95 090ca53bde feat(llm): configure GPT-5.6 reasoning defaults 2026-07-10 23:11:25 +05:30
47 changed files with 344 additions and 1060 deletions
+3 -6
View File
@@ -77,8 +77,7 @@ jobs:
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '11.10.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -119,8 +118,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '11.10.0'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
@@ -151,8 +149,7 @@ jobs:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '11.10.0'
- node/install-pnpm
- 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.update!(permitted_params(channel_attributes)[:channel])
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
@@ -59,10 +59,6 @@ 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, custom_attributes: contact_custom_attributes },
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
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, { custom_attributes: {} }],
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
@@ -61,10 +61,10 @@ const triggerAction = () => {
<div>
<slot />
</div>
<div class="flex-shrink-0">
<div>
<button
v-if="actionLabel"
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
:class="buttonClass"
@click="triggerAction"
>
@@ -107,7 +107,6 @@ const onClickCancel = () => {
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -213,7 +213,6 @@ const renderInstruction = instruction => () =>
<TextArea
v-model="state.description"
:max-length="500"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
@@ -122,7 +122,6 @@ 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"
@@ -117,7 +117,6 @@ 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
View File
@@ -7,7 +7,6 @@ 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,15 +820,6 @@
"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.",
@@ -846,70 +837,6 @@
"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,8 +29,6 @@ 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';
@@ -80,8 +78,6 @@ export default {
ColorPicker,
SelectInput,
AccountHealth,
WhatsappManualMigrationDialog,
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
@@ -116,7 +112,6 @@ export default {
isLoadingHealth: false,
healthError: null,
isRegisteringWebhook: false,
isTransferringWhatsAppToManual: false,
widgetBubblePosition: 'right',
widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '',
@@ -387,12 +382,10 @@ 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.showWhatsAppManualMigration
this.inbox.reauthorization_required
);
},
whatsappRegistrationIncomplete() {
@@ -409,16 +402,6 @@ 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}`;
},
@@ -430,7 +413,6 @@ export default {
if (inboxChanged) {
this.syncInboxData();
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
}
}
},
@@ -441,7 +423,6 @@ export default {
this.fetchHealthData();
this.$nextTick(() => {
this.setTabFromRouteParam();
this.openWhatsAppManualMigrationIfRequested();
});
} else {
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
@@ -452,52 +433,8 @@ 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(
@@ -811,12 +748,6 @@ 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"
@@ -1408,13 +1339,6 @@ export default {
@register-webhook="registerWebhook"
/>
</div>
<WhatsappManualMigrationDialog
v-if="showWhatsAppManualMigration"
ref="whatsappManualMigrationDialog"
:inbox="inbox"
:is-loading="isTransferringWhatsAppToManual"
@reconnect="transferWhatsAppToManualSetup"
/>
</div>
</section>
</div>
@@ -1,46 +0,0 @@
<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>
@@ -1,524 +0,0 @@
<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>
@@ -10,6 +10,7 @@ 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 {
@@ -21,6 +22,7 @@ export default {
SmtpSettings,
NextButton,
TextArea,
WhatsappReauthorize,
},
mixins: [inboxMixin],
props: {
@@ -37,6 +39,7 @@ export default {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
@@ -50,6 +53,9 @@ export default {
isEmbeddedSignupWhatsApp() {
return this.inbox.provider_config?.source === 'embedded_signup';
},
whatsappAppId() {
return window.chatwootConfig?.whatsappAppId;
},
isForwardingEnabled() {
return !!this.inbox.forwarding_enabled;
},
@@ -160,6 +166,11 @@ 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 {
@@ -351,17 +362,22 @@ export default {
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
v-if="whatsappAppId"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
<div class="flex flex-col gap-1 items-start">
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
<template v-else-if="!isEmbeddedSignupWhatsApp">
<template v-else>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
@@ -419,6 +435,12 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
ref="whatsappReauth"
:inbox="inbox"
class="hidden"
/>
</div>
</template>
-1
View File
@@ -11,7 +11,6 @@ const createConversation = params => {
name: params.fullName,
email: params.emailAddress,
phone_number: params.phoneNumber,
custom_attributes: params.contactCustomAttributes,
},
message: {
content: params.message,
@@ -32,50 +32,6 @@ 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(() => ({
+7 -6
View File
@@ -3,6 +3,7 @@ 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';
@@ -41,10 +42,6 @@ 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,
@@ -55,7 +52,6 @@ export default {
email: emailAddress,
name: fullName,
phone_number: phoneNumber,
custom_attributes: contactCustomAttributes,
},
});
} else {
@@ -67,9 +63,14 @@ export default {
message: message,
phoneNumber: phoneNumber,
customAttributes: conversationCustomAttributes,
contactCustomAttributes: contactCustomAttributes,
});
}
if (!isEmptyObject(contactCustomAttributes)) {
this.$store.dispatch(
'contacts/setCustomAttributes',
contactCustomAttributes
);
}
},
},
};
@@ -1,89 +0,0 @@
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();
});
});
-2
View File
@@ -22,8 +22,6 @@
# 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
-10
View File
@@ -33,7 +33,6 @@ 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?
@@ -130,15 +129,6 @@ 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 age threshold
# Skip stale backlog with no activity beyond the policy's age threshold (defaults to 7 days)
policy = inbox.assignment_policy
scope = apply_age_exclusions(scope, age_exclusion_hours(policy))
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Apply conversation priority using assignment policy if available
scope = if policy&.longest_waiting?
@@ -49,12 +49,6 @@ 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,17 +54,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def validate_provider_config?
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)
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{whatsapp_channel.provider_config['api_key']}")
response.success?
end
def api_headers
@@ -90,16 +81,6 @@ 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
-4
View File
@@ -249,7 +249,3 @@
display_name: Advanced Assignment
enabled: false
premium: true
- name: whatsapp_manual_transfer
display_name: WhatsApp Manual Transfer
enabled: false
column: feature_flags_ext_1
+1 -1
View File
@@ -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-4.1-mini'
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-5.6-luna'
locked: false
- name: CAPTAIN_OPEN_AI_ENDPOINT
display_title: 'OpenAI API Endpoint (optional)'
+67 -12
View File
@@ -35,6 +35,18 @@ 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'
@@ -78,11 +90,14 @@ 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-4.1-mini
default: gpt-5.6-luna
reasoning_effort: low
assistant:
models:
[
@@ -91,12 +106,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-4.1
default: gpt-5.6-terra
reasoning_effort: medium
copilot:
models:
[
@@ -105,16 +124,28 @@ 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-4.1
default: gpt-5.6-terra
reasoning_effort: medium
label_suggestion:
models:
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
default: gpt-4.1-mini
[
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
document_faq_generation:
models:
[
@@ -123,12 +154,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-4.1-mini
default: gpt-5.6-terra
reasoning_effort: medium
conversation_faq_generation:
models:
[
@@ -137,15 +172,20 @@ 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.2
default: gpt-5.6-terra
reasoning_effort: medium
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:
[
@@ -154,19 +194,34 @@ 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.2
default: gpt-5.6-terra
reasoning_effort: medium
onboarding_content_generation:
models:
[gpt-4.1, gpt-4.1-mini, gpt-5-mini, gpt-5.1, gpt-5.2]
default: gpt-4.1
[
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
help_center_query_translation:
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini]
default: gpt-4.1-nano
models: [gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gpt-5.6-luna]
default: gpt-5.6-luna
reasoning_effort: low
audio_transcription:
models: [gpt-4o-mini-transcribe, whisper-1]
default: gpt-4o-mini-transcribe
+160 -1
View File
@@ -27970,6 +27970,165 @@
"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",
@@ -61509,4 +61668,4 @@
"owned_by": "xai"
}
}
]
]
@@ -1,9 +0,0 @@
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
View File
@@ -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_10_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) 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_10_000000) do
create_table "captain_assistants", force: :cascade do |t|
t.string "name", null: false
t.bigint "account_id", null: false
t.text "description"
t.string "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {}, null: false
+2 -4
View File
@@ -4,7 +4,7 @@
#
# id :bigint not null, primary key
# config :jsonb not null
# description :text
# description :string
# guardrails :jsonb
# name :string not null
# response_guidelines :jsonb
@@ -17,8 +17,6 @@
# index_captain_assistants_on_account_id (account_id)
#
class Captain::Assistant < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
@@ -41,7 +39,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, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :description, presence: true
validates :account_id, presence: true
scope :ordered, -> { order(created_at: :desc) }
+1 -3
View File
@@ -21,8 +21,6 @@
# index_captain_scenarios_on_enabled (enabled)
#
class Captain::Scenario < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
@@ -45,7 +43,7 @@ class Captain::Scenario < ApplicationRecord
belongs_to :account
validates :title, presence: true
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :description, presence: true
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 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"
- 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"
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, age_exclusion_hours(policy))
scope = apply_age_exclusions(scope, policy&.exclude_older_than_hours)
# Then apply the capacity policy's exclusion rules (labels and age)
scope = apply_exclusion_rules(scope)
+1 -1
View File
@@ -1,7 +1,7 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
DEFAULT_MODEL = 'gpt-5.6-luna'.freeze
class << self
def initialized?
+2 -1
View File
@@ -1,7 +1,7 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.6-terra'.freeze
class << self
def resolve(feature:, account: nil)
@@ -17,6 +17,7 @@ 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
+6 -1
View File
@@ -15,6 +15,10 @@ 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
@@ -46,7 +50,8 @@ module Llm::Models
credit_multiplier: model['credit_multiplier']
}
end,
default: feature['default']
default: feature['default'],
reasoning_effort: feature['reasoning_effort']
}
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1'
DEFAULT_MODEL = 'gpt-5.6-luna'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
@@ -44,10 +44,12 @@ 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-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
@@ -55,15 +57,17 @@ 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-4.1',
selected: 'gpt-4.1',
model: 'gpt-5.6-terra',
selected: 'gpt-5.6-terra',
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
@@ -81,7 +85,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
it 'returns the Captain V2 assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
@@ -140,51 +140,6 @@ 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)
+1 -1
View File
@@ -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('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
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('gpt-5.2')
expect(dummy_instance.send(:agent_model)).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
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('gpt-5.2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
+12 -7
View File
@@ -12,25 +12,27 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to eq(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
it 'uses a valid account model override' do
account.update!(captain_models: { 'editor' => 'gpt-4.1' })
account.update!(captain_models: { 'editor' => 'gpt-5.6-terra' })
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
feature: 'editor',
provider: 'openai',
model: 'gpt-4.1',
model: 'gpt-5.6-terra',
reasoning_effort: 'low',
source: :account_override
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
it 'resolves the Captain V2 assistant default without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
@@ -38,7 +40,8 @@ RSpec.describe Llm::FeatureRouter do
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
model: described_class::CAPTAIN_V2_ASSISTANT_MODEL,
reasoning_effort: 'medium',
source: :default
)
expect(account.reload.captain_models).to be_nil
@@ -62,7 +65,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
@@ -73,7 +77,8 @@ RSpec.describe Llm::FeatureRouter do
resolved = described_class.resolve(feature: 'editor', account: account)
expect(resolved).to include(
model: 'gpt-4.1-mini',
model: 'gpt-5.6-luna',
reasoning_effort: 'low',
source: :default
)
end
+26 -3
View File
@@ -27,12 +27,33 @@ 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-4.1-mini')
expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2')
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')
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']
@@ -49,7 +70,9 @@ 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-4.1-mini')
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[:models].first).to include(
id: 'gpt-4.1-mini',
display_name: 'GPT-4.1 Mini',
+3 -2
View File
@@ -108,6 +108,7 @@ 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
@@ -392,10 +393,10 @@ RSpec.describe Account do
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
it 'returns the Captain V2 default for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_preferences[:models]['assistant']).to eq(Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL)
expect(account.reload.captain_models).to be_nil
end
end
-10
View File
@@ -42,18 +42,8 @@ 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,24 +239,6 @@ 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