diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 9f56c3817..bb9ca2a70 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb
index 5b87e2d1a..3912e5b6e 100644
--- a/app/controllers/api/v1/widget/base_controller.rb
+++ b/app/controllers/api/v1/widget/base_controller.rb
@@ -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,
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index 00e718614..8f5977d54 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -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
diff --git a/app/javascript/dashboard/components-next/banner/Banner.vue b/app/javascript/dashboard/components-next/banner/Banner.vue
index c9b86d42b..f44a6ae5e 100644
--- a/app/javascript/dashboard/components-next/banner/Banner.vue
+++ b/app/javascript/dashboard/components-next/banner/Banner.vue
@@ -61,10 +61,10 @@ const triggerAction = () => {
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
index 0c1ac3a14..97a884555 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/CloudWhatsapp.vue
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
+import InboxesAPI from 'dashboard/api/inboxes';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -12,6 +13,12 @@ export default {
components: {
NextButton,
},
+ props: {
+ enableCallingOnComplete: {
+ type: Boolean,
+ default: false,
+ },
+ },
setup() {
return { v$: useVuelidate() };
},
@@ -59,6 +66,14 @@ export default {
}
);
+ if (this.enableCallingOnComplete) {
+ try {
+ await InboxesAPI.enableWhatsappCalling(whatsappChannel.id);
+ } catch (_) {
+ useAlert(this.$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE_FAILED'));
+ }
+ }
+
router.replace({
name: 'settings_inboxes_add_agents',
params: {
@@ -165,6 +180,7 @@ export default {
-import { ref, onMounted } from 'vue';
+import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
+import Banner from 'dashboard/components-next/banner/Banner.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
+import { useAccount } from 'dashboard/composables/useAccount';
+import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const { t } = useI18n();
+const { isOnChatwootCloud } = useAccount();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
+const isInstagramConnectionRestricted = computed(() => {
+ return isOnChatwootCloud.value;
+});
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
@@ -56,7 +64,7 @@ const requestAuthorization = async () => {
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
@@ -68,11 +76,36 @@ const requestAuthorization = async () => {
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
- :disabled="isRequestingAuthorization"
+ :disabled="
+ isRequestingAuthorization || isInstagramConnectionRestricted
+ "
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
/>
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.RESTRICTED_WARNING') }}
+
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index 98cc90fee..3e822f796 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -7,11 +7,13 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
-import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
+import { useAccount } from 'dashboard/composables/useAccount';
+import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
+const { isOnChatwootCloud } = useAccount();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
@@ -22,9 +24,12 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
};
+const isWhatsappEmbeddedSignupRestricted = computed(() => {
+ return isOnChatwootCloud.value;
+});
+
const hasWhatsappAppId = computed(() => {
return (
- !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -103,7 +108,11 @@ const handleManualLinkClick = () => {
hasWhatsappAppId && selectedProvider === PROVIDER_TYPES.WHATSAPP
"
>
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
index c27cd7d1f..abaa6c0e3 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
@@ -1,11 +1,11 @@
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index 3dda0ad8e..0972e4b95 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
+import Banner from 'next/banner/Banner.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
@@ -17,6 +18,22 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ isDisabled: {
+ type: Boolean,
+ default: false,
+ },
+ showRestrictionAlert: {
+ type: Boolean,
+ default: false,
+ },
+ restrictionStatusUrl: {
+ type: String,
+ default: '',
+ },
+ restrictionWarningText: {
+ type: String,
+ default: '',
+ },
});
const store = useStore();
@@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => {
};
const launchEmbeddedSignup = async () => {
+ if (props.isDisabled) return;
+
let credentials;
try {
credentials = await runEmbeddedSignup();
@@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => {
+
+
+
+
+ {{
+ restrictionWarningText ||
+ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING')
+ }}
+
+ {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }}
+
+
+
+
+
+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'),
+}));
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue
new file mode 100644
index 000000000..6e7136a02
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/WhatsappManualMigrationDialog.vue
@@ -0,0 +1,524 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
index 8beaea489..e1ecdbd3f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue
@@ -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 {
-
-
- {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
-
-
+
-
+
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index 713de56f1..b1c76e94f 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -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,
diff --git a/app/javascript/widget/api/specs/endPoints.spec.js b/app/javascript/widget/api/specs/endPoints.spec.js
index b95b2f659..cf7f2486b 100644
--- a/app/javascript/widget/api/specs/endPoints.spec.js
+++ b/app/javascript/widget/api/specs/endPoints.spec.js
@@ -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(() => ({
diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue
index 4872edbcd..5bbac0596 100644
--- a/app/javascript/widget/views/PreChatForm.vue
+++ b/app/javascript/widget/views/PreChatForm.vue
@@ -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
- );
- }
},
},
};
diff --git a/app/javascript/widget/views/specs/PreChatForm.spec.js b/app/javascript/widget/views/specs/PreChatForm.spec.js
new file mode 100644
index 000000000..25bb16b53
--- /dev/null
+++ b/app/javascript/widget/views/specs/PreChatForm.spec.js
@@ -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();
+ });
+});
diff --git a/app/models/assignment_policy.rb b/app/models/assignment_policy.rb
index 69b619581..12c46aa97 100644
--- a/app/models/assignment_policy.rb
+++ b/app/models/assignment_policy.rb
@@ -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
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 2a558cd13..7a109c455 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -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
diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb
index c4c494d57..b29f0ca02 100644
--- a/app/services/auto_assignment/assignment_service.rb
+++ b/app/services/auto_assignment/assignment_service.rb
@@ -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?
diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb
index 1b3ee3fb2..e00f8357d 100644
--- a/app/services/conversations/unread_counts.rb
+++ b/app/services/conversations/unread_counts.rb
@@ -2,9 +2,9 @@ module Conversations::UnreadCounts
READY_TTL = 24.hours.to_i
SET_TTL = 25.hours.to_i
FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i
- FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i
+ FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i
FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW
FILTERED_COUNT_VERSION_TTL = SET_TTL
- FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i
- MAX_INLINE_FILTER_BUILDS = 10
+ FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i
+ MAX_INLINE_FILTER_BUILDS = 3
end
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 7981f54b5..69631c468 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -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
diff --git a/config/features.yml b/config/features.yml
index 80a3afdf1..c122e7cfc 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -249,8 +249,11 @@
display_name: Advanced Assignment
enabled: false
premium: true
+- name: whatsapp_manual_transfer
+ display_name: WhatsApp Manual Transfer
+ enabled: false
+ column: feature_flags_ext_1
- name: delayed_automations
display_name: Delayed Automations
enabled: false
- chatwoot_internal: true
column: feature_flags_ext_1
diff --git a/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb b/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb
new file mode 100644
index 000000000..4d68ddc3a
--- /dev/null
+++ b/db/migrate/20260710000000_change_captain_assistant_description_to_text.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 45e09a5b3..c65d8df01 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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_09_060200) 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"
@@ -362,7 +362,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_09_060200) 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
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 0735987de..d3f6cda8a 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -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) }
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index 8a6a3c979..895133897 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -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
diff --git a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
index fb6bab33b..10aa4e4ce 100644
--- a/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
+++ b/enterprise/app/services/captain/onboarding/website_analyzer_service.rb
@@ -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}
diff --git a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
index 36bbb6c90..207e93889 100644
--- a/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
+++ b/enterprise/app/services/enterprise/auto_assignment/assignment_service.rb
@@ -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)
diff --git a/package.json b/package.json
index 917a1b97d..d964a30a4 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"prosemirror-commands": "^1.7.1",
+ "prosemirror-inputrules": "^1.4.0",
"prosemirror-schema-list": "^1.5.1",
"qrcode": "^1.5.4",
"semver": "7.6.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 80fbf318a..0ffb85c18 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -183,6 +183,9 @@ importers:
prosemirror-commands:
specifier: ^1.7.1
version: 1.7.1
+ prosemirror-inputrules:
+ specifier: ^1.4.0
+ version: 1.4.0
prosemirror-schema-list:
specifier: ^1.5.1
version: 1.5.1
@@ -9037,7 +9040,7 @@ snapshots:
prosemirror-state@1.4.3:
dependencies:
prosemirror-model: 1.22.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-tables@1.5.0:
@@ -9065,7 +9068,7 @@ snapshots:
dependencies:
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
proto-list@1.2.4: {}
diff --git a/spec/controllers/api/v1/widget/conversations_controller_spec.rb b/spec/controllers/api/v1/widget/conversations_controller_spec.rb
index 6966c87ea..56bb01282 100644
--- a/spec/controllers/api/v1/widget/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/widget/conversations_controller_spec.rb
@@ -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)
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 9adc98cc0..b91d04455 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -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
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index cd4fa3a21..10a192004 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -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
diff --git a/spec/services/auto_assignment/assignment_service_spec.rb b/spec/services/auto_assignment/assignment_service_spec.rb
index 75dfaa532..44bbf89b1 100644
--- a/spec/services/auto_assignment/assignment_service_spec.rb
+++ b/spec/services/auto_assignment/assignment_service_spec.rb
@@ -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
diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
index 7732eb2dd..3a7cd52d8 100644
--- a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
+++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb
@@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
- expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
+ expect(
+ described_class.built_in_filter_counts_state(
+ account_id: account_id,
+ user_id: user_id,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL +
+ Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second
+ )
+ ).to be_expired
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
@@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
)
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
- expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
- expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ )
+ ).to be(false)
+ expect(
+ described_class.refresh_due?(
+ snapshot,
+ now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
+ ).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
index bb5d419a6..2904d1e4e 100644
--- a/spec/services/conversations/unread_counts/filtered_counter_spec.rb
+++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb
@@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
create(:mention, account: account, conversation: second_mention, user: agent)
store.bump_conversation_version!(account.id)
- expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second
+ ).perform[:mentions_count]
+ ).to eq(1)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
- expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
+ expect(
+ described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ ).perform[:mentions_count]
+ ).to eq(2)
end
it 'returns stale built-in counts when a refresh build hits a database error' do
@@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do
store.bump_conversation_version!(account.id)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
- failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
+ failing_counter = described_class.new(
+ account: account,
+ user: agent,
+ now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second
+ )
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
expect(failing_counter.perform[:mentions_count]).to eq(1)