From 64790ea204133d573b441a9456227caaf0881a0c Mon Sep 17 00:00:00 2001 From: Pranav Date: Fri, 1 May 2026 10:53:01 -0700 Subject: [PATCH 1/6] fix: Redirect to the conversation URL if custom_view is not available (#14340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent shares a conversation link copied from a custom view (e.g. /custom_view/{id}/conversations/{id}), the link previously broke for recipients who didn't have access to that custom view. The conversation now loads regardless — if the custom view isn't available to the recipient, they're redirected to the direct conversation URL. ### How to reproduce 1. As Agent A, open a conversation from inside a personal custom view and copy the URL from the address bar. 2. Share the URL with Agent B who does not have access to that custom view. 3. Before this fix, the link failed to load the conversation. After this fix, Agent B lands on the conversation via the direct URL. ### What changed - Added a beforeEnter guard on the conversations_through_folders route. It checks the user's available conversation custom views (fetching them on demand for deep links), and if the foldersId in the URL isn't among them, redirects to the inbox_conversation route with the same conversation_id. --------- Co-authored-by: iamsivin --- .../conversation/conversation.routes.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js index 241c70f45..d58617f30 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js +++ b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js @@ -1,5 +1,6 @@ /* eslint arrow-body-style: 0 */ import { frontendURL } from '../../../helper/URLHelper'; +import store from '../../../store'; import ConversationView from './ConversationView.vue'; const CONVERSATION_PERMISSIONS = [ @@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [ 'conversation_participating_manage', ]; +const isFolderAvailable = async folderId => { + let folders = store.getters['customViews/getConversationCustomViews']; + if (!folders.length) { + await store.dispatch('customViews/get', 'conversation'); + folders = store.getters['customViews/getConversationCustomViews']; + } + return folders.some(folder => folder.id === Number(folderId)); +}; + +const redirectFolderListIfUnavailable = async (to, _from, next) => { + if (await isFolderAvailable(to.params.id)) { + next(); + return; + } + next({ name: 'home', params: { accountId: to.params.accountId } }); +}; + +const redirectFolderConversationIfUnavailable = async (to, _from, next) => { + if (await isFolderAvailable(to.params.id)) { + next(); + return; + } + next({ + name: 'inbox_conversation', + params: { + accountId: to.params.accountId, + conversation_id: to.params.conversation_id, + }, + }); +}; + export default { routes: [ { @@ -113,6 +145,7 @@ export default { meta: { permissions: CONVERSATION_PERMISSIONS, }, + beforeEnter: redirectFolderListIfUnavailable, component: ConversationView, props: route => ({ foldersId: route.params.id }), }, @@ -125,6 +158,7 @@ export default { permissions: CONVERSATION_PERMISSIONS, }, component: ConversationView, + beforeEnter: redirectFolderConversationIfUnavailable, props: route => ({ conversationId: route.params.conversation_id, foldersId: route.params.id, From 28ec1794f436821f1dd8c59096a47bf834764591 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 4 May 2026 12:44:19 +0700 Subject: [PATCH 2/6] feat(voice): add WhatsApp Cloud Calling provider methods (#14312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Meta WhatsApp Cloud API surface needed for browser-based calling. This is the second slice of the WhatsApp calling feature, sitting on top of `feat/voice-call-model-wiring` and consumed by later PRs (incoming-webhook pipeline, call service, frontend). This PR ships only the provider-level HTTP wrapper and one error class. It is feature-flag-free and does not change any user-visible behaviour on its own — without later PRs, no caller invokes these methods. ## Linear - https://linear.app/chatwoot/issue/PLA-148/pr-2-meta-cloud-api-provider-methods ## What changed - Add `Whatsapp::Providers::WhatsappCloudCallMethods` (`enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb`) wrapping six Meta endpoints: - `pre_accept_call`, `accept_call`, `reject_call`, `terminate_call` — `POST /{phone_id}/calls` with the relevant action payload. - `send_call_permission_request` — `POST /{phone_id}/messages` interactive `call_permission_request`. - `initiate_call` — `POST /{phone_id}/calls` with `audio`/`offer` session. - Prepend the module into `Whatsapp::Providers::WhatsappCloudService` only if defined, so OSS continues to work without the enterprise overlay. - Add `Voice::CallErrors::NoCallPermission` (`enterprise/lib/voice/call_errors.rb`) — raised when Meta returns error code `138006` from `initiate_call`. The remaining call-service errors (`NotRinging`, `AlreadyAccepted`, `CallFailed`) will land with PR-4. ## How to test There is no UI in this PR. Smoke-test from a Rails console with a WhatsApp inbox configured for calling: ```ruby inbox = Inbox.find() svc = inbox.channel.provider_service svc.respond_to?(:initiate_call) # => true svc.respond_to?(:send_call_permission_request) # => true # Optional live calls (require a real phone + Meta call-permission opt-in): svc.send_call_permission_request('15551234567') svc.initiate_call('15551234567', '') ``` Failure path: `initiate_call` against a contact who has not granted call permission should raise `Voice::CallErrors::NoCallPermission` with Meta's user-facing message. --- .../providers/whatsapp_cloud_service.rb | 2 + config/locales/en.yml | 1 + .../providers/whatsapp_cloud_service.rb | 85 +++++++++++++++++++ enterprise/lib/voice/call_errors.rb | 11 +++ .../providers/whatsapp_cloud_service_spec.rb | 69 +++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb create mode 100644 enterprise/lib/voice/call_errors.rb create mode 100644 spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 5b4c26196..225242fc5 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -205,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi process_response(response, message) end end + +Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService') diff --git a/config/locales/en.yml b/config/locales/en.yml index e4b348664..8a53e12c5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -245,6 +245,7 @@ en: deleted: This message was deleted whatsapp: list_button_label: 'Choose an item' + call_permission_request_body: 'We would like to call you regarding your conversation.' delivery_status: error_code: 'Error code: %{error_code}' activity: diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb new file mode 100644 index 000000000..552c3c389 --- /dev/null +++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb @@ -0,0 +1,85 @@ +module Enterprise::Whatsapp::Providers::WhatsappCloudService + def pre_accept_call(call_id, sdp_answer) + call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer)) + end + + def accept_call(call_id, sdp_answer) + call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer)) + end + + def reject_call(call_id) + call_api('reject_call', call_action_body(call_id, 'reject')) + end + + def terminate_call(call_id) + call_api('terminate_call', call_action_body(call_id, 'terminate')) + end + + def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body')) + response = HTTParty.post( + "#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text) + ) + + unless response.success? + Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}" + return nil + end + + response.parsed_response + end + + def initiate_call(to_phone_number, sdp_offer) + response = HTTParty.post( + "#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer) + ) + process_initiate_call_response(response) + end + + private + + def call_action_body(call_id, action, sdp_answer = nil) + body = { messaging_product: 'whatsapp', call_id: call_id, action: action } + body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer + body + end + + def call_api(action_name, body) + url = "#{phone_id_path}/calls" + Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}" + response = HTTParty.post(url, headers: api_headers, body: body.to_json) + Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success? + response.success? + end + + def permission_request_body(to_phone_number, body_text) + { + messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number, + type: 'interactive', + interactive: { + type: 'call_permission_request', + action: { name: 'call_permission_request' }, + body: { text: body_text } + } + }.to_json + end + + def initiate_call_body(to_phone_number, sdp_offer) + { + messaging_product: 'whatsapp', to: to_phone_number, type: 'audio', + session: { sdp: sdp_offer, sdp_type: 'offer' } + }.to_json + end + + def process_initiate_call_response(response) + return response.parsed_response if response.success? + + Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}" + parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {} + error_code = parsed.dig('error', 'code') + error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call' + + raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE + + raise Voice::CallErrors::CallFailed, error_msg + end +end diff --git a/enterprise/lib/voice/call_errors.rb b/enterprise/lib/voice/call_errors.rb new file mode 100644 index 000000000..6b53ddbdc --- /dev/null +++ b/enterprise/lib/voice/call_errors.rb @@ -0,0 +1,11 @@ +module Voice::CallErrors + # Meta WhatsApp Cloud Calling error code returned when the contact has not + # granted call permission yet. See `initiate_call` in + # Enterprise::Whatsapp::Providers::WhatsappCloudService. + NO_CALL_PERMISSION_CODE = 138_006 + + class NoCallPermission < StandardError; end + class CallFailed < StandardError; end + class NotRinging < StandardError; end + class AlreadyAccepted < StandardError; end +end diff --git a/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb new file mode 100644 index 000000000..9c90d9ccb --- /dev/null +++ b/spec/enterprise/services/enterprise/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe Whatsapp::Providers::WhatsappCloudService do + subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) } + + let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) } + let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' } + let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' } + let(:headers) { { 'Content-Type' => 'application/json' } } + + before { stub_request(:get, /message_templates/) } + + describe 'call action methods' do + it 'POSTs the action body with the SDP answer and returns true on success' do + stub_request(:post, calls_url) + .with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept', + session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json) + .to_return(status: 200, body: '{}', headers: headers) + + expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true + end + + it 'returns false when Meta responds with a non-success status' do + stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers) + + expect(service.reject_call('WACALL')).to be false + end + end + + describe '#send_call_permission_request' do + it 'returns the parsed body on success' do + stub_request(:post, messages_url) + .with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive')) + .to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers) + + expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }]) + end + end + + describe '#initiate_call' do + it 'returns the parsed body on success' do + stub_request(:post, calls_url) + .with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio', + session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json) + .to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers) + + expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }]) + end + + it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do + stub_request(:post, calls_url).to_return( + status: 400, + body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json, + headers: headers + ) + + expect { service.initiate_call('15551234567', 'sdp_offer') } + .to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission') + end + + it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do + stub_request(:post, calls_url).to_return(status: 502, body: '502 Bad Gateway', + headers: { 'Content-Type' => 'text/html' }) + + expect { service.initiate_call('15551234567', 'sdp_offer') } + .to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call') + end + end +end From 2a30e7b0824bc879a37e696fbf52dd13f612ec14 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 4 May 2026 13:25:40 +0530 Subject: [PATCH 3/6] fix: render agent variables in automation messages (#14338) # Pull Request Template ## Description This PR fixes an issue where agent variables like `{{agent.name}}`,`{{agent.first_name}}`, `{{agent.last_name}}`, and `{{agent.email}}` were not rendering in automation messages. In automation, these either showed blank or returned `Liquid error: internal`, while the same variables worked fine in macros. **Cause** Automation messages are created without a sender, so agent data was missing during variable rendering. This also caused errors in name handling, and `email` was not defined at all. **Solution** * Handle missing agent data safely to avoid errors * Add support for `{{agent.email}}` * Fallback to conversation assignee when sender is not present Fixes https://linear.app/chatwoot/issue/CW-6979/template-variables-not-working-in-automated-messages ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screenshots **Automation** image **Before** image **After** image ## 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 - [ ] 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 --- app/drops/user_drop.rb | 8 ++++++-- app/models/concerns/liquidable.rb | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb index 7cafea1bb..cf6f1b6a1 100644 --- a/app/drops/user_drop.rb +++ b/app/drops/user_drop.rb @@ -7,11 +7,15 @@ class UserDrop < BaseDrop @obj.try(:available_name) end + def email + @obj.try(:email) + end + def first_name - @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end def last_name - @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1 + @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 end end diff --git a/app/models/concerns/liquidable.rb b/app/models/concerns/liquidable.rb index 8a90f5f9f..8ef8064e7 100644 --- a/app/models/concerns/liquidable.rb +++ b/app/models/concerns/liquidable.rb @@ -11,7 +11,7 @@ module Liquidable def message_drops { 'contact' => ContactDrop.new(conversation.contact), - 'agent' => UserDrop.new(sender), + 'agent' => UserDrop.new(sender || conversation.assignee), 'conversation' => ConversationDrop.new(conversation), 'inbox' => InboxDrop.new(inbox), 'account' => AccountDrop.new(conversation.account) From a01adf860aa3d8ee2acd9e1be0ae4802c3e78fb2 Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 4 May 2026 00:56:28 -0700 Subject: [PATCH 4/6] fix: [CW-7001] Limit emails fetch (#14354) This PR limits IMAP email fetching to 500 messages per sync run to avoid expensive/long-running mailbox scans. It also filters out already-imported emails and Chatwoot-generated notification emails during the header fetch phase, before fetching full email bodies, reducing unnecessary IMAP work. Fixes #CW-7001 (issue) : https://linear.app/chatwoot/issue/CW-7001/emails-not-syncing --- app/services/imap/base_fetch_email_service.rb | 54 +++++++++++++------ .../services/imap/fetch_email_service_spec.rb | 29 ++++++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 09332092c..17114f516 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -1,6 +1,8 @@ require 'net/imap' class Imap::BaseFetchEmailService + MAX_MESSAGES_PER_SYNC = 500 + pattr_initialize [:channel!, :interval] def fetch_emails @@ -77,27 +79,49 @@ class Imap::BaseFetchEmailService Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}." message_ids_with_seq = [] - seq_nums.each_slice(10).each do |batch| - # Fetch only message-id only without mail body or contents. - batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]') - - # .fetch returns an array of Net::IMAP::FetchData or nil - # (instead of an empty array) if there is no matching message. - # Check - if batch_message_ids.blank? - Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}." - next - end - - batch_message_ids.each do |data| - message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id - message_ids_with_seq.push([data.seqno, message_id]) + seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch| + append_message_ids_for_batch(batch, message_ids_with_seq) + if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync." + break end end message_ids_with_seq end + def append_message_ids_for_batch(batch, message_ids_with_seq) + # Fetch only message-id only without mail body or contents. + batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]') + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages." + + # .fetch returns an array of Net::IMAP::FetchData or nil + # (instead of an empty array) if there is no matching message. + if batch_message_ids.blank? + Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}." + return + end + + batch_message_ids.each do |data| + entry = build_message_id_entry(data) + next if entry.nil? + + message_ids_with_seq.push(entry) + break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC + end + end + + def build_message_id_entry(data) + mail = build_mail_from_string(data.attr['BODY[HEADER]']) + return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot? + + message_id = mail.message_id + return nil if message_id.blank? + return nil if email_already_present?(channel, message_id) + + [data.seqno, message_id] + end + # Sends a SEARCH command to search the mailbox for messages that were # created between yesterday (or given date) and today and returns message sequence numbers. # Return diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb index 46336bf0f..2910f41fb 100644 --- a/spec/services/imap/fetch_email_service_spec.rb +++ b/spec/services/imap/fetch_email_service_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Imap::FetchEmailService do let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) } let(:imap) { instance_double(Net::IMAP) } let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read } + let(:eml_content_without_message_id) { eml_content_with_message_id.sub(/^Message-ID:.*\n/, '') } describe '#perform' do before do @@ -63,6 +64,34 @@ RSpec.describe Imap::FetchEmailService do expect(imap).not_to have_received(:fetch).with(1, 'RFC822') end end + + it 'does not count emails without message ids toward the sync limit' do + travel_to '26.10.2020 10:00'.to_datetime do + email_object = create_inbound_email_from_fixture('only_text.eml') + max_messages_per_sync = Imap::BaseFetchEmailService::MAX_MESSAGES_PER_SYNC + empty_message_id_seq_nums = (1..max_messages_per_sync).to_a + valid_message_seq_num = max_messages_per_sync + 1 + empty_message_id_headers = empty_message_id_seq_nums.map do |seq_num| + Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id) + end + valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id) + imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id) + + allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num]) + allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers) + allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header]) + allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail]) + allow(imap).to receive(:logout) + + result = described_class.new(channel: imap_email_channel).perform + + expect(result.length).to eq 1 + expect(result[0].message_id).to eq email_object.message_id + expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]') + expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]') + expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822') + end + end end end end From d00867d6368cf54f2593a70241d9b1d6e2a3edff Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 4 May 2026 13:37:25 +0530 Subject: [PATCH 5/6] fix: captain auto sync scheduler config (#14336) --- .../trigger_hourly_scheduled_items_job.rb | 7 ++ config/initializers/sidekiq.rb | 5 -- config/installation_config.yml | 6 ++ config/schedule.yml | 6 ++ .../captain/documents/schedule_syncs_job.rb | 3 +- .../trigger_hourly_scheduled_items_job.rb | 7 ++ enterprise/app/models/enterprise/account.rb | 31 ++++++-- enterprise/config/schedule.yml | 10 --- .../documents/schedule_syncs_job_spec.rb | 1 + spec/enterprise/models/account_spec.rb | 74 ++++++++++++------- 10 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 app/jobs/internal/trigger_hourly_scheduled_items_job.rb create mode 100644 enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb delete mode 100644 enterprise/config/schedule.yml diff --git a/app/jobs/internal/trigger_hourly_scheduled_items_job.rb b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb new file mode 100644 index 000000000..f870d742b --- /dev/null +++ b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb @@ -0,0 +1,7 @@ +class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform; end +end + +Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob') diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 60596156c..7b78b466a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do if File.exist?(schedule_file) && Sidekiq.server? schedule = YAML.load_file(schedule_file) - # Merge enterprise-only cron entries when running an enterprise build. - # Mirrors the conditional-load pattern already used for enterprise initializers. - enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml') - schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist? - # Cron entries removed from schedule.yml but possibly still in Redis # with source:'dynamic' (predating the source tag). load_from_hash! # only cleans up source:'schedule' entries, so these need explicit removal. diff --git a/config/installation_config.yml b/config/installation_config.yml index 884dd2e58..e374d0948 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -209,6 +209,12 @@ description: 'The limits for the Captain AI service for different plans' value: type: code +- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS + display_title: 'Captain Document Auto Sync Intervals' + description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.' + value: + locked: false + type: code # End of Captain Config # ------- Context.dev Config ------- # diff --git a/config/schedule.yml b/config/schedule.yml index f1054ad68..f5e335ba3 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -14,6 +14,12 @@ trigger_scheduled_items_job: class: 'TriggerScheduledItemsJob' queue: scheduled_jobs +# executed hourly for scheduled jobs that do not need minute-level cadence +trigger_hourly_scheduled_items_job: + cron: '0 * * * *' + class: 'Internal::TriggerHourlyScheduledItemsJob' + queue: scheduled_jobs + # executed At every minute.. trigger_imap_email_inboxes_job: cron: '*/1 * * * *' diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb index ab72e9b4a..3c2c55132 100644 --- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb +++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb @@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob def perform @remaining_global_capacity = GLOBAL_HOURLY_CAP + sync_intervals = Enterprise::Account.captain_document_sync_intervals stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 } Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account| @@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob next unless account.feature_enabled?('captain_document_auto_sync') stats[:accounts_enabled] += 1 - interval = account.captain_document_sync_interval + interval = account.captain_document_sync_interval(sync_intervals) next unless interval stats[:accounts_scheduled] += 1 diff --git a/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb new file mode 100644 index 000000000..9d3baa2d6 --- /dev/null +++ b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb @@ -0,0 +1,7 @@ +module Enterprise::Internal::TriggerHourlyScheduledItemsJob + def perform + super + + Captain::Documents::ScheduleSyncsJob.perform_later + end +end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index e8828cc4c..9b451a23c 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,10 +1,22 @@ module Enterprise::Account - CAPTAIN_SYNC_INTERVALS = { - 'hacker' => nil, - 'startups' => 7.days, - 'business' => 1.day, - 'enterprise' => 6.hours - }.freeze + class << self + def captain_document_sync_intervals + parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value) + end + + private + + def parse_captain_document_sync_intervals(configured_intervals) + return {} if configured_intervals.blank? + + parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals + return {} unless parsed_intervals.is_a?(Hash) + + parsed_intervals.transform_keys { |plan| plan.to_s.downcase } + rescue JSON::ParserError + {} + end + end # TODO: Remove this when we upgrade administrate gem to the latest version # this is a temporary method since current administrate doesn't support virtual attributes @@ -41,12 +53,15 @@ module Enterprise::Account custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save end - def captain_document_sync_interval + def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals) plan = custom_attributes['plan_name'] plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise? return nil if plan.blank? - CAPTAIN_SYNC_INTERVALS[plan.downcase] + interval_hours = sync_intervals[plan.downcase] + return nil unless interval_hours.is_a?(Integer) && interval_hours.positive? + + interval_hours.hours end def saml_enabled? diff --git a/enterprise/config/schedule.yml b/enterprise/config/schedule.yml deleted file mode 100644 index 05ea11e64..000000000 --- a/enterprise/config/schedule.yml +++ /dev/null @@ -1,10 +0,0 @@ -# Enterprise-only Sidekiq cron schedule. -# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true. -# Add cron entries here when the referenced job class lives under enterprise/. - -# Captain document auto-sync scheduler -# Runs hourly, finds due documents based on plan sync intervals -captain_documents_schedule_syncs_job: - cron: '0 * * * *' - class: 'Captain::Documents::ScheduleSyncsJob' - queue: scheduled_jobs diff --git a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb index e7a16db5b..66828ef9f 100644 --- a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb +++ b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do let(:assistant) { create(:captain_assistant, account: account) } before do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json) account.enable_features!('captain_document_auto_sync') clear_enqueued_jobs end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index 8183c76ff..c69a83256 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -225,46 +225,68 @@ RSpec.describe Account, type: :model do describe 'captain document sync cadence' do let(:account) { create(:account) } - it 'has no cadence on the hacker plan' do - account.update!(custom_attributes: { plan_name: 'hacker' }) - expect(account.captain_document_sync_interval).to be_nil - end - - it 'syncs weekly on the startups plan' do - account.update!(custom_attributes: { plan_name: 'startups' }) - expect(account.captain_document_sync_interval).to eq(7.days) - end - - it 'syncs daily on the business plan' do + it 'has no cadence when installation config is missing' do account.update!(custom_attributes: { plan_name: 'business' }) - expect(account.captain_document_sync_interval).to eq(1.day) - end - - it 'syncs every six hours on the enterprise plan' do - account.update!(custom_attributes: { plan_name: 'enterprise' }) - expect(account.captain_document_sync_interval).to eq(6.hours) - end - - it 'has no cadence when plan is missing' do - account.update!(custom_attributes: {}) expect(account.captain_document_sync_interval).to be_nil end - it 'has no cadence for unknown plans' do - account.update!(custom_attributes: { plan_name: 'mystery' }) - expect(account.captain_document_sync_interval).to be_nil + it 'uses configured plan intervals from installation config' do + intervals = { + business: 48, + enterprise: 24 + } + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json) + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to eq(2.days) end - it 'normalizes plan name casing' do + it 'normalizes configured plan name casing' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json) account.update!(custom_attributes: { plan_name: 'Business' }) + expect(account.captain_document_sync_interval).to eq(1.day) end - it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do + it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true) + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json) account.update!(custom_attributes: {}) + expect(account.captain_document_sync_interval).to eq(6.hours) end + + it 'allows installation config to disable a plan cadence' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json) + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to be_nil + end + + it 'has no cadence when installation config is invalid' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json') + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to be_nil + end + + it 'treats invalid plan interval values as disabled' do + intervals = { + business: false, + enterprise: { hours: 6 }, + startups: '168' + } + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json) + + account.update!(custom_attributes: { plan_name: 'business' }) + expect(account.captain_document_sync_interval).to be_nil + + account.update!(custom_attributes: { plan_name: 'enterprise' }) + expect(account.captain_document_sync_interval).to be_nil + + account.update!(custom_attributes: { plan_name: 'startups' }) + expect(account.captain_document_sync_interval).to be_nil + end end describe 'account deletion' do From ea8761099991507593dc8ec5e497379645c22900 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 4 May 2026 12:24:31 +0400 Subject: [PATCH 6/6] feat(voice): Join active call from the conversation bubble (#14343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can now click **Join call** directly on the incoming call bubble in the conversation timeline. If they refresh the page or miss the floating widget while a call is still ringing, the bubble becomes the recovery affordance — one click joins the conference, no need to wait for the next event. The button only appears when the call is still ringing, no other agent has claimed it, and the conversation is unassigned or assigned to the current agent (mirroring the floating widget's eligibility rules). It disappears as soon as anyone joins the call or it ends. Fixes https://linear.app/chatwoot/issue/PLA-117/ability-to-join-the-call-by-clicking-on-call-bubble-in-a-conversation ## How to test 1. Set up a Twilio voice inbox and trigger an inbound call to it. 2. As an agent who is eligible to answer (unassigned conversation, or assigned to you), open the conversation **without answering from the floating widget**. The bubble should show a teal **Join call** link under "Not answered yet". 3. Refresh the page mid-ring — the link should still be there. 4. Click **Join call** — you should be connected to the conference, the bubble should flip to "Call in progress / You answered", and the link should disappear. 5. As a second agent who is **not** eligible (conversation assigned to someone else), open the same conversation — the link should not appear. 6. Wait for the call to end — the bubble should show "Call ended" with no Join link. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../message/bubbles/VoiceCall.vue | 58 +++++++++++++++++-- .../i18n/locale/en/conversation.json | 3 +- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index f2383551c..229fbec03 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { useStore } from 'vuex'; import { useMessageContext } from '../provider.js'; import { VOICE_CALL_STATUS } from '../constants'; +import { useCallSession } from 'dashboard/composables/useCallSession'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -29,14 +30,16 @@ const BG_COLOR_MAP = { const { t } = useI18n(); const store = useStore(); -const { call, conversationId, currentUserId } = useMessageContext(); +const { call, conversationId, currentUserId, inboxId } = useMessageContext(); +const { joinCall, endCall, activeCall, hasActiveCall, isJoining } = + useCallSession(); const status = computed(() => call.value?.status); const isOutbound = computed(() => call.value?.direction === 'outgoing'); const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); -const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id); +const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value @@ -52,8 +55,7 @@ const conversationAssignee = computed(() => { return conversation?.meta?.assignee || null; }); const displayAgentName = computed(() => { - if (call.value?.accepted_by_agent_name) - return call.value.accepted_by_agent_name; + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; if (acceptedByAgentId.value) { const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); if (agent?.available_name) return agent.available_name; @@ -104,6 +106,45 @@ const iconName = computed(() => { }); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); + +const callSid = computed(() => call.value?.providerCallId); + +// Show "Join call" when the call is still ringing, no agent has claimed it, +// and the conversation is unassigned or assigned to the current user. Mirrors +// the eligibility used by FloatingCallWidget so the bubble can act as a +// recovery affordance after a refresh or missed widget. +const canJoinCall = computed(() => { + if (status.value !== VOICE_CALL_STATUS.RINGING) return false; + if (isOutbound.value) return false; + if (acceptedByAgentId.value) return false; + if (!callSid.value || !inboxId.value || !conversationId.value) return false; + // Suppress the button once this call is the local active session — the + // message status webhook may lag behind, so we can't rely on `status` alone + // to hide it after a successful join from this client. + if (hasActiveCall.value && activeCall.value?.callSid === callSid.value) + return false; + const assignee = conversationAssignee.value; + if (assignee?.id && assignee.id !== currentUserId.value) return false; + return true; +}); + +const handleJoinCall = async () => { + if (!canJoinCall.value || isJoining.value) return; + + if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) { + await endCall({ + conversationId: activeCall.value.conversationId, + inboxId: activeCall.value.inboxId, + callSid: activeCall.value.callSid, + }); + } + + await joinCall({ + conversationId: conversationId.value, + inboxId: inboxId.value, + callSid: callSid.value, + }); +};