Compare commits

..
Author SHA1 Message Date
Sony Mathew a0f3967148 ci: pin pnpm version in CircleCI 2026-07-13 14:30:20 +05:30
Tanmay Deep SharmaandGitHub 9c10fe4eb1 fix: default assignment age exclusion (#14998)
## Description
Auto-assignment was skipping the 7-day staleness check entirely for
inboxes that don't have an assignment policy attached. Those inboxes
would pull unassigned conversations of any age off the backlog and hand
them to agents — including conversations untouched for months — while
the activity log still credited "Default Policy" for the assignment.
This makes the default behaviour match what that label implies: with no
policy configured, conversations with no activity in the last 7 days are
now excluded, the same window a freshly created policy uses.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 14:01:47 +05:30
Shivam MishraandGitHub 6b3800e25b fix: pre-chat form contact custom attributes lost for existing contacts (#14956)
Contact custom attributes filled in the pre-chat form (e.g. a CPF field)
were silently lost when the visitor's email or phone matched an existing
contact. The widget sent the attributes in a separate request that raced
the conversation create: creating the conversation merges the widget
contact into the existing contact, so the attribute update landed on the
destroyed contact and vanished without an error. New visitors were
unaffected, which made the bug hard to spot.

The fix sends contact custom attributes inside the same request that
identifies the contact. The widget conversation create endpoint now
accepts `contact.custom_attributes` and applies them through
`ContactIdentifyAction` in the same transaction as the merge, so the
submitted values always land on the surviving contact. The campaign path
folds the attributes into the existing contact update call the same way.

<details>
<summary>Reproduction script (rails runner, concurrent
threads)</summary>

```ruby
# Concurrent reproduction of the pre-chat form race that loses contact
# custom attributes when the visitor's email matches an existing contact.
#
# The old widget fired two unawaited requests on pre-chat submit. Each
# iteration replays them as real concurrent threads:
#   - create thread = POST /widget/conversations: resolves the widget contact,
#     then runs ContactIdentifyAction (which merges the widget contact into the
#     existing contact) inside a transaction held open while the conversation
#     and message are created.
#   - patch thread = PATCH /widget/contact: resolves the widget contact via its
#     contact inbox and applies the custom attributes through
#     ContactIdentifyAction, exactly like Widget::ContactsController#update.
#
# Run with: bundle exec rails runner confirm_prechat_race.rb
# Creates throwaway contacts on Account.first and deletes them afterwards.

ITERATIONS = 20
CPF = '123.456.789-09'.freeze

account = Account.first!
inbox = account.inboxes.first!
losses = 0

ITERATIONS.times do |i|
  existing = account.contacts.create!(name: 'Existing Contact', email: "race-existing-#{SecureRandom.hex(6)}@example.com")
  temp = account.contacts.create!(name: 'Widget Visitor')
  contact_inbox = ContactInbox.create!(contact: temp, inbox: inbox, source_id: SecureRandom.uuid)

  begin
    create_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ActiveRecord::Base.transaction do
          ContactIdentifyAction.new(
            contact: widget_contact,
            params: { email: existing.email, phone_number: nil, name: 'Widget Visitor' },
            retain_original_contact_name: true,
            discard_invalid_attrs: true
          ).perform
          sleep(0.02) # conversation + message creation keeps the transaction open
        end
      end
    end

    patch_request = Thread.new do
      ActiveRecord::Base.connection_pool.with_connection do
        sleep(rand * 0.02) # network jitter between the two requests
        widget_contact = ContactInbox.find(contact_inbox.id).contact # set_contact before_action
        ContactIdentifyAction.new(
          contact: widget_contact,
          params: { custom_attributes: { cpf: CPF } },
          discard_invalid_attrs: true
        ).perform
      end
    end

    create_request.join
    patch_request.join

    survivor = existing.reload
    if survivor.custom_attributes['cpf'] == CPF
      puts "iteration #{i + 1}: CPF survived"
    else
      losses += 1
      puts "iteration #{i + 1}: CPF LOST (survivor custom_attributes: #{survivor.custom_attributes.inspect})"
    end
  ensure
    account.contacts.where(id: [existing.id, temp.id]).find_each(&:destroy!)
  end
end

puts
puts "#{losses}/#{ITERATIONS} iterations lost the CPF -- race #{losses.positive? ? 'confirmed' : 'not reproduced in this run'}"
```

Result: **20/20 iterations lose the CPF** (consistent across repeated
runs). The conversation create holds its transaction open across the
contact merge, so the fast attribute update either resolves the
soon-to-be-destroyed widget contact or blocks on its row lock and then
updates 0 rows — silently, with no error. This is why the customer sees
the loss every time, not intermittently.

Note: the script replays the old two-request flow at the service layer,
so it reproduces the loss even with this fix applied — the fix works by
removing the second request from the widget, not by changing the raced
code paths. The new request spec (`saves contact custom attributes on
the surviving contact when merged into an existing contact`) covers the
fixed contract.

</details>

## Closes

- Reported via support conversation:
https://app.chatwoot.com/app/accounts/1/conversations/84465

## How to reproduce

1. Create a website inbox with a pre-chat form that has a contact custom
attribute field (e.g. CPF).
2. Create a contact with a known email address.
3. As a visitor, open the widget and fill the pre-chat form using that
same email plus a value for the custom attribute.
4. Before: the attribute never appears on the contact profile. After: it
is saved on the existing contact, overwriting any stale value.

## What changed

- `POST /api/v1/widget/conversations` now permits
`contact.custom_attributes` and passes it to `ContactIdentifyAction`,
which deep-merges it atomically with the contact merge.
- The widget pre-chat form sends contact custom attributes inside the
conversation create payload instead of a separate `setCustomAttributes`
call; the campaign path includes them in the `contacts/update` payload.
2026-07-13 13:51:58 +05:30
b560720e08 feat: allow switching embedded signup whatsapp embedded inboxes to manual setup (#14975)
Since embedded signup has been disabled on production, WhatsApp inboxes
that were created through it have no way to be managed going forward.
This PR lets admins transfer an embedded signup inbox to a manual Cloud
API setup: the inbox settings Configuration tab now shows the webhook
verification token and a "Switch to Manual Setup" form (pre-populated
with the Phone Number ID, Business Account ID, and API key stored during
the embedded signup journey) instead of the old Reconfigure button.

Saving the form updates the channel's `provider_config` without the
`source: embedded_signup` marker, so the inbox becomes a regular
manually-configured WhatsApp Cloud inbox. The backend re-validates the
submitted credentials against Meta before accepting the change.

## How to test

1. Open the settings page of a WhatsApp inbox created via embedded
signup → Configuration tab.
2. The Reconfigure button is gone; you see the webhook verification
token and a Switch to Manual Setup form pre-filled with the stored
credentials.
3. Configure the webhook in your own Meta app using the verification
token, enter a permanent access token from that app, and click "Switch
to Manual Setup".
4. On success the page switches to the standard manual configuration
view (verify token, API key update), and messaging continues to work
with the new credentials. Invalid credentials are rejected with an
error.

## What changed

- `ConfigurationPage.vue`: replaced the embedded-signup Reconfigure
section (and the hidden reauthorize component) with the manual transfer
form.
- New `WHATSAPP_MANUAL_TRANSFER_*` translation keys.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-07-13 13:48:38 +05:30
Aakash BakhleandGitHub cc87429903 feat(captain): expand assistant description limit (#14985)
# Pull Request Template

## Description

Increases description for Captain.

Why?
We are planning to include business context in description and 255 char
limit on the column and 200 char limit on the UI are very limiting to
get proper context.

## Type of change

Improvement to accommodate business context

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
locally

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-13 12:59:36 +05:30
47 changed files with 1060 additions and 344 deletions
+6 -3
View File
@@ -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')
"
@@ -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"
@@ -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"
+1
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+1
View File
@@ -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(() => ({
+6 -7
View File
@@ -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();
});
});
+2
View File
@@ -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
+10
View File
@@ -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
+4
View File
@@ -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
+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-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
View File
@@ -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
View File
@@ -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
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_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 -2
View File
@@ -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) }
+3 -1
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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)
+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(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
+7 -12
View File
@@ -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
+3 -26
View File
@@ -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',
+2 -3
View File
@@ -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
+10
View File
@@ -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