From 9fab70aebfd255160fb9c047c9da1678c0daed7c Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 25 Feb 2026 09:08:24 -0800 Subject: [PATCH 01/90] fix: Use search API instead of filter in the filter in the endpoints (#13651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `POST /contacts/filter` with `GET /contacts/search` for contact lookup in compose new conversation - Remove client-side input-type detection logic (`generateContactQuery`, key filtering by email/phone/name) — the search API handles matching across name, email, phone_number, and identifier server-side via a single `ILIKE` query - Filter the contacts with emails in cc and bcc fields. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: iamsivin --- .../components/ComposeNewConversationForm.vue | 20 +--- .../components/EmailOptions.vue | 18 +-- .../helpers/composeConversationHelper.js | 28 +---- .../specs/composeConversationHelper.spec.js | 104 ++---------------- .../components/SearchContactAgentSelector.vue | 5 +- 5 files changed, 27 insertions(+), 148 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 5ba97b779..0ed22ad0c 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -158,21 +158,7 @@ const isAnyDropdownActive = computed(() => { const handleContactSearch = value => { showContactsDropdown.value = true; - const query = typeof value === 'string' ? value.trim() : ''; - const hasAlphabet = Array.from(query).some(char => { - const lower = char.toLowerCase(); - const upper = char.toUpperCase(); - return lower !== upper; - }); - const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query); - - const keys = ['email', 'phone_number', 'name'].filter(key => { - if (key === 'phone_number' && hasAlphabet) return false; - if (key === 'name' && isEmailLike) return false; - return true; - }); - - emit('searchContacts', { keys, query: value }); + emit('searchContacts', value); }; const handleDropdownUpdate = (type, value) => { @@ -187,12 +173,12 @@ const handleDropdownUpdate = (type, value) => { const searchCcEmails = value => { showCcEmailsDropdown.value = true; - emit('searchContacts', { keys: ['email'], query: value }); + emit('searchContacts', value); }; const searchBccEmails = value => { showBccEmailsDropdown.value = true; - emit('searchContacts', { keys: ['email'], query: value }); + emit('searchContacts', value); }; const setSelectedContact = async ({ value, action, ...rest }) => { diff --git a/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue b/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue index 4e0c71adb..c6013b093 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/EmailOptions.vue @@ -44,14 +44,16 @@ const bccEmailsArray = computed(() => ); const contactEmailsList = computed(() => { - return props.contacts?.map(({ name, id, email }) => ({ - id, - label: email, - email, - thumbnail: { name: name, src: '' }, - value: id, - action: 'email', - })); + return props.contacts + ?.filter(contact => contact.email) + .map(({ name, id, email }) => ({ + id, + label: email, + email, + thumbnail: { name: name, src: '' }, + value: id, + action: 'email', + })); }); // Handle updates from TagInput and convert array back to string diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js index 482988581..2139e8cf8 100644 --- a/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js +++ b/app/javascript/dashboard/components-next/NewConversation/helpers/composeConversationHelper.js @@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({ }; }; -export const generateContactQuery = ({ keys = ['email'], query }) => { - return { - payload: keys.map(key => { - const filterPayload = { - attribute_key: key, - filter_operator: 'contains', - values: [query], - attribute_model: 'standard', - }; - if (keys.findIndex(k => k === key) !== keys.length - 1) { - filterPayload.query_operator = 'or'; - } - return filterPayload; - }), - }; -}; - // API Calls -export const searchContacts = async ({ keys, query }) => { +export const searchContacts = async query => { + const trimmed = typeof query === 'string' ? query.trim() : ''; + if (!trimmed) return []; + const { data: { payload }, - } = await ContactAPI.filter( - undefined, - 'name', - generateContactQuery({ keys, query }) - ); + } = await ContactAPI.search(trimmed); const camelCasedPayload = camelcaseKeys(payload, { deep: true }); // Filter contacts that have either phone_number or email const filteredPayload = camelCasedPayload?.filter( diff --git a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js index 105fe46a0..985e473b3 100644 --- a/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js +++ b/app/javascript/dashboard/components-next/NewConversation/helpers/specs/composeConversationHelper.spec.js @@ -336,70 +336,6 @@ describe('composeConversationHelper', () => { }); }); - describe('generateContactQuery', () => { - it('generates correct query structure for contact search', () => { - const query = 'test@example.com'; - const expected = { - payload: [ - { - attribute_key: 'email', - filter_operator: 'contains', - values: [query], - attribute_model: 'standard', - }, - ], - }; - - expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual( - expected - ); - }); - - it('handles empty query', () => { - const expected = { - payload: [ - { - attribute_key: 'email', - filter_operator: 'contains', - values: [''], - attribute_model: 'standard', - }, - ], - }; - - expect( - helpers.generateContactQuery({ keys: ['email'], query: '' }) - ).toEqual(expected); - }); - - it('handles mutliple keys', () => { - const expected = { - payload: [ - { - attribute_key: 'email', - filter_operator: 'contains', - values: ['john'], - attribute_model: 'standard', - query_operator: 'or', - }, - { - attribute_key: 'phone_number', - filter_operator: 'contains', - values: ['john'], - attribute_model: 'standard', - }, - ], - }; - - expect( - helpers.generateContactQuery({ - keys: ['email', 'phone_number'], - query: 'john', - }) - ).toEqual(expected); - }); - }); - describe('API calls', () => { describe('searchContacts', () => { it('searches contacts and returns camelCase results', async () => { @@ -413,14 +349,11 @@ describe('composeConversationHelper', () => { }, ]; - ContactAPI.filter.mockResolvedValue({ + ContactAPI.search.mockResolvedValue({ data: { payload: mockPayload }, }); - const result = await helpers.searchContacts({ - keys: ['email'], - query: 'john', - }); + const result = await helpers.searchContacts('john'); expect(result).toEqual([ { @@ -432,16 +365,7 @@ describe('composeConversationHelper', () => { }, ]); - expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', { - payload: [ - { - attribute_key: 'email', - filter_operator: 'contains', - values: ['john'], - attribute_model: 'standard', - }, - ], - }); + expect(ContactAPI.search).toHaveBeenCalledWith('john'); }); it('searches contacts and returns only contacts with email or phone number', async () => { @@ -469,14 +393,11 @@ describe('composeConversationHelper', () => { }, ]; - ContactAPI.filter.mockResolvedValue({ + ContactAPI.search.mockResolvedValue({ data: { payload: mockPayload }, }); - const result = await helpers.searchContacts({ - keys: ['email'], - query: 'john', - }); + const result = await helpers.searchContacts('john'); // Should only return contacts with either email or phone number expect(result).toEqual([ @@ -496,20 +417,11 @@ describe('composeConversationHelper', () => { }, ]); - expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', { - payload: [ - { - attribute_key: 'email', - filter_operator: 'contains', - values: ['john'], - attribute_model: 'standard', - }, - ], - }); + expect(ContactAPI.search).toHaveBeenCalledWith('john'); }); it('handles empty search results', async () => { - ContactAPI.filter.mockResolvedValue({ + ContactAPI.search.mockResolvedValue({ data: { payload: [] }, }); @@ -536,7 +448,7 @@ describe('composeConversationHelper', () => { }, ]; - ContactAPI.filter.mockResolvedValue({ + ContactAPI.search.mockResolvedValue({ data: { payload: mockPayload }, }); diff --git a/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue b/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue index ff09c9467..17edc33a5 100644 --- a/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue +++ b/app/javascript/dashboard/modules/search/components/SearchContactAgentSelector.vue @@ -119,10 +119,7 @@ const debouncedSearch = debounce(async query => { } try { - const contacts = await searchContacts({ - keys: ['name', 'email', 'phone_number'], - query, - }); + const contacts = await searchContacts(query); // Add selected contact to top if not already in results const allContacts = selectedContact.value From e2dd2ccb42420a70eb17f760d504234749c96f96 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 25 Feb 2026 18:22:41 -0800 Subject: [PATCH 02/90] feat: Add a priority + created at sort for conversations (#13658) - Add a new conversation sort option "Priority: Highest first, Created: Oldest first" that sorts by priority descending (urgent > high > medium > low > none) with created_at ascending as the tiebreaker --- app/finders/conversation_finder.rb | 1 + .../widgets/conversation/ConversationBasicFilter.vue | 4 ++++ app/javascript/dashboard/constants/globals.js | 1 + app/javascript/dashboard/i18n/locale/en/chatlist.json | 3 +++ .../dashboard/store/modules/conversations/helpers.js | 9 +++++++++ app/models/concerns/sort_handler.rb | 4 ++++ 6 files changed, 22 insertions(+) diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 7821bee49..fa437327d 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -11,6 +11,7 @@ class ConversationFinder 'priority_desc' => %w[sort_on_priority desc], 'waiting_since_asc' => %w[sort_on_waiting_since asc], 'waiting_since_desc' => %w[sort_on_waiting_since desc], + 'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc], # To be removed in v3.5.0 'latest' => %w[sort_on_last_activity_at desc], diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue index d699923f5..fa1563ed8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue @@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [ label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'), value: 'priority_asc', }, + { + label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'), + value: 'priority_desc_created_at_asc', + }, { label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'), value: 'waiting_since_asc', diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js index eb42a270f..21303efcb 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -21,6 +21,7 @@ export default { PRIORITY_DESC: 'priority_desc', WAITING_SINCE_ASC: 'waiting_since_asc', WAITING_SINCE_DESC: 'waiting_since_desc', + PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc', }, ARTICLE_STATUS_TYPES: { DRAFT: 0, diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 92e67635b..0e8e87a04 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -76,6 +76,9 @@ }, "waiting_since_desc": { "TEXT": "Pending Response: Shortest first" + }, + "priority_desc_created_at_asc": { + "TEXT": "Priority: Highest first, Created: Oldest first" } }, "ATTACHMENTS": { diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js index ebbdcbe64..af6b16023 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers.js @@ -116,6 +116,7 @@ const SORT_OPTIONS = { priority_desc: ['sortOnPriority', 'desc'], waiting_since_asc: ['sortOnWaitingSince', 'asc'], waiting_since_desc: ['sortOnWaitingSince', 'desc'], + priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'], }; const sortAscending = (valueA, valueB) => valueA - valueB; const sortDescending = (valueA, valueB) => valueB - valueA; @@ -139,6 +140,14 @@ const sortConfig = { return getSortOrderFunction(sortDirection)(p1, p2); }, + sortOnPriorityCreatedAt: (a, b) => { + const DEFAULT_FOR_NULL = 0; + const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL; + const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL; + if (p1 !== p2) return p2 - p1; + return a.created_at - b.created_at; + }, + sortOnWaitingSince: (a, b, sortDirection) => { const sortFunc = getSortOrderFunction(sortDirection); if (!a.waiting_since || !b.waiting_since) { diff --git a/app/models/concerns/sort_handler.rb b/app/models/concerns/sort_handler.rb index 00eb73717..065fa7fea 100644 --- a/app/models/concerns/sort_handler.rb +++ b/app/models/concerns/sort_handler.rb @@ -14,6 +14,10 @@ module SortHandler order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC")) end + def sort_on_priority_created_at(sort_direction = :desc) + order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC")) + end + def sort_on_waiting_since(sort_direction = :asc) order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC")) end From 3ddab3ab26e82c5649b93953b4f40c1027e56160 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Thu, 26 Feb 2026 12:54:40 +0530 Subject: [PATCH 03/90] fix: show upgrade prompt when email transcript returns 402 (#13650) - Show a specific upgrade prompt when free-plan users attempt to send an email transcript and the API returns a 402 Payment Required error - Previously, a generic "There was an error, please try again" message was shown for all failures, including plan restrictions Fixes https://linear.app/chatwoot/issue/CW-6538/show-ui-feedback-for-email-transcript-402-plan-restriction --- .../widgets/conversation/EmailTranscriptModal.vue | 7 ++++++- app/javascript/dashboard/i18n/locale/en/conversation.json | 1 + .../dashboard/store/modules/conversations/actions.js | 6 +----- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue b/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue index 5c1c79d23..bd0f645cf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue +++ b/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue @@ -85,7 +85,12 @@ export default { useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS')); this.onCancel(); } catch (error) { - useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR')); + const status = error?.response?.status; + if (status === 402) { + useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED')); + } else { + useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR')); + } } finally { this.isSubmitting = false; } diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 951a46993..23552a162 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -305,6 +305,7 @@ "CANCEL": "Cancel", "SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully", "SEND_EMAIL_ERROR": "There was an error, please try again", + "SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.", "FORM": { "SEND_TO_CONTACT": "Send the transcript to the customer", "SEND_TO_AGENT": "Send the transcript to the assigned agent", diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 0c4c084e0..c6a197d85 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -457,11 +457,7 @@ const actions = { }, sendEmailTranscript: async (_, { conversationId, email }) => { - try { - await ConversationApi.sendEmailTranscript({ conversationId, email }); - } catch (error) { - throw new Error(error); - } + await ConversationApi.sendEmailTranscript({ conversationId, email }); }, updateCustomAttributes: async ( From 109b43aadb7da14a11a846c4771fcd3b12e7ad28 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:05:05 +0530 Subject: [PATCH 04/90] chore: Disable API channel reply editor outside 24h window (#13664) --- .../widgets/WootWriter/ReplyBottomPanel.vue | 6 +++++- .../components/widgets/conversation/ReplyBox.vue | 12 ++++++++---- .../dashboard/i18n/locale/en/conversation.json | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index bc0001238..5f76041dc 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -380,7 +380,11 @@ export default { @click="$emit('selectContentTemplate')" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index e55f13b7c..23b4d89a8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -200,9 +200,13 @@ export default { }, messagePlaceHolder() { if (this.isEditorDisabled) { - return this.isAWhatsAppChannel - ? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP') - : this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED'); + if (this.isAWhatsAppChannel) { + return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP'); + } + if (this.isAPIInbox) { + return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API'); + } + return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED'); } return this.isPrivate ? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT') @@ -427,7 +431,7 @@ export default { }, isEditorDisabled() { return ( - this.isAWhatsAppChannel && + (this.isAWhatsAppChannel || this.isAPIInbox) && !this.isOnPrivateNote && !this.currentChat.can_reply ); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 23552a162..835a7e512 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -192,6 +192,7 @@ "PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents", "MESSAGING_RESTRICTED": "You cannot reply to this conversation", "MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction", + "MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction", "MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.", "COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up", "CLICK_HERE": "Click here to update", From 6b3f1114fdb117c2953c01eb824194b504c8996d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 26 Feb 2026 14:45:15 +0400 Subject: [PATCH 05/90] fix(slack): Show correct sender name and avatar for Slack replies (#13624) --- .../components-next/message/Message.vue | 17 ++- .../widget/components/AgentMessage.vue | 14 ++- .../slack/slack_message_helper.rb | 26 +++- .../slack/incoming_message_builder_spec.rb | 114 ++++++++++++++++-- 4 files changed, 151 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 66234984c..3de9d2d04 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -129,6 +129,7 @@ const props = defineProps({ inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties isEmailInbox: { type: Boolean, default: false }, private: { type: Boolean, default: false }, + additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties sender: { type: Object, default: null }, senderId: { type: Number, default: null }, senderType: { type: String, default: null }, @@ -172,7 +173,10 @@ const variant = computed(() => { return MESSAGE_VARIANTS.AGENT; } - const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT; + const isBot = + props.sender?.type === SENDER_TYPES.AGENT_BOT || + props.senderType === SENDER_TYPES.AGENT_BOT || + (!props.sender && !props.additionalAttributes?.senderName); if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { return MESSAGE_VARIANTS.BOT; } @@ -450,12 +454,13 @@ const avatarInfo = computed(() => { }; } - // If no sender, return bot info + // If no sender, check for Slack (or other integration) sender info if (!props.sender) { - return { - name: t('CONVERSATION.BOT'), - src: '', - }; + const { senderName, senderAvatarUrl } = props.additionalAttributes || {}; + if (senderName) { + return { name: senderName, src: senderAvatarUrl ?? '' }; + } + return { name: t('CONVERSATION.BOT'), src: '' }; } const { sender } = props; diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index e8d245ddb..a13f7bf65 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -72,6 +72,10 @@ export default { return this.message.sender.available_name || this.message.sender.name; } + if (this.message.additional_attributes?.sender_name) { + return this.message.additional_attributes.sender_name; + } + if (this.useInboxAvatarForBot) { return this.channelConfig.websiteName; } @@ -87,9 +91,13 @@ export default { return displayImage; } - return this.message.sender - ? this.message.sender.avatar_url - : displayImage; + if (this.message.sender) { + return this.message.sender.avatar_url; + } + + return ( + this.message.additional_attributes?.sender_avatar_url || displayImage + ); }, hasRecordedResponse() { return ( diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb index 0ee328fb3..3af57a4c3 100644 --- a/lib/integrations/slack/slack_message_helper.rb +++ b/lib/integrations/slack/slack_message_helper.rb @@ -27,6 +27,10 @@ module Integrations::Slack::SlackMessageHelper end def create_message + resolved_sender, sender_name, sender_avatar_url = resolve_slack_sender + slack_sender_attrs = {} + slack_sender_attrs[:sender_name] = sender_name if sender_name + slack_sender_attrs[:sender_avatar_url] = sender_avatar_url if sender_avatar_url @message = conversation.messages.build( message_type: :outgoing, account_id: conversation.account_id, @@ -34,7 +38,8 @@ module Integrations::Slack::SlackMessageHelper content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''), external_source_id_slack: params[:event][:ts], private: private_note?, - sender: sender + sender: resolved_sender, + additional_attributes: slack_sender_attrs ) process_attachments(params[:event][:files]) if attachments_present? @message.save! @@ -81,9 +86,22 @@ module Integrations::Slack::SlackMessageHelper @conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first end - def sender - user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email] - conversation.account.users.from_email(user_email) + def resolve_slack_sender + return [nil, nil, nil] unless params[:event][:user] + + slack_user = slack_client.users_info(user: params[:event][:user])[:user] + chatwoot_user = conversation.account.users.from_email(slack_user[:profile][:email]) + return [chatwoot_user, nil, nil] if chatwoot_user + + sender_name = slack_user.dig(:profile, :display_name).presence || + slack_user[:real_name].presence || + slack_user[:name] + sender_avatar_url = slack_user.dig(:profile, :image_192).presence + [nil, sender_name, sender_avatar_url] + rescue Slack::Web::Api::Errors::MissingScope + raise + rescue StandardError + [nil, nil, nil] end def private_note? diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb index 2ce206489..65234767e 100644 --- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -69,7 +69,7 @@ describe Integrations::Slack::IncomingMessageBuilder do expect(hook).not_to be_nil messages_count = conversation.messages.count builder = described_class.new(message_params) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) 2.times.each { builder.perform } expect(conversation.messages.count).to eql(messages_count + 1) expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again') @@ -79,7 +79,7 @@ describe Integrations::Slack::IncomingMessageBuilder do expect(hook).not_to be_nil messages_count = conversation.messages.count builder = described_class.new(message_params) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) builder.perform expect(conversation.messages.count).to eql(messages_count + 1) expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again') @@ -89,7 +89,7 @@ describe Integrations::Slack::IncomingMessageBuilder do expect(hook).not_to be_nil messages_count = conversation.messages.count builder = described_class.new(private_message_params) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) builder.perform expect(conversation.messages.count).to eql(messages_count + 1) expect(conversation.messages.last.content).to eql('pRivate: A private note message') @@ -130,7 +130,7 @@ describe Integrations::Slack::IncomingMessageBuilder do messages_count = conversation.messages.count message_with_attachments[:event][:files] = nil builder = described_class.new(message_with_attachments) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) builder.perform expect(conversation.messages.count).to eql(messages_count) end @@ -139,7 +139,7 @@ describe Integrations::Slack::IncomingMessageBuilder do expect(hook).not_to be_nil messages_count = conversation.messages.count builder = described_class.new(message_with_attachments) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) builder.perform expect(conversation.messages.count).to eql(messages_count + 1) expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again') @@ -152,7 +152,7 @@ describe Integrations::Slack::IncomingMessageBuilder do message_with_attachments[:event][:text] = 'Attached File!' builder = described_class.new(message_with_attachments) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) builder.perform expect(conversation.messages.count).to eql(messages_count) @@ -165,13 +165,113 @@ describe Integrations::Slack::IncomingMessageBuilder do video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4' builder = described_class.new(video_attachment_params) - allow(builder).to receive(:sender).and_return(nil) + allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil]) expect { builder.perform }.not_to raise_error expect(conversation.messages.last.attachments).to be_any end end + context 'when resolving slack sender' do + let(:builder) { described_class.new(message_params) } + + before do + allow(builder).to receive(:slack_client).and_return(slack_client) + end + + context 'when slack user email matches a chatwoot agent' do + before do + create(:user, account: conversation.account, email: 'agent@example.com') + slack_response = { + user: { + profile: { email: 'agent@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' }, + real_name: 'Muhsin K', + name: 'muhsink' + } + } + allow(slack_client).to receive(:users_info) + .with(user: message_params[:event][:user]) + .and_return(slack_response) + end + + it 'sets the matched agent as message sender' do + builder.perform + expect(conversation.messages.last.sender).to eq(conversation.account.users.from_email('agent@example.com')) + end + + it 'does not store sender_name in additional_attributes' do + builder.perform + expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name') + end + end + + context 'when slack user email does not match any chatwoot agent' do + before do + slack_response = { + user: { + profile: { email: 'unknown@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' }, + real_name: 'Muhsin K', + name: 'muhsink' + } + } + allow(slack_client).to receive(:users_info) + .with(user: message_params[:event][:user]) + .and_return(slack_response) + end + + it 'saves sender_name from slack display_name in additional_attributes' do + builder.perform + expect(conversation.messages.last.sender).to be_nil + expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K') + end + + it 'saves sender_avatar_url from slack profile image in additional_attributes' do + builder.perform + expect(conversation.messages.last.additional_attributes['sender_avatar_url']) + .to eq('https://avatars.slack-edge.com/avatar.png') + end + + it 'falls back to real_name when display_name is blank' do + allow(slack_client).to receive(:users_info).and_return({ + user: { + profile: { email: 'unknown@example.com', display_name: '', + image_192: nil }, real_name: 'Muhsin K', name: 'muhsink' + } + }) + builder.perform + expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K') + end + + it 'falls back to slack username when display_name and real_name are both blank' do + allow(slack_client).to receive(:users_info).and_return({ + user: { + profile: { email: 'unknown@example.com', display_name: '', + image_192: nil }, real_name: '', name: 'muhsink' + } + }) + builder.perform + expect(conversation.messages.last.additional_attributes['sender_name']).to eq('muhsink') + end + end + + context 'when the slack API call raises an error' do + before do + allow(slack_client).to receive(:users_info).and_raise(StandardError, 'API error') + end + + it 'creates the message with nil sender' do + expect { builder.perform }.not_to raise_error + expect(conversation.messages.last.sender).to be_nil + end + + it 'does not store sender info in additional_attributes' do + builder.perform + expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name') + expect(conversation.messages.last.additional_attributes).not_to have_key('sender_avatar_url') + end + end + end + context 'when link shared' do let(:link_shared) do { From 7c60ad9e28950c71985194781d91db752f468ffe Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Feb 2026 16:16:33 +0530 Subject: [PATCH 06/90] feat: include contact verified status with each tool call (#13663) Co-authored-by: aakashb95 --- enterprise/app/models/concerns/toolable.rb | 6 ++ .../captain/assistant/agent_runner_service.rb | 21 ++++--- .../lib/captain/prompts/assistant.liquid | 2 +- .../lib/captain/prompts/scenario.liquid | 2 +- .../lib/captain/tools/http_tool_spec.rb | 58 ++++++++++++++++++- .../models/captain/custom_tool_spec.rb | 37 ++++++++++++ .../assistant/agent_runner_service_spec.rb | 9 +++ 7 files changed, 125 insertions(+), 10 deletions(-) diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index 51ec1be3e..f40ac4a65 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -71,6 +71,7 @@ module Concerns::Toolable add_base_headers(headers, state) add_conversation_headers(headers, state[:conversation]) if state[:conversation] add_contact_headers(headers, state[:contact]) if state[:contact] + add_contact_inbox_headers(headers, state[:contact_inbox]) end end @@ -91,6 +92,11 @@ module Concerns::Toolable headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present? end + def add_contact_inbox_headers(headers, contact_inbox) + headers['X-Chatwoot-Contact-Inbox-Id'] = contact_inbox[:id].to_s if contact_inbox&.[](:id) + headers['X-Chatwoot-Contact-Inbox-Verified'] = (contact_inbox&.[](:hmac_verified) || false).to_s + end + def format_response(raw_response_body) return raw_response_body if response_template.blank? diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 8238ca4d8..bdf35e98e 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService custom_attributes additional_attributes ].freeze + CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze + CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze def initialize(assistant:, conversation: nil, callbacks: {}) @@ -127,16 +129,21 @@ class Captain::Assistant::AgentRunnerService assistant_config: @assistant.config } - if @conversation - state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) - state[:channel_type] = @conversation.inbox&.channel_type - state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact - state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign - end - + build_conversation_state(state) if @conversation state end + def build_conversation_state(state) + state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) + state[:channel_type] = @conversation.inbox&.channel_type + state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact + state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign + return unless @conversation.contact_inbox + + state[:contact_inbox] = + @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES) + end + def build_and_wire_agents assistant_agent = @assistant.agent scenario_agents = @assistant.scenarios.enabled.map(&:agent) diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 1ba3ba7a7..aa94ae1d4 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc {% endif -%} {% if campaign.id -%} -{% render 'campaign' %} +{% render 'campaign', campaign: campaign %} {% endif -%} {% endif -%} diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 10eeb6fd7..6d0f11821 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc {% endif -%} {% if campaign.id -%} -{% render 'campaign' %} +{% render 'campaign', campaign: campaign %} {% endif -%} {% endif -%} diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index 967a10574..e05308a7d 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -249,6 +249,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do id: conversation.id, display_id: conversation.display_id }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + }, contact: { id: contact.id, email: contact.email, @@ -272,6 +276,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do 'X-Chatwoot-Tool-Slug' => custom_tool.slug, 'X-Chatwoot-Conversation-Id' => conversation.id.to_s, 'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s, + 'X-Chatwoot-Contact-Inbox-Id' => conversation.contact_inbox.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Id' => contact.id.to_s, 'X-Chatwoot-Contact-Email' => contact.email }) @@ -282,6 +288,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') .with(headers: { 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Email' => contact.email }) end @@ -296,6 +303,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do 'Content-Type' => 'application/json', 'X-Chatwoot-Account-Id' => account.id.to_s, 'X-Chatwoot-Tool-Slug' => custom_tool.slug, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Email' => contact.email } ) @@ -316,6 +324,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do .with(headers: { 'Authorization' => 'Bearer test_token', 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Id' => contact.id.to_s }) .to_return(status: 200, body: '{"success": true}') @@ -336,13 +345,18 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do conversation: { id: conversation.id, display_id: conversation.display_id + }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified } }) stub_request(:get, 'https://example.com/api/data') .with(headers: { 'X-Chatwoot-Account-Id' => account.id.to_s, - 'X-Chatwoot-Conversation-Id' => conversation.id.to_s + 'X-Chatwoot-Conversation-Id' => conversation.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s }) .to_return(status: 200, body: '{"success": true}') @@ -351,6 +365,32 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') end + it 'defaults contact inbox verified header to false when contact inbox is missing' do + tool_context_without_contact_inbox = Struct.new(:state).new({ + account_id: account.id, + assistant_id: assistant.id, + conversation: { + id: conversation.id, + display_id: conversation.display_id + }, + contact: { + id: contact.id, + email: contact.email + } + }) + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Contact-Inbox-Verified' => 'false' + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_without_contact_inbox) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' }) + end + it 'includes contact phone when present' do contact.update!(phone_number: '+1234567890') tool_context_with_state.state[:contact][:phone_number] = '+1234567890' @@ -366,6 +406,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') .with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' }) end + + it 'includes unverified contact inbox status explicitly as false' do + conversation.contact_inbox.update!(hmac_verified: false) + tool_context_with_state.state[:contact_inbox][:hmac_verified] = false + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Contact-Inbox-Verified' => 'false' + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_with_state) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' }) + end end end end diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index 0ead8fb1f..60b66778f 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -341,6 +341,10 @@ RSpec.describe Captain::CustomTool, type: :model do id: conversation.id, display_id: conversation.display_id }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + }, contact: { id: contact.id, email: contact.email, @@ -376,6 +380,13 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email) end + it 'includes contact inbox verification metadata when present' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Id']).to eq(conversation.contact_inbox.id.to_s) + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq(conversation.contact_inbox.hmac_verified.to_s) + end + it 'handles missing conversation gracefully' do state[:conversation] = nil @@ -396,11 +407,21 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s) end + it 'handles missing contact inbox gracefully' do + state[:contact_inbox] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Id']).to be_nil + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + end + it 'handles empty state' do headers = tool.build_metadata_headers({}) expect(headers).to be_a(Hash) expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool') + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') end it 'omits contact email header when email is blank' do @@ -418,6 +439,22 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers).not_to have_key('X-Chatwoot-Contact-Phone') end + + it 'includes contact inbox verified header when false' do + state[:contact_inbox][:hmac_verified] = false + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + end + + it 'defaults contact inbox verified header to false when value is nil' do + state[:contact_inbox][:hmac_verified] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + end end describe '#to_tool_metadata' do diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 0d22b8266..2ac3c6589 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -384,6 +384,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do expect(state[:channel_type]).to eq(inbox.channel_type) end + it 'includes contact inbox attributes when conversation is present' do + state = service.send(:build_state) + + expect(state[:contact_inbox]).to include( + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + ) + end + it 'includes contact attributes when contact is present' do state = service.send(:build_state) From c218eff5ecdcb0caffc831c2157f287853cd79df Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Feb 2026 17:26:12 +0530 Subject: [PATCH 07/90] feat: add per-webhook secret with backfill migration (#13573) --- .../i18n/locale/en/integrations.json | 8 ++ .../settings/integrations/Webhooks/Index.vue | 1 + .../integrations/Webhooks/NewWebHook.vue | 134 +++++++++++------- .../integrations/Webhooks/WebhookForm.vue | 39 +++++ .../dashboard/store/modules/webhooks.js | 1 + app/jobs/webhook_job.rb | 4 +- app/listeners/webhook_listener.rb | 7 +- app/models/webhook.rb | 3 + .../accounts/webhooks/_webhook.json.jbuilder | 1 + .../20260218075101_add_secret_to_webhooks.rb | 5 + ...20260226084618_backfill_webhook_secrets.rb | 11 ++ db/schema.rb | 3 +- .../app/models/enterprise/audit/webhook.rb | 2 +- lib/webhooks/trigger.rb | 30 +++- spec/jobs/agent_bots/webhook_job_spec.rb | 2 +- spec/jobs/webhook_job_spec.rb | 4 +- spec/lib/webhooks/trigger_spec.rb | 65 +++++++++ spec/listeners/webhook_listener_spec.rb | 61 ++++++-- spec/models/webhook_spec.rb | 16 +++ 19 files changed, 319 insertions(+), 78 deletions(-) create mode 100644 db/migrate/20260218075101_add_secret_to_webhooks.rb create mode 100644 db/migrate/20260226084618_backfill_webhook_secrets.rb diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 8ee990006..c8e488959 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -31,6 +31,14 @@ "WEBHOOK": { "SUBSCRIBED_EVENTS": "Subscribed Events", "LEARN_MORE": "Learn more about webhooks", + "SECRET": { + "LABEL": "Secret", + "COPY": "Copy secret to clipboard", + "COPY_SUCCESS": "Secret copied to clipboard", + "TOGGLE": "Toggle secret visibility", + "CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.", + "DONE": "Done" + }, "COUNT": "{n} webhook | {n} webhooks", "SEARCH_PLACEHOLDER": "Search webhooks...", "NO_RESULTS": "No webhooks found matching your search", diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue index 15db70c40..7213c735a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue @@ -58,6 +58,7 @@ export default { }, }, mounted() { + this.$store.dispatch('integrations/get', 'webhook'); this.$store.dispatch('webhooks/get'); }, methods: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue index 491a3cd87..76c4e895f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue @@ -1,60 +1,98 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 3f4b31299..3bcef1ca2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core'; import { required, url, minLength } from '@vuelidate/validators'; import wootConstants from 'dashboard/constants/globals'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; +import { copyTextToClipboard } from 'shared/helpers/clipboard'; +import { useAlert } from 'dashboard/composables'; import NextButton from 'dashboard/components-next/button/Button.vue'; const { EXAMPLE_WEBHOOK_URL } = wootConstants; @@ -57,10 +59,14 @@ export default { url: this.value.url || '', name: this.value.name || '', subscriptions: this.value.subscriptions || [], + secretVisible: false, supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS, }; }, computed: { + hasSecret() { + return !!this.value.secret; + }, webhookURLInputPlaceholder() { return this.$t( 'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER', @@ -81,6 +87,10 @@ export default { subscriptions: this.subscriptions, }); }, + async copySecret() { + await copyTextToClipboard(this.value.secret); + useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS')); + }, getI18nKey, }, }; @@ -111,6 +121,35 @@ export default { :placeholder="webhookNameInputPlaceholder" /> + diff --git a/app/javascript/dashboard/store/modules/webhooks.js b/app/javascript/dashboard/store/modules/webhooks.js index eb096468e..774c173d3 100644 --- a/app/javascript/dashboard/store/modules/webhooks.js +++ b/app/javascript/dashboard/store/modules/webhooks.js @@ -42,6 +42,7 @@ export const actions = { } = response.data; commit(types.default.ADD_WEBHOOK, webhook); commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); + return webhook; } catch (error) { commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); throw error; diff --git a/app/jobs/webhook_job.rb b/app/jobs/webhook_job.rb index 57d3739b7..54eac45e3 100644 --- a/app/jobs/webhook_job.rb +++ b/app/jobs/webhook_job.rb @@ -1,7 +1,7 @@ class WebhookJob < ApplicationJob queue_as :medium # There are 3 types of webhooks, account, inbox and agent_bot - def perform(url, payload, webhook_type = :account_webhook) - Webhooks::Trigger.execute(url, payload, webhook_type) + def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil) + Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id) end end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index 82a9fc711..762eaa6ee 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -111,7 +111,9 @@ class WebhookListener < BaseListener account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) - WebhookJob.perform_later(webhook.url, payload) + WebhookJob.perform_later(webhook.url, payload, :account_webhook, + secret: webhook.secret, + delivery_id: SecureRandom.uuid) end end @@ -119,7 +121,8 @@ class WebhookListener < BaseListener return unless inbox.channel_type == 'Channel::Api' return if inbox.channel.webhook_url.blank? - WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook) + WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook, + delivery_id: SecureRandom.uuid) end def deliver_webhook_payloads(payload, inbox) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 1d61c1614..6b36c4bbd 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -21,6 +21,9 @@ class Webhook < ApplicationRecord belongs_to :account belongs_to :inbox, optional: true + has_secure_token :secret + encrypts :secret if Chatwoot.encryption_configured? + validates :account_id, presence: true validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) validate :validate_webhook_subscriptions diff --git a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder index 5406cf183..7b1943c5d 100644 --- a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder +++ b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder @@ -3,6 +3,7 @@ json.name webhook.name json.url webhook.url json.account_id webhook.account_id json.subscriptions webhook.subscriptions +json.secret webhook.secret if webhook.inbox json.inbox do json.id webhook.inbox.id diff --git a/db/migrate/20260218075101_add_secret_to_webhooks.rb b/db/migrate/20260218075101_add_secret_to_webhooks.rb new file mode 100644 index 000000000..ff6c40c4c --- /dev/null +++ b/db/migrate/20260218075101_add_secret_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddSecretToWebhooks < ActiveRecord::Migration[7.1] + def change + add_column :webhooks, :secret, :string + end +end diff --git a/db/migrate/20260226084618_backfill_webhook_secrets.rb b/db/migrate/20260226084618_backfill_webhook_secrets.rb new file mode 100644 index 000000000..aec6cfde7 --- /dev/null +++ b/db/migrate/20260226084618_backfill_webhook_secrets.rb @@ -0,0 +1,11 @@ +class BackfillWebhookSecrets < ActiveRecord::Migration[7.1] + def up + Webhook.find_each do |webhook| + webhook.update!(secret: SecureRandom.urlsafe_base64(24)) + end + end + + def down + # no-op: removing the column in the previous migration handles cleanup + end +end diff --git a/db/schema.rb b/db/schema.rb index fd4d18cb1..8a450e734 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_01_30_061021) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1250,6 +1250,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do t.integer "webhook_type", default: 0 t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"] t.string "name" + t.string "secret" t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true end diff --git a/enterprise/app/models/enterprise/audit/webhook.rb b/enterprise/app/models/enterprise/audit/webhook.rb index 34e0bcc1b..303141ce2 100644 --- a/enterprise/app/models/enterprise/audit/webhook.rb +++ b/enterprise/app/models/enterprise/audit/webhook.rb @@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook extend ActiveSupport::Concern included do - audited associated_with: :account + audited associated_with: :account, except: [:secret] end end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index ef3410b78..456e186ca 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -1,14 +1,16 @@ class Webhooks::Trigger SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze - def initialize(url, payload, webhook_type) + def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil) @url = url @payload = payload @webhook_type = webhook_type + @secret = secret + @delivery_id = delivery_id end - def self.execute(url, payload, webhook_type) - new(url, payload, webhook_type).execute + def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil) + new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute end def execute @@ -21,15 +23,27 @@ class Webhooks::Trigger private def perform_request + body = @payload.to_json RestClient::Request.execute( method: :post, url: @url, - payload: @payload.to_json, - headers: { content_type: :json, accept: :json }, + payload: body, + headers: request_headers(body), timeout: webhook_timeout ) end + def request_headers(body) + headers = { content_type: :json, accept: :json } + headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present? + if @secret.present? + ts = Time.now.to_i.to_s + headers['X-Chatwoot-Timestamp'] = ts + headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}" + end + headers + end + def handle_error(error) return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless message @@ -72,7 +86,11 @@ class Webhooks::Trigger def message return if message_id.blank? - @message ||= Message.find_by(id: message_id) + if defined?(@message) + @message + else + @message = Message.find_by(id: message_id) + end end def message_id diff --git a/spec/jobs/agent_bots/webhook_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb index a8117d84e..346d85e83 100644 --- a/spec/jobs/agent_bots/webhook_job_spec.rb +++ b/spec/jobs/agent_bots/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do end it 'executes perform' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb index 81802a3c0..c74c1d8a8 100644 --- a/spec/jobs/webhook_job_spec.rb +++ b/spec/jobs/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe WebhookJob do end it 'executes perform with default webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end @@ -24,7 +24,7 @@ RSpec.describe WebhookJob do let(:webhook_type) { :api_inbox_webhook } it 'executes perform with inbox webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 79cf92150..1e047b557 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -168,6 +168,71 @@ describe Webhooks::Trigger do end end + describe 'request headers' do + let(:payload) { { event: 'message_created' } } + let(:body) { payload.to_json } + + context 'without secret or delivery_id' do + it 'sends only content-type and accept headers' do + expect(RestClient::Request).to receive(:execute).with( + hash_including(headers: { content_type: :json, accept: :json }) + ) + trigger.execute(url, payload, webhook_type) + end + end + + context 'with delivery_id' do + it 'adds X-Chatwoot-Delivery header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid') + expect(args[:headers]).not_to have_key('X-Chatwoot-Signature') + expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp') + end + trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid') + end + end + + context 'with secret' do + let(:secret) { 'test-secret' } + + it 'adds X-Chatwoot-Timestamp header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'adds X-Chatwoot-Signature header with correct HMAC' do + expect(RestClient::Request).to receive(:execute) do |args| + ts = args[:headers]['X-Chatwoot-Timestamp'] + expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}" + expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'signs timestamp.body not just body' do + expect(RestClient::Request).to receive(:execute) do |args| + args[:headers]['X-Chatwoot-Timestamp'] + wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + end + + context 'with both secret and delivery_id' do + it 'includes all three security headers' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123') + expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present + expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=') + end + trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123') + end + end + end + it 'does not update message status if webhook fails for other events' do payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id } diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index 5062b11bc..51dae239b 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -28,7 +28,10 @@ describe WebhookListener do context 'when webhook is configured and event is subscribed' do it 'triggers the webhook event' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.message_created(message_created_event) end end @@ -54,8 +57,10 @@ describe WebhookListener do conversation: api_conversation ) api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.message_created(api_event) end @@ -90,7 +95,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_created(conversation_created_event) end end @@ -101,9 +109,11 @@ describe WebhookListener do api_inbox = channel_api.inbox api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user) api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, - api_conversation.webhook_data.merge(event: 'conversation_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, + api_conversation.webhook_data.merge(event: 'conversation_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.conversation_created(api_event) end @@ -156,7 +166,9 @@ describe WebhookListener do } } ] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.conversation_updated(conversation_updated_event) @@ -177,7 +189,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.contact_created(contact_event) end end @@ -213,7 +228,9 @@ describe WebhookListener do contact.webhook_data.merge( event: 'contact_updated', changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.contact_updated(contact_updated_event) end @@ -235,7 +252,10 @@ describe WebhookListener do it 'triggers webhook' do inbox_data = Inbox::EventDataPresenter.new(inbox).push_data webhook = create(:webhook, account: account, subscriptions: ['inbox_created']) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.inbox_created(inbox_created_event) end end @@ -272,7 +292,9 @@ describe WebhookListener do expect(WebhookJob).to receive(:perform_later).with( webhook.url, - inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data) + inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.inbox_updated(inbox_updated_event) @@ -302,7 +324,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_on(typing_event) end end @@ -321,7 +346,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, payload, :api_inbox_webhook, + delivery_id: instance_of(String) + ).once listener.conversation_typing_on(api_event) end end @@ -349,7 +377,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_off(typing_event) end end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 81e6d9551..b8570de59 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -8,4 +8,20 @@ RSpec.describe Webhook do describe 'associations' do it { is_expected.to belong_to(:account) } end + + describe 'secret token' do + let!(:account) { create(:account) } + + it 'auto-generates a secret on create' do + webhook = create(:webhook, account: account) + expect(webhook.secret).to be_present + end + + it 'does not regenerate the secret on update' do + webhook = create(:webhook, account: account) + original_secret = webhook.secret + webhook.update!(url: "#{webhook.url}?updated=1") + expect(webhook.reload.secret).to eq(original_secret) + end + end end From 9ca03c1af31e4b059d1a97324db1a68f3f7a81eb Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:01:13 +0530 Subject: [PATCH 08/90] chore: make all the deprecated feature flag reclaimable (#13646) ## Docs https://www.notion.so/chatwoot/Redeeming-a-depreciated-feature-flag-313a5f274c9280f381cdd811eab42019?source=copy_link ## Description Marks 8 unused feature flags as deprecated: true in features.yml, freeing their bit slots for future reuse. Removes dead code references from JS constants, help URLs, and enterprise billing config. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Simulated the "claim a slot" workflow ## 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 --- app/javascript/dashboard/featureFlags.js | 3 --- app/javascript/dashboard/helper/featureHelper.js | 1 - config/features.yml | 4 +--- .../enterprise/billing/handle_stripe_event_service.rb | 1 - enterprise/config/premium_features.yml | 1 - 5 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 353bed96e..858c0ecbc 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -21,10 +21,8 @@ export const FEATURE_FLAGS = { AUDIT_LOGS: 'audit_logs', INBOX_VIEW: 'inbox_view', SLA: 'sla', - RESPONSE_BOT: 'response_bot', CHANNEL_EMAIL: 'channel_email', CHANNEL_FACEBOOK: 'channel_facebook', - CHANNEL_TWITTER: 'channel_twitter', CHANNEL_WEBSITE: 'channel_website', CUSTOM_REPLY_DOMAIN: 'custom_reply_domain', CUSTOM_REPLY_EMAIL: 'custom_reply_email', @@ -36,7 +34,6 @@ export const FEATURE_FLAGS = { CAPTAIN: 'captain_integration', CUSTOM_ROLES: 'custom_roles', CHATWOOT_V4: 'chatwoot_v4', - REPORT_V4: 'report_v4', CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_TIKTOK: 'channel_tiktok', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index ae805ccf1..c90ec15db 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = { integrations: 'https://chwt.app/hc/integrations', labels: 'https://chwt.app/hc/labels', macros: 'https://chwt.app/hc/macros', - message_reply_to: 'https://chwt.app/hc/reply-to', reports: 'https://chwt.app/hc/reports', sla: 'https://chwt.app/hc/sla', team_management: 'https://chwt.app/hc/teams', diff --git a/config/features.yml b/config/features.yml index d8378d61c..65b3c6194 100644 --- a/config/features.yml +++ b/config/features.yml @@ -108,12 +108,10 @@ - name: response_bot display_name: Response Bot enabled: false - premium: true deprecated: true - name: message_reply_to display_name: Message Reply To enabled: false - help_url: https://chwt.app/hc/reply-to deprecated: true - name: insert_article_in_reply display_name: Insert Article in Reply @@ -149,7 +147,7 @@ enabled: true - name: report_v4 display_name: Report V4 - enabled: true + enabled: false deprecated: true - name: contact_chatwoot_support_team display_name: Contact Chatwoot Support Team diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 52a28844f..d3c5b15db 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -11,7 +11,6 @@ class Enterprise::Billing::HandleStripeEventService help_center campaigns team_management - channel_twitter channel_facebook channel_email channel_instagram diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml index 64275503d..0cb89df01 100644 --- a/enterprise/config/premium_features.yml +++ b/enterprise/config/premium_features.yml @@ -1,7 +1,6 @@ # List of the premium features in EE edition - disable_branding - audit_logs -- response_bot - sla - custom_roles - captain_integration From 7acd239c7050887fc9b0c5dd562e545a96080abc Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:01:23 +0530 Subject: [PATCH 09/90] fix: call authorization_error! on IMAP auth failures (#13560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Notion document https://www.notion.so/chatwoot/Email-IMAP-Issue-30aa5f274c928062aa6bddc2e5877a63?showMoveTo=true&saveParent=true ## Description PLAIN IMAP channels (non-OAuth) were silently retrying failed authentication every minute, forever. When credentials are wrong/expired, Net::IMAP::NoResponseError was caught and logged but channel.authorization_error! was never called — so the Redis error counter never incremented, reauthorization_required? was never set, and admins were never notified. OAuth channels already had this handled correctly via the Reauthorizable concern. Additionally, Net::IMAP::ResponseParseError (raised by non-RFC-compliant IMAP servers) was falling through to the StandardError catch-all, flooding Estimated impact before fix: ~70–75 broken IMAP inboxes generating ~700k–750k wasted Sidekiq jobs/week. ## 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 --- .../inboxes/fetch_imap_email_inboxes_job.rb | 1 + app/jobs/inboxes/fetch_imap_emails_job.rb | 29 ++++---- app/models/channel/email.rb | 4 ++ app/models/concerns/backoffable.rb | 70 +++++++++++++++++++ app/models/concerns/reauthorizable.rb | 3 + app/services/imap/authentication_error.rb | 1 + app/services/imap/base_fetch_email_service.rb | 6 +- config/installation_config.yml | 13 ++++ lib/exception_list.rb | 6 ++ lib/redis/redis_keys.rb | 4 ++ .../inboxes/fetch_imap_emails_job_spec.rb | 50 +++++++++++++ spec/models/channel/email_spec.rb | 2 + spec/models/concerns/backoffable_shared.rb | 43 ++++++++++++ 13 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 app/models/concerns/backoffable.rb create mode 100644 app/services/imap/authentication_error.rb create mode 100644 spec/models/concerns/backoffable_shared.rb diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index ea2705955..ea7f7664f 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -15,6 +15,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob return false if inbox.account.suspended? return false unless inbox.channel.imap_enabled return false if inbox.channel.reauthorization_required? + return false if inbox.channel.in_backoff? return true unless ChatwootApp.chatwoot_cloud? return false if default_plan?(inbox.account) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index ec5717f3b..74368c830 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -6,26 +6,29 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob def perform(channel, interval = 1) return unless should_fetch_email?(channel) - key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) - - with_lock(key, 5.minutes) do - process_email_for_channel(channel, interval) - end - rescue *ExceptionList::IMAP_EXCEPTIONS => e - Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" - rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError, - Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e - Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" - rescue LockAcquisitionError - Rails.logger.error "Lock failed for #{channel.inbox.id}" + fetch_emails_with_backoff(channel, interval) rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end private + def fetch_emails_with_backoff(channel, interval) + key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) + with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) } + channel.clear_backoff! + rescue Imap::AuthenticationError => e + Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}" + channel.authorization_error! + rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e + Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}" + channel.apply_backoff! + rescue LockAcquisitionError + Rails.logger.error "Lock failed for #{channel.inbox.id}" + end + def should_fetch_email?(channel) - channel.imap_enabled? && !channel.reauthorization_required? + channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff? end def process_email_for_channel(channel, interval) diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index b1124dd75..1091b17d8 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -72,6 +72,10 @@ class Channel::Email < ApplicationRecord imap_enabled && imap_address == 'imap.gmail.com' end + def backoff_log_identifier + "Error for email channel - #{inbox.id}" + end + private def ensure_forward_to_email diff --git a/app/models/concerns/backoffable.rb b/app/models/concerns/backoffable.rb new file mode 100644 index 000000000..60b6ac98c --- /dev/null +++ b/app/models/concerns/backoffable.rb @@ -0,0 +1,70 @@ +# Backoffable provides transient-error retry backoff for models that depend on external services. +# +# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!. +# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that +# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!. +# +# Call clear_backoff! after a successful operation to reset the counter. + +module Backoffable + extend ActiveSupport::Concern + + def backoff_log_identifier + inbox_id = respond_to?(:inbox) && inbox&.id + inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}" + end + + def backoff_retry_count + ::Redis::Alfred.get(backoff_retry_count_key).to_i + end + + def in_backoff? + val = ::Redis::Alfred.get(backoff_retry_after_key) + val.present? && Time.zone.at(val.to_f) > Time.current + end + + def apply_backoff! + new_count = backoff_retry_count + 1 + max_interval, max_retries = backoff_limits + + if new_count > max_retries + exhaust_backoff(new_count) + else + schedule_backoff_retry(new_count, max_interval, max_retries) + end + end + + def clear_backoff! + ::Redis::Alfred.delete(backoff_retry_count_key) + ::Redis::Alfred.delete(backoff_retry_after_key) + end + + private + + def backoff_limits + max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i + max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i + [max_interval, (max_interval - 1) + max_count] + end + + def exhaust_backoff(new_count) + Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization" + clear_backoff! + prompt_reauthorization! + end + + def schedule_backoff_retry(new_count, max_interval, max_retries) + wait_minutes = [new_count, max_interval].min + ::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours) + ::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours) + Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m" + end + + def backoff_retry_count_key + format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id) + end + + def backoff_retry_after_key + format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id) + end +end diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index 7a09f6436..b25f5525e 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -13,6 +13,8 @@ module Reauthorizable extend ActiveSupport::Concern + include Backoffable + AUTHORIZATION_ERROR_THRESHOLD = 2 # model attribute @@ -65,6 +67,7 @@ module Reauthorizable def reauthorized! ::Redis::Alfred.delete(authorization_error_count_key) ::Redis::Alfred.delete(reauthorization_required_key) + clear_backoff! invalidate_inbox_cache unless instance_of?(::AutomationRule) end diff --git a/app/services/imap/authentication_error.rb b/app/services/imap/authentication_error.rb new file mode 100644 index 000000000..5698f5b3b --- /dev/null +++ b/app/services/imap/authentication_error.rb @@ -0,0 +1 @@ +class Imap::AuthenticationError < StandardError; end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 09332092c..49e06491b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -107,7 +107,11 @@ class Imap::BaseFetchEmailService def build_imap_client imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true) - imap.authenticate(authentication_type, channel.imap_login, imap_password) + begin + imap.authenticate(authentication_type, channel.imap_login, imap_password) + rescue Net::IMAP::NoResponseError => e + raise Imap::AuthenticationError, e.message + end imap.select('INBOX') imap end diff --git a/config/installation_config.yml b/config/installation_config.yml index 34cb736bf..0d22de6fc 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -96,6 +96,19 @@ locked: false # ------- End of Account Related Config ------- # +# ------- Transient Error Backoff Config ------- # +- name: BACKOFF_MAX_INTERVAL_MINUTES + display_title: 'Backoff Max Interval (minutes)' + description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus' + value: 5 + locked: false +- name: BACKOFF_MAX_INTERVAL_COUNT + display_title: 'Backoff Max Retry Count' + description: 'Number of additional retries at the maximum interval before prompting reauthorization' + value: 10 + locked: false +# ------- End of Transient Error Backoff Config ------- # + # ------- Email Related Config ------- # - name: MAILER_INBOUND_EMAIL_DOMAIN display_title: 'Inbound Email Domain' diff --git a/lib/exception_list.rb b/lib/exception_list.rb index 2fee0a170..dd468f07b 100644 --- a/lib/exception_list.rb +++ b/lib/exception_list.rb @@ -16,4 +16,10 @@ module ExceptionList Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError, SocketError ].freeze + + IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [ + EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, + Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError, + Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError + ]).freeze end diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 8c9361ab5..fa9eeb31e 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -52,4 +52,8 @@ module Redis::RedisKeys ## Account Email Rate Limiting ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze + + ## Transient Error Backoff + BACKOFF_RETRY_COUNT = 'BACKOFF:%s:%d:retry_count'.freeze + BACKOFF_RETRY_AFTER = 'BACKOFF:%s:%d:retry_after'.freeze end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index da4b95b15..1abd7450b 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -38,6 +38,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end + context 'when channel is in backoff' do + it 'does not fetch emails' do + allow(imap_email_channel).to receive(:in_backoff?).and_return(true) + expect(Imap::FetchEmailService).not_to receive(:new) + described_class.perform_now(imap_email_channel) + end + end + context 'when the channel is regular imap' do it 'calls the imap fetch service' do fetch_service = double @@ -56,6 +64,17 @@ RSpec.describe Inboxes::FetchImapEmailsJob do described_class.perform_now(imap_email_channel, 4) expect(fetch_service).to have_received(:perform) end + + it 'clears backoff after successful fetch' do + fetch_service = double + allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service) + allow(fetch_service).to receive(:perform).and_return([]) + allow(imap_email_channel).to receive(:clear_backoff!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:clear_backoff!) + end end context 'when the channel is Microsoft' do @@ -69,6 +88,37 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end + context 'when authentication error is raised' do + it 'calls authorization_error! on the channel' do + allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError) + allow(imap_email_channel).to receive(:authorization_error!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:authorization_error!) + end + end + + context 'when a transient IMAP error is raised' do + it 'calls apply_backoff! on the channel' do + allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError) + allow(imap_email_channel).to receive(:apply_backoff!) + + described_class.perform_now(imap_email_channel) + + expect(imap_email_channel).to have_received(:apply_backoff!) + end + end + + context 'when lock acquisition fails' do + it 'does not raise an error' do + lock_manager = instance_double(Redis::LockManager, lock: false) + allow(Redis::LockManager).to receive(:new).and_return(lock_manager) + + expect { described_class.perform_now(imap_email_channel) }.not_to raise_error + end + end + context 'when IMAP OAuth errors out' do it 'marks the connection as requiring authorization' do error_response = double diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index 939e8e10a..bdb9f859b 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -2,12 +2,14 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' +require Rails.root.join 'spec/models/concerns/backoffable_shared.rb' RSpec.describe Channel::Email do let(:channel) { create(:channel_email) } describe 'concerns' do it_behaves_like 'reauthorizable' + it_behaves_like 'backoffable' context 'when prompt_reauthorization!' do it 'calls channel notifier mail for email' do diff --git a/spec/models/concerns/backoffable_shared.rb b/spec/models/concerns/backoffable_shared.rb new file mode 100644 index 000000000..385f93be7 --- /dev/null +++ b/spec/models/concerns/backoffable_shared.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +shared_examples_for 'backoffable' do + let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) } + + before do + allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2) + allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3) + # max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff! + end + + it 'starts with no backoff' do + expect(obj.in_backoff?).to be false + expect(obj.backoff_retry_count).to eq 0 + end + + it 'ramps backoff on each failure' do + obj.apply_backoff! + expect(obj.backoff_retry_count).to eq 1 + expect(obj.in_backoff?).to be true + end + + it 'caps wait time at max interval' do + 4.times { obj.apply_backoff! } + expect(obj.backoff_retry_count).to eq 4 + expect(obj.in_backoff?).to be true + end + + it 'exhausts backoff and calls prompt_reauthorization! after max retries' do + allow(obj).to receive(:prompt_reauthorization!) + 5.times { obj.apply_backoff! } + expect(obj).to have_received(:prompt_reauthorization!) + expect(obj.backoff_retry_count).to eq 0 + expect(obj.in_backoff?).to be false + end + + it 'clear_backoff! resets retry count and backoff window' do + obj.apply_backoff! + obj.clear_backoff! + expect(obj.in_backoff?).to be false + expect(obj.backoff_retry_count).to eq 0 + end +end From bdcc62f1b004f26ddfa76d73b0d095270e44a043 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 26 Feb 2026 19:05:15 +0400 Subject: [PATCH 10/90] feat(facebook): Mark Messenger native-app echoes as external echo message (#13665) When agents send replies from the native Facebook Messenger app (not Chatwoot), echo events were created without external_echo metadata and could be misrepresented in the UI. This change updates Messenger echo message creation to: - set content_attributes.external_echo = true for outgoing_echo messages - set echo message status to delivered - keep sender as nil for echo messages (existing behavior) CleanShot 2026-02-26 at 16 32
04@2x --- .../messages/facebook/message_builder.rb | 10 +++++-- .../messages/facebook/message_builder_spec.rb | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 2c55922f6..1f59deadb 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder end def message_params + content_attributes = { + in_reply_to_external_id: response.in_reply_to_external_id + } + content_attributes[:external_echo] = true if @outgoing_echo + { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: @message_type, + status: @outgoing_echo ? :delivered : :sent, content: response.content, source_id: response.identifier, - content_attributes: { - in_reply_to_external_id: response.in_reply_to_external_id - }, + content_attributes: content_attributes, sender: @outgoing_echo ? nil : @contact_inbox.contact } end diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index 4b94c9be4..525ad7736 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -59,6 +59,36 @@ describe Messages::Facebook::MessageBuilder do expect(contact.name).to eq(default_name) end + it 'marks echo messages as external echo messages' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + first_name: 'Jane', + last_name: 'Dae', + account_id: facebook_channel.inbox.account_id, + profile_pic: 'https://chatwoot-assets.local/sample.png' + }.with_indifferent_access + ) + + echo_message_object = { + messaging: { + sender: { id: facebook_channel.page_id }, + recipient: { id: '3383290475046708' }, + message: { mid: 'm_echo_1', text: 'Echo testing', is_echo: true, app_id: '263902037430900' } + } + }.to_json + echo_message = Integrations::Facebook::MessageParser.new(echo_message_object) + + described_class.new(echo_message, facebook_channel.inbox, outgoing_echo: true).perform + + message = facebook_channel.inbox.messages.find_by(source_id: 'm_echo_1') + expect(message).to be_present + expect(message.message_type).to eq('outgoing') + expect(message.sender).to be_nil + expect(message.status).to eq('delivered') + expect(message.content_attributes['external_echo']).to be true + end + context 'when lock to single conversation' do subject(:mocked_message_builder) do described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform From d84ae196d57611ff95bc6f449bbc31496ff05046 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 26 Feb 2026 18:45:18 -0800 Subject: [PATCH 11/90] fix: call authorization_error! on IMAP auth failures (#13560) (revert) (#13671) This reverts commit 7acd239c7050887fc9b0c5dd562e545a96080abc to further debug upstream issues. --- .../inboxes/fetch_imap_email_inboxes_job.rb | 1 - app/jobs/inboxes/fetch_imap_emails_job.rb | 29 ++++---- app/models/channel/email.rb | 4 -- app/models/concerns/backoffable.rb | 70 ------------------- app/models/concerns/reauthorizable.rb | 3 - app/services/imap/authentication_error.rb | 1 - app/services/imap/base_fetch_email_service.rb | 6 +- config/installation_config.yml | 13 ---- lib/exception_list.rb | 6 -- lib/redis/redis_keys.rb | 4 -- .../inboxes/fetch_imap_emails_job_spec.rb | 50 ------------- spec/models/channel/email_spec.rb | 2 - spec/models/concerns/backoffable_shared.rb | 43 ------------ 13 files changed, 14 insertions(+), 218 deletions(-) delete mode 100644 app/models/concerns/backoffable.rb delete mode 100644 app/services/imap/authentication_error.rb delete mode 100644 spec/models/concerns/backoffable_shared.rb diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index ea7f7664f..ea2705955 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -15,7 +15,6 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob return false if inbox.account.suspended? return false unless inbox.channel.imap_enabled return false if inbox.channel.reauthorization_required? - return false if inbox.channel.in_backoff? return true unless ChatwootApp.chatwoot_cloud? return false if default_plan?(inbox.account) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index 74368c830..ec5717f3b 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -6,29 +6,26 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob def perform(channel, interval = 1) return unless should_fetch_email?(channel) - fetch_emails_with_backoff(channel, interval) + key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) + + with_lock(key, 5.minutes) do + process_email_for_channel(channel, interval) + end + rescue *ExceptionList::IMAP_EXCEPTIONS => e + Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}" + rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError, + Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e + Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}" + rescue LockAcquisitionError + Rails.logger.error "Lock failed for #{channel.inbox.id}" rescue StandardError => e ChatwootExceptionTracker.new(e, account: channel.account).capture_exception end private - def fetch_emails_with_backoff(channel, interval) - key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id) - with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) } - channel.clear_backoff! - rescue Imap::AuthenticationError => e - Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}" - channel.authorization_error! - rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e - Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}" - channel.apply_backoff! - rescue LockAcquisitionError - Rails.logger.error "Lock failed for #{channel.inbox.id}" - end - def should_fetch_email?(channel) - channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff? + channel.imap_enabled? && !channel.reauthorization_required? end def process_email_for_channel(channel, interval) diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index 1091b17d8..b1124dd75 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -72,10 +72,6 @@ class Channel::Email < ApplicationRecord imap_enabled && imap_address == 'imap.gmail.com' end - def backoff_log_identifier - "Error for email channel - #{inbox.id}" - end - private def ensure_forward_to_email diff --git a/app/models/concerns/backoffable.rb b/app/models/concerns/backoffable.rb deleted file mode 100644 index 60b6ac98c..000000000 --- a/app/models/concerns/backoffable.rb +++ /dev/null @@ -1,70 +0,0 @@ -# Backoffable provides transient-error retry backoff for models that depend on external services. -# -# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!. -# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that -# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!. -# -# Call clear_backoff! after a successful operation to reset the counter. - -module Backoffable - extend ActiveSupport::Concern - - def backoff_log_identifier - inbox_id = respond_to?(:inbox) && inbox&.id - inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}" - end - - def backoff_retry_count - ::Redis::Alfred.get(backoff_retry_count_key).to_i - end - - def in_backoff? - val = ::Redis::Alfred.get(backoff_retry_after_key) - val.present? && Time.zone.at(val.to_f) > Time.current - end - - def apply_backoff! - new_count = backoff_retry_count + 1 - max_interval, max_retries = backoff_limits - - if new_count > max_retries - exhaust_backoff(new_count) - else - schedule_backoff_retry(new_count, max_interval, max_retries) - end - end - - def clear_backoff! - ::Redis::Alfred.delete(backoff_retry_count_key) - ::Redis::Alfred.delete(backoff_retry_after_key) - end - - private - - def backoff_limits - max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i - max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i - [max_interval, (max_interval - 1) + max_count] - end - - def exhaust_backoff(new_count) - Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization" - clear_backoff! - prompt_reauthorization! - end - - def schedule_backoff_retry(new_count, max_interval, max_retries) - wait_minutes = [new_count, max_interval].min - ::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours) - ::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours) - Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m" - end - - def backoff_retry_count_key - format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id) - end - - def backoff_retry_after_key - format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id) - end -end diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index b25f5525e..7a09f6436 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -13,8 +13,6 @@ module Reauthorizable extend ActiveSupport::Concern - include Backoffable - AUTHORIZATION_ERROR_THRESHOLD = 2 # model attribute @@ -67,7 +65,6 @@ module Reauthorizable def reauthorized! ::Redis::Alfred.delete(authorization_error_count_key) ::Redis::Alfred.delete(reauthorization_required_key) - clear_backoff! invalidate_inbox_cache unless instance_of?(::AutomationRule) end diff --git a/app/services/imap/authentication_error.rb b/app/services/imap/authentication_error.rb deleted file mode 100644 index 5698f5b3b..000000000 --- a/app/services/imap/authentication_error.rb +++ /dev/null @@ -1 +0,0 @@ -class Imap::AuthenticationError < StandardError; end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 49e06491b..09332092c 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -107,11 +107,7 @@ class Imap::BaseFetchEmailService def build_imap_client imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true) - begin - imap.authenticate(authentication_type, channel.imap_login, imap_password) - rescue Net::IMAP::NoResponseError => e - raise Imap::AuthenticationError, e.message - end + imap.authenticate(authentication_type, channel.imap_login, imap_password) imap.select('INBOX') imap end diff --git a/config/installation_config.yml b/config/installation_config.yml index 0d22de6fc..34cb736bf 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -96,19 +96,6 @@ locked: false # ------- End of Account Related Config ------- # -# ------- Transient Error Backoff Config ------- # -- name: BACKOFF_MAX_INTERVAL_MINUTES - display_title: 'Backoff Max Interval (minutes)' - description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus' - value: 5 - locked: false -- name: BACKOFF_MAX_INTERVAL_COUNT - display_title: 'Backoff Max Retry Count' - description: 'Number of additional retries at the maximum interval before prompting reauthorization' - value: 10 - locked: false -# ------- End of Transient Error Backoff Config ------- # - # ------- Email Related Config ------- # - name: MAILER_INBOUND_EMAIL_DOMAIN display_title: 'Inbound Email Domain' diff --git a/lib/exception_list.rb b/lib/exception_list.rb index dd468f07b..2fee0a170 100644 --- a/lib/exception_list.rb +++ b/lib/exception_list.rb @@ -16,10 +16,4 @@ module ExceptionList Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError, SocketError ].freeze - - IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [ - EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, - Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError, - Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError - ]).freeze end diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index fa9eeb31e..8c9361ab5 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -52,8 +52,4 @@ module Redis::RedisKeys ## Account Email Rate Limiting ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze - - ## Transient Error Backoff - BACKOFF_RETRY_COUNT = 'BACKOFF:%s:%d:retry_count'.freeze - BACKOFF_RETRY_AFTER = 'BACKOFF:%s:%d:retry_after'.freeze end diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index 1abd7450b..da4b95b15 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -38,14 +38,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end - context 'when channel is in backoff' do - it 'does not fetch emails' do - allow(imap_email_channel).to receive(:in_backoff?).and_return(true) - expect(Imap::FetchEmailService).not_to receive(:new) - described_class.perform_now(imap_email_channel) - end - end - context 'when the channel is regular imap' do it 'calls the imap fetch service' do fetch_service = double @@ -64,17 +56,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do described_class.perform_now(imap_email_channel, 4) expect(fetch_service).to have_received(:perform) end - - it 'clears backoff after successful fetch' do - fetch_service = double - allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service) - allow(fetch_service).to receive(:perform).and_return([]) - allow(imap_email_channel).to receive(:clear_backoff!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:clear_backoff!) - end end context 'when the channel is Microsoft' do @@ -88,37 +69,6 @@ RSpec.describe Inboxes::FetchImapEmailsJob do end end - context 'when authentication error is raised' do - it 'calls authorization_error! on the channel' do - allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError) - allow(imap_email_channel).to receive(:authorization_error!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:authorization_error!) - end - end - - context 'when a transient IMAP error is raised' do - it 'calls apply_backoff! on the channel' do - allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError) - allow(imap_email_channel).to receive(:apply_backoff!) - - described_class.perform_now(imap_email_channel) - - expect(imap_email_channel).to have_received(:apply_backoff!) - end - end - - context 'when lock acquisition fails' do - it 'does not raise an error' do - lock_manager = instance_double(Redis::LockManager, lock: false) - allow(Redis::LockManager).to receive(:new).and_return(lock_manager) - - expect { described_class.perform_now(imap_email_channel) }.not_to raise_error - end - end - context 'when IMAP OAuth errors out' do it 'marks the connection as requiring authorization' do error_response = double diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index bdb9f859b..939e8e10a 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -2,14 +2,12 @@ require 'rails_helper' require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb' -require Rails.root.join 'spec/models/concerns/backoffable_shared.rb' RSpec.describe Channel::Email do let(:channel) { create(:channel_email) } describe 'concerns' do it_behaves_like 'reauthorizable' - it_behaves_like 'backoffable' context 'when prompt_reauthorization!' do it 'calls channel notifier mail for email' do diff --git a/spec/models/concerns/backoffable_shared.rb b/spec/models/concerns/backoffable_shared.rb deleted file mode 100644 index 385f93be7..000000000 --- a/spec/models/concerns/backoffable_shared.rb +++ /dev/null @@ -1,43 +0,0 @@ -require 'rails_helper' - -shared_examples_for 'backoffable' do - let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) } - - before do - allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2) - allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3) - # max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff! - end - - it 'starts with no backoff' do - expect(obj.in_backoff?).to be false - expect(obj.backoff_retry_count).to eq 0 - end - - it 'ramps backoff on each failure' do - obj.apply_backoff! - expect(obj.backoff_retry_count).to eq 1 - expect(obj.in_backoff?).to be true - end - - it 'caps wait time at max interval' do - 4.times { obj.apply_backoff! } - expect(obj.backoff_retry_count).to eq 4 - expect(obj.in_backoff?).to be true - end - - it 'exhausts backoff and calls prompt_reauthorization! after max retries' do - allow(obj).to receive(:prompt_reauthorization!) - 5.times { obj.apply_backoff! } - expect(obj).to have_received(:prompt_reauthorization!) - expect(obj.backoff_retry_count).to eq 0 - expect(obj.in_backoff?).to be false - end - - it 'clear_backoff! resets retry count and backoff window' do - obj.apply_backoff! - obj.clear_backoff! - expect(obj.in_backoff?).to be false - expect(obj.backoff_retry_count).to eq 0 - end -end From df92fd12cbe7777b5ab687cb59e52840307eab89 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 27 Feb 2026 15:31:49 +0530 Subject: [PATCH 12/90] fix: bot handoff should set waiting time (#13417) Co-authored-by: Muhsin Keloth --- app/models/conversation.rb | 1 + .../conversation/response_builder_job.rb | 8 ++-- .../conversation/response_builder_job_spec.rb | 38 +++++++++++++++++ spec/models/conversation_spec.rb | 41 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ca53238e8..6dd0e9df5 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -159,6 +159,7 @@ class Conversation < ApplicationRecord end def bot_handoff! + update(waiting_since: Time.current) if waiting_since.blank? open! dispatcher_dispatch(CONVERSATION_BOT_HANDOFF) end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 698ec56e7..c4723f6b9 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -42,10 +42,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def process_response - ActiveRecord::Base.transaction do - if handoff_requested? - process_action('handoff') - else + if handoff_requested? + process_action('handoff') + else + ActiveRecord::Base.transaction do create_messages Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}") account.increment_response_usage diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 777086613..4e48eb355 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -92,6 +92,44 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do end end + # Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the + # same transaction defers the message's after_create_commit until commit, at which + # point it clears waiting_since (bot_response). The handoff path must stay outside + # the transaction so the callback fires before bot_handoff! sets waiting_since. + context 'when handoff is requested' do + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } + let(:agent) { create(:user, account: account, role: :agent) } + + before do + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'sets waiting_since to approximately the handoff time' do + freeze_time do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.status).to eq('open') + expect(conversation.waiting_since).to be_within(1.second).of(Time.current) + end + end + + it 'preserves waiting_since so a human reply consumes it for reply_time tracking' do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.waiting_since).to be_present + + # A human reply clears waiting_since (consumed by dispatch_create_events + # to emit FIRST_REPLY_CREATED or REPLY_CREATED for reply_time tracking). + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent, account: account, inbox: inbox) + expect(conversation.reload.waiting_since).to be_nil + end + end + context 'when message contains an image' do let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') } diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index e1883b54d..89c090207 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -313,6 +313,47 @@ RSpec.describe Conversation do end end + describe '#bot_handoff!' do + let(:conversation) { create(:conversation, status: :pending) } + + before do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + context 'when waiting_since is blank' do + before { conversation.update(waiting_since: nil) } + + it 'sets waiting_since to current time' do + freeze_time do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to eq(Time.current) + end + end + end + + context 'when waiting_since is already set' do + let(:original_time) { 1.hour.ago } + + before { conversation.update(waiting_since: original_time) } + + it 'preserves existing waiting_since' do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to be_within(1.second).of(original_time) + end + end + + it 'changes status to open' do + conversation.bot_handoff! + expect(conversation.reload.status).to eq('open') + end + + it 'dispatches CONVERSATION_BOT_HANDOFF event' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation)) + conversation.bot_handoff! + end + end + describe '#toggle_priority' do it 'defaults priority to nil when created' do conversation = create(:conversation, status: 'open') From 14b4c83dc654cb3b6ac1f4cbf6d479bcaa00d165 Mon Sep 17 00:00:00 2001 From: eloijrseganfredo Date: Fri, 27 Feb 2026 07:12:03 -0300 Subject: [PATCH 13/90] fix: Prevent AudioTranscriptionJob from crashing on OpenAI 401 error (#13653) Describe the bug In v4.8.0, when an audio message is received, the system enqueues Messages::AudioTranscriptionJob even if OpenAI and Captain are disabled. This causes a Faraday::UnauthorizedError (401) which crashes the Sidekiq job and breaks the pipeline for that message. To Reproduce Disable OpenAI/Captain integrations. Send an audio message to an inbox. Check Sidekiq logs and observe the 401 crash in AudioTranscriptionService. What this PR does Adds a rescue Faraday::UnauthorizedError block inside AudioTranscriptionService#perform. Instead of crashing the worker, it logs a warning and gracefully exits, allowing the job to complete successfully. Note: This fixes the backend crash. However, there is still a frontend reactivity issue where the audio player UI requires an F5 to load the media, which has been reported in Issue #11013. --------- Co-authored-by: Eloi Junior Seganfredo Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .../app/services/messages/audio_transcription_service.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 4aa156f47..0e574cb03 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -19,6 +19,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" { success: true, transcriptions: transcriptions } + rescue Faraday::UnauthorizedError + Rails.logger.warn('Skipping audio transcription: OpenAI configuration is invalid or disabled (401 Unauthorized).') + { error: 'OpenAI configuration is invalid or disabled (401)' } end private From c08fa631a9667a2fa9680ea64ebd4f1bfb2095f2 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:07:00 +0530 Subject: [PATCH 14/90] feat: Add temporary account setting to disable Captain auto-resolve (#13680) Add a temporary `captain_disable_auto_resolve` boolean setting on accounts to prevent Captain from resolving conversations. Guards both the scheduled resolution job and the assistant's resolve tool. --------- Co-authored-by: Claude Opus 4.6 --- app/models/account.rb | 2 ++ ...inbox_pending_conversations_resolution_job.rb | 2 ++ .../conversations_resolution_scheduler_job.rb | 1 + .../captain/tools/resolve_conversation_tool.rb | 1 + ..._pending_conversations_resolution_job_spec.rb | 11 +++++++++++ ...onversations_resolution_scheduler_job_spec.rb | 16 ++++++++++++++++ .../tools/resolve_conversation_tool_spec.rb | 11 +++++++++++ 7 files changed, 44 insertions(+) diff --git a/app/models/account.rb b/app/models/account.rb index 4816494fb..eabaa5c26 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,6 +41,7 @@ class Account < ApplicationRecord 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, + 'captain_disable_auto_resolve': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -90,6 +91,7 @@ class Account < ApplicationRecord store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :keep_pending_on_bot_failure + store_accessor :settings, :captain_disable_auto_resolve has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb index d3f1f5d96..ab9ca2ab1 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -2,6 +2,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob queue_as :low def perform(inbox) + return if inbox.account.captain_disable_auto_resolve + Current.executed_by = inbox.captain_assistant resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) diff --git a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb index 599dee96a..8b6527c93 100644 --- a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb +++ b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb @@ -12,6 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob inbox = captain_inbox.inbox next if inbox.email? + next if inbox.account.captain_disable_auto_resolve Captain::InboxPendingConversationsResolutionJob.perform_later( inbox diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb index 0d2563a8b..5d96d3af1 100644 --- a/enterprise/lib/captain/tools/resolve_conversation_tool.rb +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -6,6 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool conversation = find_conversation(tool_context.state) return 'Conversation not found' unless conversation return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved? + return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason }) diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb index 1a8a5a342..ab8f0296c 100644 --- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb +++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb @@ -64,4 +64,15 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do } ) end + + it 'does not resolve conversations when auto-resolve is disabled at execution time' do + inbox.account.update!(captain_disable_auto_resolve: true) + + expect do + described_class.perform_now(inbox) + end.not_to(change { resolvable_pending_conversation.reload.status }) + + expect(resolvable_pending_conversation.reload.status).to eq('pending') + expect(resolvable_pending_conversation.messages.outgoing).to be_empty + end end diff --git a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb index b67877412..343100a50 100644 --- a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb @@ -30,6 +30,22 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do end end + context 'when account has captain_disable_auto_resolve enabled' do + let!(:regular_inbox) { create(:inbox, account: account) } + + before do + create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox) + account.update!(captain_disable_auto_resolve: true) + end + + it 'does not enqueue resolution jobs' do + expect do + described_class.perform_now + end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob) + .with(regular_inbox) + end + end + context 'when inbox has no captain enabled' do let!(:inbox_without_captain) { create(:inbox, account: create(:account)) } diff --git a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb index f91f430e8..d5792cf78 100644 --- a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -36,6 +36,17 @@ RSpec.describe Captain::Tools::ResolveConversationTool do end end + describe 'when auto-resolve is disabled for the account' do + before { account.update!(captain_disable_auto_resolve: true) } + + it 'does not resolve and returns a disabled message' do + result = tool.perform(tool_context, reason: 'Possible spam') + + expect(result).to eq('Auto-resolve is disabled for this account') + expect(conversation.reload).not_to be_resolved + end + end + describe 'resolving an already resolved conversation' do let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :resolved) } From 8d48e05283b813c5ed0429455ad8f41a8e1b77ed Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 2 Mar 2026 13:12:42 +0530 Subject: [PATCH 15/90] feat: reclaim `mobile_v2` flag for `report_rollup` (#13666) --- config/features.yml | 5 ++--- ...260226153427_disable_report_rollup_for_all_accounts.rb | 8 ++++++++ db/schema.rb | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260226153427_disable_report_rollup_for_all_accounts.rb diff --git a/config/features.yml b/config/features.yml index 65b3c6194..eed6a9da1 100644 --- a/config/features.yml +++ b/config/features.yml @@ -74,10 +74,9 @@ - name: voice_recorder display_name: Voice Recorder enabled: true -- name: mobile_v2 - display_name: Mobile App V2 +- name: report_rollup + display_name: Report Rollup enabled: false - deprecated: true - name: channel_website display_name: Website Channel enabled: true diff --git a/db/migrate/20260226153427_disable_report_rollup_for_all_accounts.rb b/db/migrate/20260226153427_disable_report_rollup_for_all_accounts.rb new file mode 100644 index 000000000..60a8f4604 --- /dev/null +++ b/db/migrate/20260226153427_disable_report_rollup_for_all_accounts.rb @@ -0,0 +1,8 @@ +class DisableReportRollupForAllAccounts < ActiveRecord::Migration[7.1] + def up + Account.feature_report_rollup.find_each(batch_size: 100) do |account| + account.disable_features(:report_rollup) + account.save!(validate: false) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8a450e734..4bb0ca3af 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_02_26_084618) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" From ab93821d2b210d60d7cd6b0cd1ff0410458c2cf7 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 2 Mar 2026 02:18:29 -0800 Subject: [PATCH 16/90] fix(agent-bot): stabilize webhook delivery for transient upstream failures (#13521) This fixes the agent-bot webhook delivery path so transient upstream failures follow the expected delivery lifecycle. Existing fallback behavior is preserved, and fallback actions are applied only after delivery attempts are exhausted. To reproduce, configure an agent-bot webhook endpoint to return 429/500 for message events. Before this fix, failure handling could be applied too early; after this fix, delivery attempts complete first and then existing fallback handling runs. Tested with: - bundle exec rspec spec/jobs/agent_bots/webhook_job_spec.rb spec/lib/webhooks/trigger_spec.rb - bundle exec rubocop spec/jobs/agent_bots/webhook_job_spec.rb spec/lib/webhooks/trigger_spec.rb --------- Co-authored-by: Muhsin Keloth --- app/jobs/agent_bots/webhook_job.rb | 7 ++++ lib/webhooks/trigger.rb | 12 +++++- spec/jobs/agent_bots/webhook_job_spec.rb | 29 ++++++++++++++ spec/lib/webhooks/trigger_spec.rb | 50 ++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/app/jobs/agent_bots/webhook_job.rb b/app/jobs/agent_bots/webhook_job.rb index b3a3d6cc1..2786ce70e 100644 --- a/app/jobs/agent_bots/webhook_job.rb +++ b/app/jobs/agent_bots/webhook_job.rb @@ -1,7 +1,14 @@ class AgentBots::WebhookJob < WebhookJob queue_as :high + retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error| + url, payload, webhook_type = job.arguments + Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error) + end def perform(url, payload, webhook_type = :agent_bot_webhook) super(url, payload, webhook_type) + rescue RestClient::TooManyRequests, RestClient::InternalServerError => e + Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}") + raise end end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 456e186ca..7cb15c836 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -15,9 +15,17 @@ class Webhooks::Trigger def execute perform_request + rescue RestClient::TooManyRequests, RestClient::InternalServerError => e + raise if @webhook_type == :agent_bot_webhook + + handle_failure(e) rescue StandardError => e - handle_error(e) - Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{e.message}" + handle_failure(e) + end + + def handle_failure(error) + handle_error(error) + Rails.logger.warn "Exception: Invalid webhook URL #{@url} : #{error.message}" end private diff --git a/spec/jobs/agent_bots/webhook_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb index 346d85e83..c14c46cb3 100644 --- a/spec/jobs/agent_bots/webhook_job_spec.rb +++ b/spec/jobs/agent_bots/webhook_job_spec.rb @@ -8,6 +8,16 @@ RSpec.describe AgentBots::WebhookJob do let(:url) { 'https://test.com' } let(:payload) { { name: 'test' } } let(:webhook_type) { :agent_bot_webhook } + let(:retryable_error) { RestClient::InternalServerError.new(nil, 500) } + + before do + ActiveJob::Base.queue_adapter = :test + end + + after do + clear_enqueued_jobs + clear_performed_jobs + end it 'queues the job' do expect { job }.to have_enqueued_job(described_class) @@ -19,4 +29,23 @@ RSpec.describe AgentBots::WebhookJob do expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end + + it 'configures retry handlers for 429 and 500 errors' do + handlers = described_class.rescue_handlers.map(&:first) + + expect(handlers).to include('RestClient::TooManyRequests', 'RestClient::InternalServerError') + end + + it 'retries 3 times and handles failure after retries are exhausted' do + allow(Webhooks::Trigger).to receive(:execute).and_raise(retryable_error) + trigger_instance = instance_double(Webhooks::Trigger, handle_failure: true) + allow(Webhooks::Trigger).to receive(:new).and_return(trigger_instance) + allow(Rails.logger).to receive(:warn) + + expect(Webhooks::Trigger).to receive(:execute).exactly(3).times + expect(trigger_instance).to receive(:handle_failure).with(instance_of(RestClient::InternalServerError)).once + expect(Rails.logger).to receive(:warn).with(/AgentBots::WebhookJob/).exactly(3).times + + perform_enqueued_jobs { job } + end end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 1e047b557..90d1ce7f8 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -77,6 +77,40 @@ describe Webhooks::Trigger do let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) } let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) } + it 'raises 500 errors for retry and does not reopen conversation immediately' do + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::InternalServerError.new(nil, 500)).once + + expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::InternalServerError) + expect(pending_conversation.reload.status).to eq('pending') + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + end + + it 'raises 429 errors for retry and does not reopen conversation immediately' do + payload = { event: 'message_created', id: pending_message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::TooManyRequests.new(nil, 429)).once + + expect { trigger.execute(url, payload, webhook_type) }.to raise_error(RestClient::TooManyRequests) + expect(pending_conversation.reload.status).to eq('pending') + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + end + it 'reopens conversation and enqueues activity message if pending' do payload = { event: 'message_created', id: pending_message.id } @@ -166,6 +200,22 @@ describe Webhooks::Trigger do expect(activity_message.content).to eq(agent_bot_error_content) end end + + it 'handles 500 without raising for non-agent webhooks' do + payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: webhook_timeout + ).and_raise(RestClient::InternalServerError.new(nil, 500)).once + + expect { trigger.execute(url, payload, webhook_type) }.not_to raise_error + expect(message.reload.status).to eq('failed') + end end describe 'request headers' do From 9aacc0335b8513cb4b1f297d14a1de81711ad5c9 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 2 Mar 2026 15:32:59 +0400 Subject: [PATCH 17/90] feat(facebook): use `HUMAN_AGENT` tag for Messenger replies when human-agent config is enabled (#13690) This PR updates Facebook Messenger outbound tagging in Chatwoot to support Human Agent messaging when enabled. Previously, Facebook outbound text and attachment messages were always sent with: ``` messaging_type: MESSAGE_TAG tag: ACCOUNT_UPDATE ``` With this change, the tag is selected dynamically: ``` HUMAN_AGENT when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled ACCOUNT_UPDATE as fallback when the flag is disabled ``` --- app/services/facebook/send_on_facebook_service.rb | 8 ++++++-- .../facebook/send_on_facebook_service_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/services/facebook/send_on_facebook_service.rb b/app/services/facebook/send_on_facebook_service.rb index ed3b7e4ab..baf72ef6e 100644 --- a/app/services/facebook/send_on_facebook_service.rb +++ b/app/services/facebook/send_on_facebook_service.rb @@ -49,7 +49,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService recipient: { id: contact.get_source_id(inbox.id) }, message: fb_text_message_payload, messaging_type: 'MESSAGE_TAG', - tag: 'ACCOUNT_UPDATE' + tag: message_tag } end @@ -90,10 +90,14 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService } }, messaging_type: 'MESSAGE_TAG', - tag: 'ACCOUNT_UPDATE' + tag: message_tag } end + def message_tag + @message_tag ||= GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil) ? 'HUMAN_AGENT' : 'ACCOUNT_UPDATE' + end + def attachment_type(attachment) return attachment.file_type if %w[image audio video file].include? attachment.file_type diff --git a/spec/services/facebook/send_on_facebook_service_spec.rb b/spec/services/facebook/send_on_facebook_service_spec.rb index 4d5f9babd..f99b1c469 100644 --- a/spec/services/facebook/send_on_facebook_service_spec.rb +++ b/spec/services/facebook/send_on_facebook_service_spec.rb @@ -7,6 +7,7 @@ describe Facebook::SendOnFacebookService do allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true) allow(bot).to receive(:deliver).and_return({ recipient_id: '1008372609250235', message_id: 'mid.1456970487936:c34767dfe57ee6e339' }.to_json) create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation) + GlobalConfig.clear_cache end let!(:account) { create(:account) } @@ -90,6 +91,17 @@ describe Facebook::SendOnFacebookService do }, { page_id: facebook_channel.page_id }) end + it 'sends with HUMAN_AGENT tag when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled' do + with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do + message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) + described_class.new(message: message).perform + expect(bot).to have_received(:deliver).with( + hash_including(tag: 'HUMAN_AGENT'), + { page_id: facebook_channel.page_id } + ) + end + end + it 'if message is sent with multiple attachments' do message = build(:message, content: nil, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) avatar = message.attachments.new(account_id: message.account_id, file_type: :image) From 89da4a2292386b5a8bc9680752ba9df0a14ddc1d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:27:51 +0530 Subject: [PATCH 18/90] feat: compose form improvements (#13668) --- app/javascript/dashboard/api/contacts.js | 4 +- .../dashboard/api/specs/contacts.spec.js | 14 ++- .../components-next/Editor/Editor.vue | 5 +- .../NewConversation/ComposeConversation.vue | 14 ++- .../components/ComposeNewConversationForm.vue | 49 ++++++++- .../components/EmailOptions.vue | 2 - .../components/InboxEmptyState.vue | 8 +- .../components/InboxSelector.vue | 7 ++ .../components/MessageEditor.vue | 82 ++++++++++---- .../helpers/composeConversationHelper.js | 47 ++++++-- .../specs/composeConversationHelper.spec.js | 104 ++++++++++++++++-- .../components-next/taginput/TagInput.vue | 2 +- .../widgets/WootWriter/CopilotMenuBar.vue | 37 +++++-- .../components/widgets/WootWriter/Editor.vue | 23 +++- .../widgets/WootWriter/ReplyTopPanel.vue | 10 +- .../widgets/conversation/ReplyBox.vue | 1 + .../dashboard/i18n/locale/en/contact.json | 6 +- .../components/SearchContactAgentSelector.vue | 12 +- 18 files changed, 354 insertions(+), 73 deletions(-) diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 1e76ac987..bae5623a7 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -57,14 +57,14 @@ class ContactAPI extends ApiClient { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } - search(search = '', page = 1, sortAttr = 'name', label = '') { + search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) { let requestURL = `${this.url}/search?${buildContactParams( page, sortAttr, label, search )}`; - return axios.get(requestURL); + return axios.get(requestURL, { signal: options.signal }); } active(page = 1, sortAttr = 'name') { diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 0059518b0..b21aeb102 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -68,7 +68,19 @@ describe('#ContactsAPI', () => { it('#search', () => { contactAPI.search('leads', 1, 'date', 'customer-support'); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: undefined } + ); + }); + + it('#search with signal', () => { + const controller = new AbortController(); + contactAPI.search('leads', 1, 'date', 'customer-support', { + signal: controller.signal, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support', + { signal: controller.signal } ); }); diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index c2cde6d17..847bbd600 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -28,7 +28,7 @@ const props = defineProps({ medium: { type: String, default: '' }, }); -const emit = defineEmits(['update:modelValue']); +const emit = defineEmits(['update:modelValue', 'executeCopilotAction']); const slots = useSlots(); @@ -113,6 +113,9 @@ watch( @input="handleInput" @focus="handleFocus" @blur="handleBlur" + @execute-copilot-action=" + (...args) => emit('executeCopilotAction', ...args) + " />
{ contact = rest; } selectedContact.value = contact; + contacts.value = []; if (contact?.id) { isFetchingInboxes.value = true; try { diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 0ed22ad0c..455abf996 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -15,6 +15,9 @@ import { prepareWhatsAppMessagePayload, } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js'; +import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; +import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; + import ContactSelector from './ContactSelector.vue'; import InboxSelector from './InboxSelector.vue'; import EmailOptions from './EmailOptions.vue'; @@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue'; import ActionButtons from './ActionButtons.vue'; import InboxEmptyState from './InboxEmptyState.vue'; import AttachmentPreviews from './AttachmentPreviews.vue'; +import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue'; const props = defineProps({ contacts: { type: Array, default: () => [] }, @@ -42,6 +46,7 @@ const props = defineProps({ const emit = defineEmits([ 'searchContacts', + 'resetContactSearch', 'discard', 'updateSelectedContact', 'updateTargetInbox', @@ -51,6 +56,8 @@ const emit = defineEmits([ const DEFAULT_FORMATTING = 'Context::Default'; +const copilot = useCopilotReply(); + const showContactsDropdown = ref(false); const showInboxesDropdown = ref(false); const showCcEmailsDropdown = ref(false); @@ -157,7 +164,7 @@ const isAnyDropdownActive = computed(() => { }); const handleContactSearch = value => { - showContactsDropdown.value = true; + showContactsDropdown.value = value.trim().length > 1; emit('searchContacts', value); }; @@ -172,12 +179,16 @@ const handleDropdownUpdate = (type, value) => { }; const searchCcEmails = value => { - showCcEmailsDropdown.value = true; + showBccEmailsDropdown.value = false; + emit('resetContactSearch'); + showCcEmailsDropdown.value = value.trim().length >= 2; emit('searchContacts', value); }; const searchBccEmails = value => { - showBccEmailsDropdown.value = true; + showCcEmailsDropdown.value = false; + emit('resetContactSearch'); + showBccEmailsDropdown.value = value.trim().length >= 2; emit('searchContacts', value); }; @@ -196,6 +207,7 @@ const stripMessageFormatting = channelType => { const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => { v$.value.$reset(); + copilot.reset(false); // Strip unsupported formatting when changing the target inbox if (channelType) { @@ -222,6 +234,7 @@ const removeSignatureFromMessage = () => { const removeTargetInbox = value => { v$.value.$reset(); + copilot.reset(false); removeSignatureFromMessage(); stripMessageFormatting(DEFAULT_FORMATTING); @@ -231,6 +244,7 @@ const removeTargetInbox = value => { }; const clearSelectedContact = () => { + copilot.reset(false); removeSignatureFromMessage(); emit('clearSelectedContact'); state.message = ''; @@ -262,6 +276,7 @@ const handleAttachFile = files => { }; const clearForm = () => { + copilot.reset(false); Object.assign(state, { message: '', subject: '', @@ -324,6 +339,24 @@ const shouldShowMessageEditor = computed(() => { !inboxTypes.value.isTwilioWhatsapp ); }); + +const isCopilotActive = computed(() => copilot.isActive?.value ?? false); + +const onSubmitCopilotReply = () => { + const acceptedMessage = copilot.accept(); + state.message = acceptedMessage; +}; + +useKeyboardEvents({ + '$mod+Enter': { + action: () => { + if (isCopilotActive.value && !copilot.isButtonDisabled.value) { + onSubmitCopilotReply(); + } + }, + allowOnFocusedInput: true, + }, +});