From a44cb2c738bef69b85824e32b05acefff5e936a5 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 25 Feb 2026 01:56:51 -0800 Subject: [PATCH] feat(inbox): Enable conversation continuity for social channels (#11079) ## Summary This PR enables and surfaces **conversation workflow** for social-style channels that should support either: - `Create new conversations` after resolve, or - `Reopen same conversation` ## What is included - Adds the conversation workflow setting UI as card-based options in Inbox Settings. - Expands channel availability in settings to include channels like: - Telegram - TikTok - Instagram - Line - WhatsApp - Facebook - Updates conversation selection behavior for Line incoming messages to respect the workflow (reopen vs create-new-after-resolved). - Updates TikTok conversation selection behavior to respect the workflow (reopen vs create-new-after-resolved). - Keeps email behavior unchanged (always starts a new thread). Fixes: https://github.com/chatwoot/chatwoot/issues/8426 ## Screenshot pr11079-workflow-sender-clear-tight ## How To Reproduce 1. Open `Settings -> Inboxes -> -> Settings`. 2. Verify **Conversation workflow** is visible with the two card options. 3. Toggle between both options and save. 4. For Line and TikTok, verify resolved-conversation behavior follows the selected workflow. ## Testing - `RAILS_ENV=test bundle exec rspec spec/builders/messages/instagram/message_builder_spec.rb:213 spec/builders/messages/instagram/message_builder_spec.rb:255 spec/builders/messages/instagram/messenger/message_builder_spec.rb:228 spec/builders/messages/instagram/messenger/message_builder_spec.rb:293 spec/services/tiktok/message_service_spec.rb` - Result: `16 examples, 0 failures` ## Follow-up - Migrate Website Live Chat workflow settings into this same conversation-workflow settings model. - Add Voice channel support for this workflow setting. --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../story => radioCard}/RadioCard.story.vue | 2 +- .../components => radioCard}/RadioCard.vue | 0 .../dashboard/i18n/locale/en/inboxMgmt.json | 10 +- .../components/AgentAssignmentPolicyForm.vue | 2 +- .../dashboard/settings/inbox/Settings.vue | 36 +++--- .../LockToSingleConversationPreview.vue | 40 +++++++ .../components/SenderNameExamplePreview.vue | 2 +- app/services/line/incoming_message_service.rb | 7 +- app/services/tiktok/message_service.rb | 7 +- app/services/tiktok/messaging_helpers.rb | 10 +- .../tiktok/authorizations_controller_spec.rb | 34 +++--- .../line/incoming_message_service_spec.rb | 106 ++++++++++++++++++ .../telegram/incoming_message_service_spec.rb | 88 +++++++++++++++ spec/services/tiktok/message_service_spec.rb | 47 ++++++++ .../tiktok/read_status_service_spec.rb | 26 +++++ 15 files changed, 378 insertions(+), 39 deletions(-) rename app/javascript/dashboard/components-next/{AssignmentPolicy/components/story => radioCard}/RadioCard.story.vue (97%) rename app/javascript/dashboard/components-next/{AssignmentPolicy/components => radioCard}/RadioCard.vue (100%) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/components/LockToSingleConversationPreview.vue diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue similarity index 97% rename from app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue rename to app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue index df1f8655c..636ded048 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/RadioCard.story.vue +++ b/app/javascript/dashboard/components-next/radioCard/RadioCard.story.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue index 249c53abb..abd2552bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vue @@ -2,7 +2,7 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Avatar from 'next/avatar/Avatar.vue'; -import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue'; +import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; const props = defineProps({ senderNameType: { diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb index 6a1192d02..b30eacd7b 100644 --- a/app/services/line/incoming_message_service.rb +++ b/app/services/line/incoming_message_service.rb @@ -145,7 +145,12 @@ class Line::IncomingMessageService end def set_conversation - @conversation = @contact_inbox.conversations.first + # if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved + @conversation = if @inbox.lock_to_single_conversation + @contact_inbox.conversations.last + else + @contact_inbox.conversations.where.not(status: :resolved).last + end return if @conversation @conversation = ::Conversation.create!(conversation_params) diff --git a/app/services/tiktok/message_service.rb b/app/services/tiktok/message_service.rb index fcd613ec2..7acbb9486 100644 --- a/app/services/tiktok/message_service.rb +++ b/app/services/tiktok/message_service.rb @@ -23,7 +23,12 @@ class Tiktok::MessageService end def conversation - @conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id) + @conversation ||= if channel.inbox.lock_to_single_conversation + contact_inbox.conversations.order(created_at: :desc).first + else + contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first + end + @conversation ||= create_conversation(channel, contact_inbox, tt_conversation_id) end def create_message diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb index 71c417cd7..0f9d9e0b3 100644 --- a/app/services/tiktok/messaging_helpers.rb +++ b/app/services/tiktok/messaging_helpers.rb @@ -27,7 +27,15 @@ module Tiktok::MessagingHelpers end def find_conversation(channel, tt_conversation_id) - channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first + contact_inbox = channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id) + return if contact_inbox.blank? + + if channel.inbox.lock_to_single_conversation + contact_inbox.conversations.order(created_at: :desc).first + else + contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first || + contact_inbox.conversations.order(created_at: :desc).first + end end def create_conversation(channel, contact_inbox, tt_conversation_id) diff --git a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb index 54724aba0..dab4dfad3 100644 --- a/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/tiktok/authorizations_controller_spec.rb @@ -32,23 +32,25 @@ RSpec.describe 'TikTok Authorization API', type: :request do end it 'creates a new authorization and returns the redirect url' do - with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do - post "/api/v1/accounts/#{account.id}/tiktok/authorization", - headers: administrator.create_new_auth_token, - as: :json + travel_to Time.zone.parse('2025-01-01 00:00:00 UTC') do + with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do + post "/api/v1/accounts/#{account.id}/tiktok/authorization", + headers: administrator.create_new_auth_token, + as: :json + end + + expect(response).to have_http_status(:success) + expect(response.parsed_body['success']).to be true + + helper = Class.new do + include Tiktok::IntegrationHelper + end.new + + expected_state = helper.generate_tiktok_token(account.id) + expected_url = Tiktok::AuthClient.authorize_url(state: expected_state) + + expect(response.parsed_body['url']).to eq(expected_url) end - - expect(response).to have_http_status(:success) - expect(response.parsed_body['success']).to be true - - helper = Class.new do - include Tiktok::IntegrationHelper - end.new - - expected_state = helper.generate_tiktok_token(account.id) - expected_url = Tiktok::AuthClient.authorize_url(state: expected_state) - - expect(response.parsed_body['url']).to eq(expected_url) end end end diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index a7805ce9b..997777a43 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -405,5 +405,111 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv') end end + + context 'when lock_to_single_conversation is false' do + before do + line_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' 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) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(line_channel.inbox.conversations.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.first.content).to eq('Second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' 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) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and an unresolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + line_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' 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) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + + # Create a contact and a resolved conversation + described_class.new(inbox: line_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = line_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = params.deep_dup + new_params[:events][0][:message][:id] = '325709' + new_params[:events][0][:message][:text] = 'Second message' + + described_class.new(inbox: line_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(line_channel.inbox.conversations.count).to eq(1) + expect(line_channel.inbox.conversations.last.messages.count).to eq(2) + expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message') + end + end end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 528161afe..b81b18756 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -410,6 +410,94 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact') end end + + context 'when lock_to_single_conversation is false' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: false) + end + + it 'creates a new conversation when all previous conversations are resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should create a new conversation + expect(telegram_channel.inbox.conversations.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('second message') + end + + it 'uses the existing conversation when there is an unresolved conversation' do + # Create a contact and an unresolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end + + context 'when lock_to_single_conversation is true' do + before do + telegram_channel.inbox.update(lock_to_single_conversation: true) + end + + it 'uses the existing conversation even when it is resolved' do + # Create a contact and a resolved conversation + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { 'text' => 'first message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: params).perform + + # Mark the conversation as resolved + conversation = telegram_channel.inbox.conversations.last + conversation.update(status: :resolved) + + # Send a new message + new_params = { + 'update_id' => 2_342_342_343_243, + 'message' => { 'text' => 'second message' }.merge(message_params) + }.with_indifferent_access + + described_class.new(inbox: telegram_channel.inbox, params: new_params).perform + + # Should use the same conversation + expect(telegram_channel.inbox.conversations.count).to eq(1) + expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2) + expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message') + end + end end context 'when lock to single conversation is enabled' do diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb index fbef64336..ee7b113ac 100644 --- a/spec/services/tiktok/message_service_spec.rb +++ b/spec/services/tiktok/message_service_spec.rb @@ -6,8 +6,29 @@ RSpec.describe Tiktok::MessageService do let(:inbox) { channel.inbox } let(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') } + let(:text_content) do + { + type: 'text', + message_id: 'tt-msg-lock', + timestamp: 1_700_000_000_000, + conversation_id: 'tt-conv-1', + text: { body: 'Hello from TikTok' }, + from: 'Alice', + from_user: { id: 'user-1' }, + to: 'Biz', + to_user: { id: 'biz-123' } + }.deep_symbolize_keys + end describe '#perform' do + subject(:perform_text_message) do + service = described_class.new(channel: channel, content: current_content) + allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) + service.perform + end + + let(:current_content) { text_content } + it 'creates an incoming text message' do content = { type: 'text', @@ -113,5 +134,31 @@ RSpec.describe Tiktok::MessageService do ensure tempfile.close! end + + context 'when lock_to_single_conversation is enabled' do + it 'reuses the last resolved conversation' do + inbox.update!(lock_to_single_conversation: true) + resolved_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(1) + expect(resolved_conversation.reload.messages.last.content).to eq('Hello from TikTok') + end + end + + context 'when lock_to_single_conversation is disabled' do + let(:current_content) { text_content.merge(message_id: 'tt-msg-lock-2') } + + it 'creates a new conversation if the previous one is resolved' do + inbox.update!(lock_to_single_conversation: false) + create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved) + + perform_text_message + + expect(inbox.conversations.count).to eq(2) + expect(inbox.conversations.last.messages.last.content).to eq('Hello from TikTok') + end + end end end diff --git a/spec/services/tiktok/read_status_service_spec.rb b/spec/services/tiktok/read_status_service_spec.rb index f0ec116ff..3c44cc738 100644 --- a/spec/services/tiktok/read_status_service_spec.rb +++ b/spec/services/tiktok/read_status_service_spec.rb @@ -31,5 +31,31 @@ RSpec.describe Tiktok::ReadStatusService do expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time)) end + + it 'updates the latest active conversation when lock_to_single_conversation is disabled' do + allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later) + + inbox.update!(lock_to_single_conversation: false) + conversation.update!(status: :resolved) + active_conversation = create( + :conversation, + account: account, + inbox: inbox, + contact: contact, + contact_inbox: contact_inbox, + status: :open, + additional_attributes: { conversation_id: 'tt-conv-1' } + ) + + content = { + conversation_id: 'tt-conv-1', + read: { last_read_timestamp: 1_700_000_000_000 }, + from_user: { id: 'user-1' } + }.deep_symbolize_keys + + described_class.new(channel: channel, content: content).perform + + expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(active_conversation.id, kind_of(Time)) + end end end