From 8d48e05283b813c5ed0429455ad8f41a8e1b77ed Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 2 Mar 2026 13:12:42 +0530 Subject: [PATCH 01/37] 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 02/37] 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 03/37] 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 04/37] 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, + }, +});