From e3020fbe2ccaabf1d596c01a629bd93b3094e920 Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 19 Sep 2025 00:09:17 -0700 Subject: [PATCH 01/13] fix: Use case sensitive filter for phone_numbers (#12470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact filter APIs were timing out due to the case‑insensitive filter. There is no index for lower case phone numbers, so it would perform a table scan, potentially examining 8 million records or more at a time. This change should fix the issue. I am changing the filter to use direct comparison without lower‑case. **Previous:** ```sql SELECT contacts.* FROM contacts WHERE contacts.account_id = $1 AND ( LOWER(contacts.phone_number) = '' OR LOWER(contacts.phone_number) = '' ) ORDER BY contacts.created_at DESC NULLS LAST LIMIT $2 OFFSET $3 ``` **Updated:** ```sql SELECT contacts.* FROM contacts WHERE contacts.account_id = $1 AND ( contacts.phone_number = '' OR contacts.phone_number = '' ) ORDER BY contacts.created_at DESC NULLS LAST LIMIT $2 OFFSET $3 ``` Fixes: https://linear.app/chatwoot/issue/CW-5582/contact-filter-timing-out --- Gemfile.lock | 2 +- lib/filters/filter_keys.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f9e253e6c..16e57d4f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -727,7 +727,7 @@ GEM retriable (3.1.2) reverse_markdown (2.1.1) nokogiri - rexml (3.4.1) + rexml (3.4.4) rotp (6.3.0) rspec-core (3.13.0) rspec-support (~> 3.13.0) diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml index 902b5f219..626a0cbd5 100644 --- a/lib/filters/filter_keys.yml +++ b/lib/filters/filter_keys.yml @@ -4,7 +4,7 @@ # 3. Automation Filters (app/services/automation_rules/conditions_filter_service.rb), (app/services/automation_rules/condition_validation_service.rb) -# Format +# Format # - Parent Key (conversation, contact, messages) # - Key (attribute_name) # - attribute_type: "standard" : supported ["standard", "additional_attributes (only for conversations and messages)"] @@ -138,7 +138,7 @@ contacts: - "does_not_contain" phone_number: attribute_type: "standard" - data_type: "text_case_insensitive" + data_type: "text" # Text is not explicity defined in filters, default filter will be used filter_operators: - "equal_to" - "not_equal_to" From d9af219ba3448cd16b1ab1b21a9aad69b879beae Mon Sep 17 00:00:00 2001 From: rotsen Date: Fri, 19 Sep 2025 04:22:01 -0300 Subject: [PATCH 02/13] feat: Implement single audio playback functionality across components (#12226) ## Description Introduces a global single-audio playback helper and hooks it into dashboard and widget entrypoints. Adds play/pause event handlers in the Audio chip to sync UI state. The helper enforces one audio playing at a time and auto-advances to the next adjacent audio on end. Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../components-next/message/chips/Audio.vue | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index 431058463..667d2d7b6 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -1,8 +1,16 @@ + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue new file mode 100644 index 000000000..e99d01758 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Guardrails.vue @@ -0,0 +1,1000 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue new file mode 100644 index 000000000..958f97dee --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/ResponseGuidelines.vue @@ -0,0 +1,990 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue new file mode 100644 index 000000000..b50bcf0ac --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Scenarios.vue @@ -0,0 +1,1060 @@ + + + + + diff --git a/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue b/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue new file mode 100644 index 000000000..d50b2ff54 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/AnimatingImg/Settings.vue @@ -0,0 +1,752 @@ + + + From be4410850072678d14252a62f64febb316e2a3d8 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Mon, 22 Sep 2025 04:53:28 -0300 Subject: [PATCH 06/13] chore: Add missing space in signup link (#12475) ## Description This branch adds a missing space in the signup footer, **changing**: `Already have an account?Login to Chatwoot` **to** `Already have an account? Login to Chatwoot` ## Type of change Non-breaking change --------- Co-authored-by: Muhsin Keloth --- app/javascript/v3/views/auth/signup/Index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/v3/views/auth/signup/Index.vue b/app/javascript/v3/views/auth/signup/Index.vue index 508c755f5..86baff55c 100644 --- a/app/javascript/v3/views/auth/signup/Index.vue +++ b/app/javascript/v3/views/auth/signup/Index.vue @@ -62,7 +62,7 @@ export default {
- {{ $t('REGISTER.HAVE_AN_ACCOUNT') }} + {{ $t('REGISTER.HAVE_AN_ACCOUNT') }} {{ replaceInstallationName($t('LOGIN.TITLE')) }} From b5deecc9f9d130139224c696c12b65f75d479c2d Mon Sep 17 00:00:00 2001 From: mix5003 Date: Mon, 22 Sep 2025 16:36:28 +0700 Subject: [PATCH 07/13] feat: Accept file attachment in line channel (#12321) # Pull Request Template ## Description This pull request allow LINE to receive files. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? add testcase. and test manually by myself. in case you want to test in android, use native share method to share files to LINE. you can share more file types to LINE (native line share only send image,video and audio). ## Checklist: - [x] My code follows the style guidelines of this project - [x] 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: mix5003 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- app/services/line/incoming_message_service.rb | 18 +++++- .../line/incoming_message_service_spec.rb | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb index 4761292d0..0d2f78c7a 100644 --- a/app/services/line/incoming_message_service.rb +++ b/app/services/line/incoming_message_service.rb @@ -76,7 +76,8 @@ class Line::IncomingMessageService response = inbox.channel.client.get_message_content(message['id']) - file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}" + extension = get_file_extension(response) + file_name = message['fileName'] || "media-#{message['id']}.#{extension}" temp_file = Tempfile.new(file_name) temp_file.binmode temp_file << response.body @@ -93,12 +94,25 @@ class Line::IncomingMessageService ) end + def get_file_extension(response) + if response.content_type&.include?('/') + response.content_type.split('/')[1] + else + 'bin' + end + end + def event_type_message?(event) event['type'] == 'message' || event['type'] == 'sticker' end def message_type_non_text?(type) - [Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type) + [ + Line::Bot::Event::MessageType::Video, + Line::Bot::Event::MessageType::Audio, + Line::Bot::Event::MessageType::Image, + Line::Bot::Event::MessageType::File + ].include?(type) end def account diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index 3bcdf8b04..9160efff2 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -105,6 +105,40 @@ describe Line::IncomingMessageService do }.with_indifferent_access end + let(:file_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + }, + 'message': { + 'type': 'file', + 'id': '354718', + 'fileName': 'contacts.csv', + 'fileSize': 2978 + } + }, + { + 'replyToken': '8cf9239d56244f4197887e939187e19e', + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + } + } + ] + }.with_indifferent_access + end + let(:sticker_params) do { 'destination': '2342234234', @@ -241,5 +275,35 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.mp4') end end + + context 'when valid file message params' do + it 'creates appropriate conversations, message and contacts' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + file = fixture_file_upload(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') + allow(line_bot).to receive(:get_message_content).and_return( + OpenStruct.new({ + body: Base64.encode64(file.read), + content_type: 'text/csv' + }) + ) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + described_class.new(inbox: line_channel.inbox, params: file_params).perform + expect(line_channel.inbox.conversations).not_to eq(0) + expect(Contact.all.first.name).to eq('LINE Test') + expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629') + expect(line_channel.inbox.messages.first.content).to be_nil + expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('file') + expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv') + end + end end end From b28c08059f6d0cdb0b36f018117bcc9fcdc341c5 Mon Sep 17 00:00:00 2001 From: Niranjan Patil Date: Mon, 22 Sep 2025 17:05:11 +0530 Subject: [PATCH 08/13] fix: Incorrect contact access in conversations listing (#11797) # Pull Request Template ## Description This PR fixes the incorrect contact access in conversations listing API. Cause: - `undefined method 'conversations' for nil` error because `@contact` is not initialized Solution: - Using `@contact_inbox` to access `@contact` - `@contact_inbox` is properly set in the parent controller's `set_contact_inbox` method Fixes https://linear.app/chatwoot/issue/CW-4185/incorrect-contact-access-pattern-in ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [x] My code follows the style guidelines of this project - [x] 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 - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Muhsin Keloth --- .../public/api/v1/inboxes/conversations_controller.rb | 2 +- .../api/v1/inbox/conversations_controller_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb index 4e3b5dca9..242dcde77 100644 --- a/app/controllers/public/api/v1/inboxes/conversations_controller.rb +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status] def index - @conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations + @conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations end def show; end diff --git a/spec/controllers/public/api/v1/inbox/conversations_controller_spec.rb b/spec/controllers/public/api/v1/inbox/conversations_controller_spec.rb index 2b2bdefc3..6684d18cf 100644 --- a/spec/controllers/public/api/v1/inbox/conversations_controller_spec.rb +++ b/spec/controllers/public/api/v1/inbox/conversations_controller_spec.rb @@ -16,6 +16,17 @@ RSpec.describe 'Public Inbox Contact Conversations API', type: :request do expect(data.first['uuid']).to eq contact_inbox.conversations.first.uuid end + it 'return the conversations when hmac_verified is true' do + contact_inbox.update(hmac_verified: true) + create(:conversation, contact: contact) + get "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}/conversations" + + expect(response).to have_http_status(:success) + data = response.parsed_body + expect(data.length).to eq 1 + expect(data.first['uuid']).to eq contact.conversations.first.uuid + end + it 'does not return any private or activity message' do conversation = create(:conversation, contact_inbox: contact_inbox) create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation, content: 'message-1') From 0e41263f9c3da2fe34cb1c04fde14620ecb7283a Mon Sep 17 00:00:00 2001 From: mix5003 Date: Mon, 22 Sep 2025 18:35:25 +0700 Subject: [PATCH 09/13] fix: Ensure messages go to correct conversation when receive multi user in 1 LINE webhook (#12322) # Pull Request Template ## Description Ensure messages go to correct conversation when receive multi user in 1 LINE webhook. base on [document](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects:~:text=There%20is%20not%20necessarily%20one%20user%20per%20webhook). it said ``` There is not necessarily one user per webhook. A message event from person A and a follow event from person B may be in the same webhook. ``` this PR has 1 break changes. In old version. when receive [follow](https://developers.line.biz/en/reference/messaging-api/#follow-event) event, it will create conversation with no messages. After this PR. when receive follow event, it will not create conversation, contact and messages ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? add test case. and follow event test by delete conversation, and block and unblock line account ## Checklist: - [x] My code follows the style guidelines of this project - [x] 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 - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: mix5003 Co-authored-by: Muhsin Keloth Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/line/incoming_message_service.rb | 27 ++--- .../line/incoming_message_service_spec.rb | 104 +++++++++++++++++- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb index 0d2f78c7a..6a1192d02 100644 --- a/app/services/line/incoming_message_service.rb +++ b/app/services/line/incoming_message_service.rb @@ -10,11 +10,6 @@ class Line::IncomingMessageService # probably test events return if params[:events].blank? - line_contact_info - return if line_contact_info['userId'].blank? - - set_contact - set_conversation parse_events end @@ -22,6 +17,14 @@ class Line::IncomingMessageService def parse_events params[:events].each do |event| + next unless event_type_message?(event) + + get_line_contact_info(event) + next if @line_contact_info['userId'].blank? + + set_contact + set_conversation + next unless message_created? event attach_files event['message'] @@ -30,8 +33,6 @@ class Line::IncomingMessageService end def message_created?(event) - return unless event_type_message?(event) - @message = @conversation.messages.build( content: message_content(event), account_id: @inbox.account_id, @@ -119,13 +120,13 @@ class Line::IncomingMessageService @account ||= inbox.account end - def line_contact_info - @line_contact_info ||= JSON.parse(inbox.channel.client.get_profile(params[:events].first['source']['userId']).body) + def get_line_contact_info(event) + @line_contact_info = JSON.parse(inbox.channel.client.get_profile(event['source']['userId']).body) end def set_contact contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: line_contact_info['userId'], + source_id: @line_contact_info['userId'], inbox: inbox, contact_attributes: contact_attributes ).perform @@ -152,15 +153,15 @@ class Line::IncomingMessageService def contact_attributes { - name: line_contact_info['displayName'], - avatar_url: line_contact_info['pictureUrl'], + name: @line_contact_info['displayName'], + avatar_url: @line_contact_info['pictureUrl'], additional_attributes: additional_attributes } end def additional_attributes { - social_line_user_id: line_contact_info['userId'] + social_line_user_id: @line_contact_info['userId'] } end diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index 9160efff2..a7805ce9b 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -35,6 +35,62 @@ describe Line::IncomingMessageService do }.with_indifferent_access end + let(:follow_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '8cf9239d56244f4197887e939187e19e', + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + } + } + ] + }.with_indifferent_access + end + + let(:multi_user_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f1', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + }, + 'message': { + 'id': '3257081', + 'type': 'text', + 'text': 'Hello, world 1' + } + }, + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f2', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af49806292' + }, + 'message': { + 'id': '3257082', + 'type': 'text', + 'text': 'Hello, world 2' + } + } + ] + }.with_indifferent_access + end + let(:image_params) do { 'destination': '2342234234', @@ -175,8 +231,8 @@ describe Line::IncomingMessageService do end describe '#perform' do - context 'when valid text message params' do - it 'creates appropriate conversations, message and contacts' do + context 'when non-text message params' do + it 'does not create conversations, messages and contacts' do line_bot = double line_user_profile = double allow(Line::Bot::Client).to receive(:new).and_return(line_bot) @@ -188,12 +244,56 @@ describe Line::IncomingMessageService do 'pictureUrl': 'https://test.com' }.to_json ) + described_class.new(inbox: line_channel.inbox, params: follow_params).perform + expect(line_channel.inbox.conversations.size).to eq(0) + expect(Contact.all.size).to eq(0) + expect(line_channel.inbox.messages.size).to eq(0) + end + end + + context 'when valid text message params' do + let(:line_bot) { double } + let(:line_user_profile) { double } + + before do + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).with('U4af4980629').and_return(line_user_profile) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + end + + it 'creates appropriate conversations, message and contacts' do described_class.new(inbox: line_channel.inbox, params: params).perform expect(line_channel.inbox.conversations).not_to eq(0) expect(Contact.all.first.name).to eq('LINE Test') expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629') expect(line_channel.inbox.messages.first.content).to eq('Hello, world') end + + it 'creates appropriate conversations, message and contacts for multi user' do + line_user_profile2 = double + allow(line_bot).to receive(:get_profile).with('U4af49806292').and_return(line_user_profile2) + allow(line_user_profile2).to receive(:body).and_return( + { + 'displayName': 'LINE Test 2', + 'userId': 'U4af49806292', + 'pictureUrl': 'https://test.com' + }.to_json + ) + described_class.new(inbox: line_channel.inbox, params: multi_user_params).perform + expect(line_channel.inbox.conversations.size).to eq(2) + expect(Contact.all.first.name).to eq('LINE Test') + expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629') + expect(Contact.all.last.name).to eq('LINE Test 2') + expect(Contact.all.last.additional_attributes['social_line_user_id']).to eq('U4af49806292') + expect(line_channel.inbox.messages.first.content).to eq('Hello, world 1') + expect(line_channel.inbox.messages.last.content).to eq('Hello, world 2') + end end context 'when valid sticker message params' do From 3655f4cedc09885e7150f6c174f4e3a9c979a100 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 22 Sep 2025 17:19:12 +0530 Subject: [PATCH 10/13] feat: Add superlong debounce condition for meta endpoint (#12486) --- .../dashboard/store/modules/conversationStats.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js index bae365f30..1b3844b08 100644 --- a/app/javascript/dashboard/store/modules/conversationStats.js +++ b/app/javascript/dashboard/store/modules/conversationStats.js @@ -27,10 +27,18 @@ const fetchMetaData = async (commit, params) => { const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000); const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000); +const superLongDebouncedFetchMetaData = debounce( + fetchMetaData, + 2000, + false, + 5000 +); export const actions = { get: async ({ commit, state: $state }, params) => { - if ($state.allCount > 100) { + if ($state.allCount > 10000) { + superLongDebouncedFetchMetaData(commit, params); + } else if ($state.allCount > 100) { longDebouncedFetchMetaData(commit, params); } else { debouncedFetchMetaData(commit, params); From 8764ade161b427491ff40ad34129ffb22bf6fe9c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 22 Sep 2025 17:52:56 +0530 Subject: [PATCH 11/13] feat: add `SKIP_INCOMING_BCC_PROCESSING` as internal config (#12484) Co-authored-by: Muhsin Keloth --- app/finders/email_channel_finder.rb | 59 ++++++++++--- config/installation_config.yml | 4 + .../super_admin/app_configs_controller.rb | 2 +- spec/finders/email_channel_finder_spec.rb | 83 +++++++++++++++++++ spec/mailboxes/application_mailbox_spec.rb | 14 ++++ spec/mailboxes/support_mailbox_spec.rb | 14 ++++ 6 files changed, 163 insertions(+), 13 deletions(-) diff --git a/app/finders/email_channel_finder.rb b/app/finders/email_channel_finder.rb index 41cd8e910..1b6d6f844 100644 --- a/app/finders/email_channel_finder.rb +++ b/app/finders/email_channel_finder.rb @@ -6,19 +6,54 @@ class EmailChannelFinder end def perform - channel = nil - - recipient_mails.each do |email| - normalized_email = normalize_email_with_plus_addressing(email) - channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) - - break if channel.present? - end - channel + channel_from_primary_recipients || channel_from_bcc_recipients end - def recipient_mails - recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)] - recipient_addresses.flatten.compact + private + + def channel_from_primary_recipients + primary_recipient_emails.each do |email| + channel = channel_from_email(email) + return channel if channel.present? + end + + nil + end + + def channel_from_bcc_recipients + bcc_recipient_emails.each do |email| + channel = channel_from_email(email) + + # Skip if BCC processing is disabled for this account + next if channel && !allow_bcc_processing?(channel.account_id) + + return channel if channel.present? + end + + nil + end + + def primary_recipient_emails + (@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact + end + + def bcc_recipient_emails + @email_object.bcc.to_a.flatten.compact + end + + def channel_from_email(email) + normalized_email = normalize_email_with_plus_addressing(email) + Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email) + end + + def bcc_processing_skipped_accounts + config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '') + return [] if config_value.blank? + + config_value.split(',').map(&:to_i) + end + + def allow_bcc_processing?(account_id) + bcc_processing_skipped_accounts.exclude?(account_id) end end diff --git a/config/installation_config.yml b/config/installation_config.yml index db907b7ad..9eb6af14f 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -236,6 +236,10 @@ display_title: 'Blocked Email Domains' description: 'Add a domain per line to block them from signing up, accepts Regex' type: code +- name: SKIP_INCOMING_BCC_PROCESSING + value: + display_title: 'Skip BCC Processing For' + description: 'Comma-separated list of account IDs that should be skipped from incoming BCC processing' - name: INACTIVE_WHATSAPP_NUMBERS value: '' display_title: 'Inactive WhatsApp Numbers' diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index 5e70d2d79..934462b93 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -33,7 +33,7 @@ module Enterprise::SuperAdmin::AppConfigsController def internal_config_options %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS - CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL + SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID] end diff --git a/spec/finders/email_channel_finder_spec.rb b/spec/finders/email_channel_finder_spec.rb index fe57dec0b..d56d97008 100644 --- a/spec/finders/email_channel_finder_spec.rb +++ b/spec/finders/email_channel_finder_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe EmailChannelFinder do include ActionMailbox::TestHelper + let!(:channel_email) { create(:channel_email) } describe '#perform' do @@ -48,6 +49,75 @@ describe EmailChannelFinder do expect(channel).to eq(channel_email) end + it 'skip bcc email when account is configured to skip BCC processing' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['bcc'] = 'test@example.com' + + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return(channel_email.account_id.to_s) + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to be_nil + end + + it 'skip bcc email when account is in multiple account ids config' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['bcc'] = 'test@example.com' + + # Include this account along with other account IDs + other_account_ids = [123, 456, channel_email.account_id, 789] + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return(other_account_ids.join(',')) + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to be_nil + end + + it 'process bcc email when account is not in skip config' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['bcc'] = 'test@example.com' + + # Configure other account IDs but not this one + other_account_ids = [123, 456, 789] + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return(other_account_ids.join(',')) + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to eq(channel_email) + end + + it 'process bcc email when skip config is empty' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['bcc'] = 'test@example.com' + + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return('') + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to eq(channel_email) + end + + it 'process bcc email when skip config is nil' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['bcc'] = 'test@example.com' + + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return(nil) + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to eq(channel_email) + end + it 'return channel with X-Original-To email' do channel_email.update(email: 'test@example.com') reply_mail.mail['to'] = nil @@ -55,6 +125,19 @@ describe EmailChannelFinder do channel = described_class.new(reply_mail.mail).perform expect(channel).to eq(channel_email) end + + it 'process X-Original-To email even when account is configured to skip BCC processing' do + channel_email.update(email: 'test@example.com') + reply_mail.mail['to'] = nil + reply_mail.mail['X-Original-To'] = 'test@example.com' + + allow(GlobalConfigService).to receive(:load) + .with('SKIP_INCOMING_BCC_PROCESSING', '') + .and_return(channel_email.account_id.to_s) + + channel = described_class.new(reply_mail.mail).perform + expect(channel).to eq(channel_email) + end end end end diff --git a/spec/mailboxes/application_mailbox_spec.rb b/spec/mailboxes/application_mailbox_spec.rb index f4f28d811..33bbf9de8 100644 --- a/spec/mailboxes/application_mailbox_spec.rb +++ b/spec/mailboxes/application_mailbox_spec.rb @@ -66,6 +66,20 @@ RSpec.describe ApplicationMailbox do expect(dbl).to receive(:perform_processing).and_return(true) described_class.route reply_cc_mail end + + it 'skips routing when BCC processing is disabled for account' do + allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(channel_email.account_id.to_s) + + # Create a BCC-only email scenario + bcc_mail = create_inbound_email_from_fixture('support.eml') + bcc_mail.mail['to'] = nil + bcc_mail.mail['bcc'] = 'care@example.com' + + channel_email.update(email: 'care@example.com') + + expect(DefaultMailbox).to receive(:new).and_return(double.tap { |d| expect(d).to receive(:perform_processing) }) + described_class.route bcc_mail + end end describe 'Invalid Mail To Address' do diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb index f6964285d..0dbfbbe3b 100644 --- a/spec/mailboxes/support_mailbox_spec.rb +++ b/spec/mailboxes/support_mailbox_spec.rb @@ -334,5 +334,19 @@ RSpec.describe SupportMailbox do expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html') end end + + describe 'when BCC processing is disabled for account' do + before do + allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s) + end + + it 'does not process BCC-only emails' do + bcc_mail = create_inbound_email_from_fixture('support.eml') + bcc_mail.mail['to'] = nil + bcc_mail.mail['bcc'] = 'care@example.com' + + expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found') + end + end end end From 8162473eb6c2d4895d75bfd65e92b46587e61b48 Mon Sep 17 00:00:00 2001 From: Honza Sterba Date: Mon, 22 Sep 2025 15:29:30 +0200 Subject: [PATCH 12/13] fix: Contact search by phone number (#10386) # Pull Request Template ## Description when filtering contacts by phone number a + is always added to the begining of the query, this means that the filtering breaks if the complete phone number with international code and + is entered ## Type of change Please delete options that are not relevant. - [X] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Updated automated tests Tested manually with contact filtering UI ## Checklist: - [X] My code follows the style guidelines of this project - [X] I have performed a self-review of my code - [X] I have commented on my code, particularly in hard-to-understand areas - [X] I have made corresponding changes to the documentation - [X] My changes generate no new warnings - [X] I have added tests that prove my fix is effective or that my feature works - [X] New and existing unit tests pass locally with my changes - [X] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- app/services/contacts/filter_service.rb | 2 +- spec/services/contacts/filter_service_spec.rb | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/services/contacts/filter_service.rb b/app/services/contacts/filter_service.rb index 7f2d6a0b8..9d017ea75 100644 --- a/app/services/contacts/filter_service.rb +++ b/app/services/contacts/filter_service.rb @@ -21,7 +21,7 @@ class Contacts::FilterService < FilterService def filter_values(query_hash) current_val = query_hash['values'][0] if query_hash['attribute_key'] == 'phone_number' - "+#{current_val}" + "+#{current_val&.delete('+')}" elsif query_hash['attribute_key'] == 'country_code' current_val.downcase else diff --git a/spec/services/contacts/filter_service_spec.rb b/spec/services/contacts/filter_service_spec.rb index 22882ae81..77d3a49f3 100644 --- a/spec/services/contacts/filter_service_spec.rb +++ b/spec/services/contacts/filter_service_spec.rb @@ -9,7 +9,7 @@ describe Contacts::FilterService do let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) } let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) } let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) } - let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) } + let!(:cs_contact) { create(:contact, :with_phone_number, account: account, additional_attributes: { 'country_code': 'cz' }) } before do create(:inbox_member, user: first_user, inbox: inbox) @@ -65,6 +65,42 @@ describe Contacts::FilterService do end end + context 'with standard attributes - phone' do + it 'filter contacts by name' do + params[:payload] = [ + { + attribute_key: 'phone_number', + filter_operator: 'equal_to', + values: [cs_contact.phone_number], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(account, first_user, params).perform + expect(result[:count]).to be 1 + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.name).to eq(cs_contact.name) + end + end + + context 'with standard attributes - phone (without +)' do + it 'filter contacts by name' do + params[:payload] = [ + { + attribute_key: 'phone_number', + filter_operator: 'equal_to', + values: [cs_contact.phone_number[1..]], + query_operator: nil + }.with_indifferent_access + ] + + result = filter_service.new(account, first_user, params).perform + expect(result[:count]).to be 1 + expect(result[:contacts].length).to be 1 + expect(result[:contacts].first.name).to eq(cs_contact.name) + end + end + context 'with standard attributes - blocked' do it 'filter contacts by blocked' do blocked_contact = create(:contact, account: account, blocked: true) From 46b75e1b035a81d6f8c5fc36f64df311a903394a Mon Sep 17 00:00:00 2001 From: Eduardo Policarpo <30879448+edupoli@users.noreply.github.com> Date: Tue, 23 Sep 2025 00:46:59 -0300 Subject: [PATCH 13/13] feat(whatsapp): add optional phone_number_id parameter to media retrieval API (#11823) ## Description This pull request introduces an optional parameter, `phone_number_id`, to the WhatsApp API call responsible for retrieving media. The addition of this parameter allows for greater flexibility when interacting with the WhatsApp API, as it can now accommodate scenarios where specifying a particular phone number ID is necessary. This change is backward compatible and does not affect existing functionality if the parameter is not provided. Fixes # (issue) ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? The changes were tested locally by invoking the WhatsApp media retrieval API with and without the `phone_number_id` parameter. Both scenarios were verified to ensure that: - When `phone_number_id` is provided, the API call includes the parameter and functions as expected. - When `phone_number_id` is omitted, the API call continues to work as before, maintaining backward compatibility. No errors or warnings were observed during testing, and all relevant unit tests passed successfully. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- ...incoming_message_whatsapp_cloud_service.rb | 8 +- .../providers/whatsapp_cloud_service.rb | 6 +- ...ing_message_whatsapp_cloud_service_spec.rb | 81 ++++++++++++++----- 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb index 97051a2cf..f8ac8c85a 100644 --- a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb +++ b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb @@ -9,7 +9,13 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB end def download_attachment_file(attachment_payload) - url_response = HTTParty.get(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers) + url_response = HTTParty.get( + inbox.channel.media_url( + attachment_payload[:id], + inbox.channel.provider_config['phone_number_id'] + ), + headers: inbox.channel.api_headers + ) # This url response will be failure if the access token has expired. inbox.channel.authorization_error! if url_response.unauthorized? Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success? diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 124e1e5d3..1968693ff 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -62,8 +62,10 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi { 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' } end - def media_url(media_id) - "#{api_base_path}/v13.0/#{media_id}" + def media_url(media_id, phone_number_id = nil) + url = "#{api_base_path}/v13.0/#{media_id}" + url += "?phone_number_id=#{phone_number_id}" if phone_number_id + url end def api_base_path diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index b222354a4..b162250bf 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -29,32 +29,23 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do context 'when valid attachment message params' do it 'creates appropriate conversations, message and contacts' do - stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return( - status: 200, - body: { - messaging_product: 'whatsapp', - url: 'https://chatwoot-assets.local/sample.png', - mime_type: 'image/jpeg', - sha256: 'sha256', - file_size: 'SIZE', - id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683' - }.to_json, - headers: { 'content-type' => 'application/json' } - ) - stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return( - status: 200, - body: File.read('spec/assets/sample.png') - ) - + stub_media_url_request + stub_sample_png_request described_class.new(inbox: whatsapp_channel.inbox, params: params).perform - expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) - expect(Contact.all.first.name).to eq('Sojan Jose') - expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') - expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true + expect_conversation_created + expect_contact_name + expect_message_content + expect_message_has_attachment end it 'increments reauthorization count if fetching attachment fails' do - stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return( + stub_request( + :get, + whatsapp_channel.media_url( + 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', + whatsapp_channel.provider_config['phone_number_id'] + ) + ).to_return( status: 401 ) @@ -115,4 +106,50 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do end end end + + # Métodos auxiliares para reduzir o tamanho do exemplo + + def stub_media_url_request + stub_request( + :get, + whatsapp_channel.media_url( + 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', + whatsapp_channel.provider_config['phone_number_id'] + ) + ).to_return( + status: 200, + body: { + messaging_product: 'whatsapp', + url: 'https://chatwoot-assets.local/sample.png', + mime_type: 'image/jpeg', + sha256: 'sha256', + file_size: 'SIZE', + id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683' + }.to_json, + headers: { 'content-type' => 'application/json' } + ) + end + + def stub_sample_png_request + stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return( + status: 200, + body: File.read('spec/assets/sample.png') + ) + end + + def expect_conversation_created + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + end + + def expect_contact_name + expect(Contact.all.first.name).to eq('Sojan Jose') + end + + def expect_message_content + expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') + end + + def expect_message_has_attachment + expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true + end end