From 0e07a27c743629e55d9fae61f1d941b15ba47b7a Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 8 Jul 2026 02:18:07 -0700 Subject: [PATCH 01/42] fix: enforce inbox limits at model level (#14949) Fixes https://linear.app/chatwoot/issue/CW-7559/inbox-limit-abuse ## Why The regular inbox API checked limits in the controller, but WhatsApp embedded signup creates inboxes through a service using `Inbox.create!`. That let Enterprise account inbox limits be skipped for embedded signup. ## What this change does - Adds an Inbox create-time validation hook in OSS and implements the limit check in the Enterprise Inbox module. - Removes the duplicate controller/helper limit check so the model is the single enforcement point. - Preserves the existing `402 Payment Required` API response for account inbox limit failures. - Keeps updates to existing inboxes allowed when an account is already at its inbox limit. ## Validation - `bundle exec rspec spec/controllers/api/v1/accounts/inboxes_controller_spec.rb spec/enterprise/models/inbox_spec.rb` --- .../api/v1/accounts/callbacks_controller.rb | 3 ++ .../channels/twilio_channels_controller.rb | 2 ++ .../api/v1/accounts/inboxes_controller.rb | 1 - .../whatsapp/authorizations_controller.rb | 6 ++-- .../concerns/request_exception_handler.rb | 5 +-- .../instagram/callbacks_controller.rb | 10 ++++++ .../tiktok/callbacks_controller.rb | 10 ++++++ app/helpers/api/v1/inboxes_helper.rb | 6 ---- .../app/models/enterprise/concerns/inbox.rb | 6 ++++ lib/custom_exceptions/inbox/limit_exceeded.rb | 15 ++++++++ .../v1/accounts/callbacks_controller_spec.rb | 34 +++++++++++++++++++ .../lib/captain/base_task_service_spec.rb | 23 ++++++------- .../conversation_completion_service_spec.rb | 10 ++++-- spec/enterprise/models/inbox_spec.rb | 23 +++++++++++++ .../audio_transcription_service_spec.rb | 8 ++++- spec/lib/captain/base_task_service_spec.rb | 5 ++- .../mappers/conversation_mapper_spec.rb | 1 + .../whatsapp/channel_creation_service_spec.rb | 11 ++++++ 18 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 lib/custom_exceptions/inbox/limit_exceeded.rb create mode 100644 spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 90cdf2418..08c0ffe43 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController page_access_token = params[:page_access_token] page_id = params[:page_id] inbox_name = params[:inbox_name] + ActiveRecord::Base.transaction do facebook_channel = Current.account.facebook_pages.create!( page_id: page_id, user_access_token: user_access_token, @@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) end + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception Rails.logger.error "Error in register_facebook_page: #{e.message}" diff --git a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb index f3b14d49f..1691b5489 100644 --- a/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb +++ b/app/controllers/api/v1/accounts/channels/twilio_channels_controller.rb @@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts: def create process_create + rescue CustomExceptions::Inbox::LimitExceeded => e + render_error_response(e) rescue StandardError => e render_could_not_create_error(e.message) end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..9f56c3817 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] - before_action :validate_limit, only: [:create] # we are already handling the authorization in fetch inbox before_action :check_authorization, except: [:show] diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index d52f396fc..db94113d9 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -8,8 +8,10 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: validate_embedded_signup_params! channel = process_embedded_signup render_success_response(channel.inbox) - rescue StandardError => e + rescue CustomExceptions::Inbox::LimitExceeded => e render_error_response(e) + rescue StandardError => e + render_embedded_signup_error(e) end private @@ -55,7 +57,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: render json: response end - def render_error_response(error) + def render_embedded_signup_error(error) Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}" Rails.logger.error error.backtrace.join("\n") render json: { diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 7f4e313b1..43d6edf1f 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -9,6 +9,7 @@ module RequestExceptionHandler included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response end private @@ -40,8 +41,8 @@ module RequestExceptionHandler render json: { error: message }, status: :not_found end - def render_could_not_create_error(message) - render json: { error: sanitized_error_message(message) }, status: :unprocessable_entity + def render_could_not_create_error(error) + render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity end def render_payment_required(message) diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb index cd317363c..e9065119f 100644 --- a/app/controllers/instagram/callbacks_controller.rb +++ b/app/controllers/instagram/callbacks_controller.rb @@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController end process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController redirect_to_error_page(error_info) end + def handle_limit_error(error) + redirect_to_error_page( + 'error_type' => error.class.name, + 'code' => Rack::Utils.status_code(error.http_status), + 'error_message' => error.message + ) + end + # Extract error details from the exception def extract_error_info(error) if error.is_a?(OAuth2::Error) diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index 20c0ee9c0..a39fec5ed 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController return handle_ungranted_scopes_error unless all_scopes_granted? process_successful_authorization + rescue CustomExceptions::Inbox::LimitExceeded => e + handle_limit_error(e) rescue StandardError => e handle_error(e) end @@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) end + def handle_limit_error(error) + redirect_to_error_page( + error_type: error.class.name, + code: Rack::Utils.status_code(error.http_status), + error_message: error.message + ) + end + # Handles the case when a user denies permissions or cancels the authorization flow def handle_authorization_error redirect_to_error_page( diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..6c64dd009 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -114,10 +114,4 @@ module Api::V1::InboxesHelper 'sms' => Current.account.sms_channels }[permitted_params[:channel][:type]] end - - def validate_limit - return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes] - - render_payment_required('Account limit exceeded. Upgrade to a higher plan') - end end diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb index bdcd0fd63..b327878b4 100644 --- a/enterprise/app/models/enterprise/concerns/inbox.rb +++ b/enterprise/app/models/enterprise/concerns/inbox.rb @@ -8,5 +8,11 @@ module Enterprise::Concerns::Inbox class_name: 'Captain::Assistant' has_many :inbox_capacity_limits, dependent: :destroy has_many :calls, dependent: :destroy_async + + before_create :ensure_create_permitted + end + + def ensure_create_permitted + raise CustomExceptions::Inbox::LimitExceeded.new({}) if account.inboxes.count >= account.usage_limits[:inboxes] end end diff --git a/lib/custom_exceptions/inbox/limit_exceeded.rb b/lib/custom_exceptions/inbox/limit_exceeded.rb new file mode 100644 index 000000000..9b5624929 --- /dev/null +++ b/lib/custom_exceptions/inbox/limit_exceeded.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CustomExceptions::Inbox::LimitExceeded < CustomExceptions::Base + def message + 'Account limit exceeded. Upgrade to a higher plan' + end + + def to_hash + { error: message } + end + + def http_status + :payment_required + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb new file mode 100644 index 000000000..33cc95fc1 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/callbacks_controller_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Enterprise Callbacks API', type: :request do + describe 'POST /api/v1/accounts/{account.id}/callbacks/register_facebook_page' do + let(:account) { create(:account, limits: { inboxes: 1 }) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:params) do + { + user_access_token: 'user-token', + page_access_token: 'page-token', + page_id: '12345', + inbox_name: 'Facebook Inbox' + } + end + + before do + create(:inbox, account: account) + end + + it 'returns payment required before creating a Facebook channel when account inbox limit is reached' do + expect do + post "/api/v1/accounts/#{account.id}/callbacks/register_facebook_page", + headers: admin.create_new_auth_token, + params: params, + as: :json + end.not_to change(Channel::FacebookPage, :count) + + expect(response).to have_http_status(:payment_required) + expect(response.parsed_body['error']).to eq('Account limit exceeded. Upgrade to a higher plan') + end + end +end diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb index fb970f726..9186874b7 100644 --- a/spec/enterprise/lib/captain/base_task_service_spec.rb +++ b/spec/enterprise/lib/captain/base_task_service_spec.rb @@ -5,6 +5,13 @@ RSpec.describe Captain::BaseTaskService, type: :model do let(:inbox) { create(:inbox, account: account) } let(:conversation) { create(:conversation, account: account, inbox: inbox) } let(:perform_result) { { message: 'Test response' } } + let(:exhausted_usage_limits) do + { + agents: ChatwootApp.max_limit, + inboxes: ChatwootApp.max_limit, + captain: { responses: { current_available: 0 } } + } + end # Create a concrete test service class with enterprise module prepended let(:test_service_class) do @@ -38,9 +45,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do context 'when usage limit is exceeded' do before do allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - allow(account).to receive(:usage_limits).and_return({ - captain: { responses: { current_available: 0 } } - }) + allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits) end it 'returns usage limit exceeded error' do @@ -125,9 +130,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do context 'when the captain_responses quota is exhausted on Cloud' do before do allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - allow(account).to receive(:usage_limits).and_return({ - captain: { responses: { current_available: 0 } } - }) + allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits) end it 'returns usage limit exceeded error for services that do not opt into BYOK' do @@ -162,9 +165,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do context 'when the captain_responses quota is exhausted on Cloud' do before do allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - allow(account).to receive(:usage_limits).and_return({ - captain: { responses: { current_available: 0 } } - }) + allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits) end it 'bypasses the 429 gate and returns the underlying result' do @@ -249,9 +250,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do context 'when the captain_responses quota is exhausted on Cloud' do before do allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - allow(account).to receive(:usage_limits).and_return({ - captain: { responses: { current_available: 0 } } - }) + allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits) end it 'bypasses the 429 gate and returns the underlying result' do diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 8b059acc3..80b9ab1d8 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -166,9 +166,13 @@ RSpec.describe Captain::ConversationCompletionService do before do allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - allow(account).to receive(:usage_limits).and_return({ - captain: { responses: { current_available: 0 } } - }) + allow(account).to receive(:usage_limits).and_return( + { + agents: ChatwootApp.max_limit, + inboxes: ChatwootApp.max_limit, + captain: { responses: { current_available: 0 } } + } + ) create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?') create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.') allow(mock_chat).to receive(:ask).and_return(mock_response) diff --git a/spec/enterprise/models/inbox_spec.rb b/spec/enterprise/models/inbox_spec.rb index cfcbdd573..1dce2833b 100644 --- a/spec/enterprise/models/inbox_spec.rb +++ b/spec/enterprise/models/inbox_spec.rb @@ -134,6 +134,29 @@ RSpec.describe Inbox do end end + describe 'validations' do + describe 'account inbox limit' do + let(:account) { create(:account, limits: { inboxes: 1 }) } + + before do + create(:inbox, account: account) + end + + it 'prevents saving inboxes beyond the account limit' do + new_inbox = build(:inbox, account: account) + + expect { new_inbox.save! }.to raise_error(CustomExceptions::Inbox::LimitExceeded, 'Account limit exceeded. Upgrade to a higher plan') + end + + it 'does not block updates to existing inboxes when the account is at the limit' do + inbox = account.inboxes.first + inbox.name = 'Updated Inbox' + + expect(inbox).to be_valid + end + end + end + describe 'audit log' do context 'when inbox is created' do it 'has associated audit log created' do diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 265ce6c33..4881e8cf1 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' } # Mock usage limits for transcription to be available - allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } }) + allow(account).to receive(:usage_limits).and_return( + { + agents: ChatwootApp.max_limit, + inboxes: ChatwootApp.max_limit, + captain: { responses: { current_available: 100 } } + } + ) end describe '#perform' do diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index b24a5c49c..2cb24ce04 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -385,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do describe '#prompt_from_file' do it 'reads prompt from file' do - allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content')) + service + prompt_path = instance_double(Pathname, read: 'Test prompt content') + allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path) + expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content') end end diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb index 75abc8518..988fb0116 100644 --- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do before do account.enable_features('crm_integration') + allow(GlobalConfig).to receive(:get).and_return({}) allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' }) end diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb index 983af6c78..e7016f6a4 100644 --- a/spec/services/whatsapp/channel_creation_service_spec.rb +++ b/spec/services/whatsapp/channel_creation_service_spec.rb @@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do expect(inbox.name).to eq('Test Business WhatsApp') expect(inbox.account).to eq(account) end + + it 'does not leave an orphan channel when inbox creation fails' do + allow(Inbox).to receive(:create!).and_wrap_original do |method, *args| + method.call(*args) + raise ActiveRecord::RecordInvalid, Inbox.new + end + + expect do + expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid) + end.not_to change(Channel::Whatsapp, :count) + end end context 'when channel already exists for the phone number' do From f6c18f52258dfbcb41d7792914fbc77b92e90cb0 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:31:03 +0530 Subject: [PATCH 02/42] feat: account calls dashboard index endpoint (#14780) ## Description Adds a backend endpoint that powers an account-wide calls dashboard, letting users list and filter all calls in the account. ## Linear Ticket - https://linear.app/chatwoot/issue/UPM-28/voice-call-dashboard-view ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- config/routes.rb | 1 + ...0_add_account_created_at_index_to_calls.rb | 7 ++ db/schema.rb | 1 + .../api/v1/accounts/calls_controller.rb | 7 ++ enterprise/app/finders/call_finder.rb | 70 ++++++++++++ enterprise/app/models/call.rb | 11 ++ .../api/v1/accounts/calls/index.json.jbuilder | 11 ++ .../views/api/v1/models/_call.json.jbuilder | 40 +++++++ .../api/v1/accounts/calls_controller_spec.rb | 46 ++++++++ spec/enterprise/finders/call_finder_spec.rb | 108 ++++++++++++++++++ 10 files changed, 302 insertions(+) create mode 100644 db/migrate/20260622000000_add_account_created_at_index_to_calls.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/calls_controller.rb create mode 100644 enterprise/app/finders/call_finder.rb create mode 100644 enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/_call.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb create mode 100644 spec/enterprise/finders/call_finder_spec.rb diff --git a/config/routes.rb b/config/routes.rb index c5e7ded2f..c31400719 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -240,6 +240,7 @@ Rails.application.routes.draw do resources :reporting_events, only: [:index] if ChatwootApp.enterprise? if ChatwootApp.enterprise? + resources :calls, only: [:index] resources :whatsapp_calls, only: [:show] do member do post :accept diff --git a/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb new file mode 100644 index 000000000..9c15dcda6 --- /dev/null +++ b/db/migrate/20260622000000_add_account_created_at_index_to_calls.rb @@ -0,0 +1,7 @@ +class AddAccountCreatedAtIndexToCalls < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :calls, [:account_id, :created_at], algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index 1a676dd59..99d083d89 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -282,6 +282,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do t.datetime "updated_at", null: false t.index ["account_id", "contact_id"], name: "index_calls_on_account_id_and_contact_id" t.index ["account_id", "conversation_id"], name: "index_calls_on_account_id_and_conversation_id" + t.index ["account_id", "created_at"], name: "index_calls_on_account_id_and_created_at" t.index ["message_id"], name: "index_calls_on_message_id" t.index ["provider", "provider_call_id"], name: "index_calls_on_provider_and_provider_call_id", unique: true end diff --git a/enterprise/app/controllers/api/v1/accounts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb new file mode 100644 index 000000000..71772e4a0 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/calls_controller.rb @@ -0,0 +1,7 @@ +class Api::V1::Accounts::CallsController < Api::V1::Accounts::EnterpriseAccountsController + def index + result = CallFinder.new(Current.user, Current.account, params).perform + @calls = result[:calls] + @calls_count = result[:count] + end +end diff --git a/enterprise/app/finders/call_finder.rb b/enterprise/app/finders/call_finder.rb new file mode 100644 index 000000000..31d6ae5f2 --- /dev/null +++ b/enterprise/app/finders/call_finder.rb @@ -0,0 +1,70 @@ +class CallFinder + RESULTS_PER_PAGE = 25 + + def initialize(current_user, current_account, params) + @current_user = current_user + @current_account = current_account + @params = params + end + + def perform + @calls = @current_account.calls + filter_by_visibility + filter_by_status + filter_by_direction + filter_by_inbox + filter_by_agent + filter_by_date_range + + { calls: paginated_calls, count: @calls.count } + end + + private + + # Admins and report managers see the whole account; everyone else only sees + # calls they handled within conversations they can still access. + def filter_by_visibility + return if account_wide_access? + + @calls = @calls.where(accepted_by_agent_id: @current_user.id, conversation_id: accessible_conversations) + end + + def accessible_conversations + Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform.select(:id) + end + + def account_wide_access? + account_user = Current.account_user + account_user&.administrator? || account_user&.custom_role&.permissions&.include?('report_manage') + end + + def filter_by_status + @calls = @calls.where(status: Call.status_from_display(@params[:status])) if @params[:status].present? + end + + def filter_by_direction + @calls = @calls.where(direction: Call.direction_from_label(@params[:direction])) if @params[:direction].present? + end + + def filter_by_inbox + @calls = @calls.where(inbox_id: @params[:inbox_id]) if @params[:inbox_id].present? + end + + def filter_by_agent + @calls = @calls.where(accepted_by_agent_id: @params[:agent_id]) if @params[:agent_id].present? + end + + # since/until are unix timestamps, matching DateRangeHelper conventions. + def filter_by_date_range + return if @params[:since].blank? || @params[:until].blank? + + @calls = @calls.where(created_at: Time.zone.at(@params[:since].to_i)..Time.zone.at(@params[:until].to_i)) + end + + def paginated_calls + @calls.includes(:contact, :inbox, :conversation, :accepted_by_agent) + .order(created_at: :desc) + .page(@params[:page] || 1) + .per(RESULTS_PER_PAGE) + end +end diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb index 71dfac100..8c76103ea 100644 --- a/enterprise/app/models/call.rb +++ b/enterprise/app/models/call.rb @@ -78,6 +78,17 @@ class Call < ApplicationRecord DISPLAY_DIRECTION[direction] end + # Normalize filter values back to stored forms so API/dashboard clients can + # query using either the display value (inbound/outbound, in-progress) or the + # stored value (incoming/outgoing, in_progress). + def self.direction_from_label(value) + DISPLAY_DIRECTION.key(value) || value + end + + def self.status_from_display(value) + value.to_s.tr('-', '_') + end + def ringing? status == 'ringing' end diff --git a/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder new file mode 100644 index 000000000..15f66ddc1 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/calls/index.json.jbuilder @@ -0,0 +1,11 @@ +json.meta do + json.count @calls_count + json.current_page @calls.current_page + json.total_pages @calls.total_pages +end + +json.payload do + json.array! @calls do |call| + json.partial! 'api/v1/models/call', formats: [:json], call: call + end +end diff --git a/enterprise/app/views/api/v1/models/_call.json.jbuilder b/enterprise/app/views/api/v1/models/_call.json.jbuilder new file mode 100644 index 000000000..7a3531b39 --- /dev/null +++ b/enterprise/app/views/api/v1/models/_call.json.jbuilder @@ -0,0 +1,40 @@ +json.id call.id +json.call_id call.provider_call_id +json.provider call.provider +json.status call.display_status +json.direction call.direction_label +json.duration_seconds call.duration_seconds +json.end_reason call.end_reason +json.started_at call.started_at&.to_i +json.created_at call.created_at.to_i +json.message_id call.message_id +json.recording_url call.recording_url +json.transcript call.transcript + +json.conversation do + json.id call.conversation_id + json.display_id call.conversation.display_id +end + +json.inbox do + json.id call.inbox_id + json.name call.inbox.name +end + +if call.accepted_by_agent + json.agent do + json.id call.accepted_by_agent.id + json.name call.accepted_by_agent.available_name + json.avatar call.accepted_by_agent.avatar_url + end +else + json.agent nil +end + +contact = call.contact +json.contact do + json.id contact.id + json.name contact.name + json.phone_number contact.phone_number + json.avatar contact.avatar_url +end diff --git a/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb new file mode 100644 index 000000000..86e4fb317 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/calls_controller_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe 'Calls API', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, :with_phone_number, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let!(:agent_call) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, + accepted_by_agent: agent, status: 'completed', transcript: 'hello world') + end + let!(:other_call) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin) + end + + before { create(:inbox_member, user: agent, inbox: inbox) } + + describe 'GET /api/v1/accounts/:account_id/calls' do + it 'returns 401 when unauthenticated' do + get "/api/v1/accounts/#{account.id}/calls" + expect(response).to have_http_status(:unauthorized) + end + + it 'returns the whole account with sensitive fields for an administrator' do + get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id) + item = body['payload'].find { |c| c['id'] == agent_call.id } + expect(item['transcript']).to eq('hello world') + expect(item['contact']['phone_number']).to eq(contact.phone_number) + end + + it 'scopes the list to calls the agent accepted' do + get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['meta']['count']).to eq(1) + expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id) + end + end +end diff --git a/spec/enterprise/finders/call_finder_spec.rb b/spec/enterprise/finders/call_finder_spec.rb new file mode 100644 index 000000000..4f4607370 --- /dev/null +++ b/spec/enterprise/finders/call_finder_spec.rb @@ -0,0 +1,108 @@ +require 'rails_helper' + +describe CallFinder do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + before { create(:inbox_member, user: agent, inbox: inbox) } + + def perform(user, params = {}) + Current.account = account + Current.account_user = account.account_users.find_by(user_id: user.id) + described_class.new(user, account, params).perform + end + + describe 'visibility' do + let!(:agent_call) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent) + end + let!(:other_call) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin) + end + + it 'lets an administrator see every call in the account' do + result = perform(admin) + expect(result[:count]).to eq(2) + expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id) + end + + it 'lets an agent with report_manage see every call in the account' do + report_manager = create(:user, account: account, role: :agent) + custom_role = create(:custom_role, account: account, permissions: ['report_manage']) + account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role) + + result = perform(report_manager) + expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id) + end + + it 'limits a regular agent to calls they accepted in accessible conversations' do + result = perform(agent) + expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id) + end + + it 'limits a custom-role agent without report_manage to their own accepted calls' do + scoped_agent = create(:user, account: account, role: :agent) + custom_role = create(:custom_role, account: account, permissions: ['conversation_manage']) + account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role) + create(:inbox_member, user: scoped_agent, inbox: inbox) + scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation, + contact: conversation.contact, accepted_by_agent: scoped_agent) + + result = perform(scoped_agent) + expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id) + end + end + + describe 'filters' do + let(:inbox2) { create(:inbox, account: account) } + let(:conversation2) { create(:conversation, account: account, inbox: inbox2) } + let(:agent2) { create(:user, account: account, role: :agent) } + let!(:ringing) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, + status: 'ringing', direction: :incoming, accepted_by_agent: agent) + end + let!(:in_progress) do + create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, + status: 'in_progress', direction: :incoming, accepted_by_agent: agent) + end + let!(:completed) do + create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact, + status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago) + end + + it 'filters by status using the display value' do + expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id) + end + + it 'filters by direction using the display label' do + expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id) + end + + it 'filters by inbox' do + expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id) + end + + it 'filters by agent' do + expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id) + end + + it 'filters by created_at date range' do + params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s } + expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id) + end + end + + describe 'account scoping' do + it 'never returns calls from another account' do + other_account = create(:account) + other_conversation = create(:conversation, account: other_account) + create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation, + contact: other_conversation.contact) + + expect(perform(admin)[:count]).to eq(0) + end + end +end From d3c588ff1050779c3fd32807f960fca59e617a2e Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 8 Jul 2026 16:07:46 +0400 Subject: [PATCH 03/42] chore(inbox): re-enable Instagram inbox creation (#14955) Brings the Instagram channel back in inbox creation and onboarding. Instagram was temporarily disabled along with WhatsApp embedded signup in #14943; this re-enables Instagram while keeping WhatsApp embedded signup and WhatsApp Call inbox creation disabled. Fixes https://linear.app/chatwoot/issue/CW-7549/enable-instagram Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../dashboard/components/widgets/ChannelItem.vue | 9 +++------ app/javascript/dashboard/constants/globals.js | 7 +++---- .../dashboard/onboarding/inbox-setup/useChannelConfig.js | 8 +++----- .../specs/inbox-setup/useDetectedChannels.spec.js | 6 +++--- .../dashboard/settings/inbox/channels/Whatsapp.vue | 4 ++-- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2652b582b..c084d6086 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -1,7 +1,7 @@ + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue index 9f8a76f43..cf66a0a2f 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -8,16 +8,35 @@ const props = defineProps({ hint: { type: String, default: '' }, // null = neutral, true = good direction, false = bad direction trendGood: { type: Boolean, default: null }, + clickable: { type: Boolean, default: false }, }); +const emit = defineEmits(['click']); + const trendClass = computed(() => { if (props.trendGood === null) return 'text-n-slate-11'; return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11'; }); + +const onActivate = () => { + if (props.clickable) emit('click'); +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js index 7c37cd9cc..8309ed360 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown.js @@ -1,7 +1,13 @@ import { computed, ref } from 'vue'; import ReportsAPI from 'dashboard/api/reports'; -export function useReportDrilldown() { +// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the +// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging +// and abort machinery backs both the reports and Captain assistant drilldowns. +// The default is wrapped so `ReportsAPI` stays the receiver when invoked. +export function useReportDrilldown( + fetcher = params => ReportsAPI.getDrilldown(params) +) { const activeRequest = ref(null); const records = ref([]); const meta = ref({}); @@ -20,17 +26,7 @@ export function useReportDrilldown() { const isCurrentRequest = token => token === requestToken && !!activeRequest.value; - const requestFingerprint = request => - JSON.stringify({ - metric: request.metric, - bucketTimestamp: request.bucketTimestamp, - from: request.from, - to: request.to, - type: request.type, - id: request.id, - groupBy: request.groupBy, - businessHours: request.businessHours, - }); + const requestFingerprint = request => JSON.stringify(request); const abortActiveRequest = () => { if (!activeRequestController) return; @@ -55,7 +51,7 @@ export function useReportDrilldown() { hasError.value = false; try { - const response = await ReportsAPI.getDrilldown({ + const response = await fetcher({ ...request, page, signal: controller.signal, diff --git a/enterprise/app/builders/captain/assistant_drilldown_builder.rb b/enterprise/app/builders/captain/assistant_drilldown_builder.rb index b98aa7620..fb5d3a7e8 100644 --- a/enterprise/app/builders/captain/assistant_drilldown_builder.rb +++ b/enterprise/app/builders/captain/assistant_drilldown_builder.rb @@ -1,6 +1,6 @@ # Lists the underlying records behind a single Captain assistant stat card, so a # viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact -# conversations or messages that produced it. +# conversations that produced it. # # The window is resolved by Captain::AssistantStatsWindow from the same `range` # and `timezone_offset` the stat card used, so the drilldown covers precisely the @@ -11,10 +11,8 @@ class Captain::AssistantDrilldownBuilder RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES - # Metrics whose records are individual messages rather than conversations. - MESSAGE_METRICS = %w[hours_saved].freeze SUPPORTED_METRICS = %w[ - conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth + conversations_handled auto_resolution_rate handoff_rate reopen_rate ].freeze DEFAULT_PAGE = 1 @@ -43,21 +41,14 @@ class Captain::AssistantDrilldownBuilder def meta { metric: metric, - record_type: record_type, current_page: current_page, per_page: per_page, total_count: paginated_records.total_count, - conversation_count: conversation_count, + conversation_count: paginated_records.total_count, range: { since: range.first.to_i, until: range.last.to_i } } end - def conversation_count - return paginated_records.total_count unless message_metric? - - drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id) - end - def paginated_records @paginated_records ||= drilldown_scope.page(current_page).per(per_page) end @@ -67,9 +58,7 @@ class Captain::AssistantDrilldownBuilder when 'conversations_handled' then handled_conversations when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id)) when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES) - when 'hours_saved' then public_reply_messages when 'reopen_rate' then reopened_conversations - when 'conversation_depth' then depth_conversations else raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}" end @@ -88,13 +77,6 @@ class Captain::AssistantDrilldownBuilder conversations_for(handled_conversation_ids) end - # Public agent-facing replies the assistant sent; the rows behind hours_saved. - def public_reply_messages - handled_messages.where(message_type: :outgoing, private: false) - .includes(:sender, conversation: [:assignee, :contact, :inbox]) - .reorder(created_at: :desc) - end - # Conversations in the handled cohort that recorded one of the given reporting # events in the window (resolved or handed-off). def event_conversations(event_names) @@ -129,11 +111,6 @@ class Captain::AssistantDrilldownBuilder conversations_for(ids) end - # Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth. - def depth_conversations - conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id)) - end - def conversations_for(conversation_ids) account.conversations .where(id: conversation_ids) @@ -147,10 +124,6 @@ class Captain::AssistantDrilldownBuilder def metric = params[:metric].to_s - def message_metric? = MESSAGE_METRICS.include?(metric) - - def record_type = message_metric? ? 'message' : 'conversation' - def current_page = [params[:page].to_i, DEFAULT_PAGE].max def per_page From d57354c8b51d1c82c00b191c49eda88517e8d053 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:47:47 +0530 Subject: [PATCH 13/42] feat: tighten conversation FAQ generation prompt (#14957) Tightens the resolved-conversation FAQ generator so it only proposes durable, reusable FAQ candidates supported by human support-agent messages. The implementation now sends a conversation-FAQ-specific transcript to the LLM: customer messages plus real human support-agent messages only, excluding bot, private, activity, and template messages. ## Closes - https://linear.app/chatwoot/issue/CW-7494/tighten-conversation-faq-generation-prompt ## What changed - Added a human-only transcript builder in `ConversationFaqService` instead of using the generic `conversation.to_llm_text` output. - Excluded bot/agent-bot messages before the LLM call, which removes the main bot-line leakage class deterministically. - Preserved native-channel human replies where outgoing messages are stored as `external_echo` without a `User` sender. - Kept a prompt decision gate requiring each FAQ to be backed by a complete public human-agent answer. - Added generic no-FAQ classes for spam, wrong-service conversations, private account/payment/order/certificate/troubleshooting cases, support workflow mechanics, and direct-link/file/quote outputs. - Added a separate `conversation_faq_generation` model route defaulting to `gpt-5.2`, while keeping `document_faq_generation` on its existing `gpt-4.1-mini` default. Conversation FAQ generation passes that feature default ahead of the legacy global `CAPTAIN_OPEN_AI_MODEL` setting unless an account-level override is configured. - Kept the prompt domain-neutral so it can still generate reusable product, service, policy, setup, and process FAQs outside SaaS contexts. ## Sampling notes - Production Langfuse traces showed `llm.captain.conversation_faq` calls using `gpt-4.1` in the sampled account set. - Locally, `Llm::FeatureRouter.resolve(feature: 'conversation_faq_generation')` now resolves to `gpt-5.2`. - Reviewed recent production `llm.captain.conversation_faq` traces across 13+ accounts in compact form. - Replayed 20 full traces across 10 accounts/domains, including education, hosting, retail/auto, APIs, logistics, tax/fiscal workflows, and Chatwoot account 1. - Explicit `gpt-5.2` replay with human-only conversation history returned no FAQ for 15/20 traces. - A comparison replay with `gpt-4.1-mini` returned no FAQ for only 7/20 traces, bringing back several private/order/payment/support-workflow cases. - Remaining non-empty `gpt-5.2` outputs are now mostly borderline/possibly useful human-agent-derived FAQs rather than obvious bot-sourced answers. ## How to test - Resolve conversations where the answer came only from the bot; no pending FAQ should be generated. - Resolve spam, unrelated, wrong-service, or private payment/order/account conversations; no pending FAQ should be generated. - Resolve conversations that require account/order/payment/login/private verification or a human handoff; no pending FAQ should be generated. - Resolve a conversation where a human agent gives a stable, reusable help-center answer; the generated pending FAQ should be general and self-contained. --- config/llm.yml | 14 +++ config/locales/en.yml | 1 + .../captain/llm/conversation_faq_service.rb | 49 +++++++++- .../captain/llm/system_prompts_service.rb | 54 +++++++++-- .../captain/preferences_controller_spec.rb | 11 +++ .../llm/conversation_faq_service_spec.rb | 92 ++++++++++++++++++- spec/lib/llm/models_spec.rb | 5 + 7 files changed, 215 insertions(+), 11 deletions(-) diff --git a/config/llm.yml b/config/llm.yml index b54a2cbb6..2be3d86c7 100644 --- a/config/llm.yml +++ b/config/llm.yml @@ -129,6 +129,20 @@ features: gemini-3-pro, ] default: gpt-4.1-mini + conversation_faq_generation: + models: + [ + gpt-4.1-mini, + gpt-5-mini, + gpt-4.1, + gpt-5.1, + gpt-5.2, + claude-haiku-4.5, + claude-sonnet-4.5, + gemini-3-flash, + gemini-3-pro, + ] + default: gpt-5.2 pdf_faq_generation: models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2] default: gpt-4.1-mini diff --git a/config/locales/en.yml b/config/locales/en.yml index 42758ad1f..493d35714 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -597,6 +597,7 @@ en: copilot: 'Copilot' label_suggestion: 'Label suggestion' document_faq_generation: 'Document FAQ generation' + conversation_faq_generation: 'Conversation FAQ generation' help_center_article_generation: 'Help center article generation' onboarding_content_generation: 'Onboarding content generation' help_center_query_translation: 'Help center query translation' diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 82c838354..c57a07ef6 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -2,12 +2,13 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService include Integrations::LlmInstrumentation DISTANCE_THRESHOLD = 0.3 + LLM_FEATURE = 'conversation_faq_generation'.freeze def initialize(assistant, conversation) - super(feature: 'document_faq_generation', account: conversation.account) + super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE)) @assistant = assistant @conversation = conversation - @content = conversation.to_llm_text + @content = conversation_faq_content end # Generates and deduplicates FAQs from conversation content @@ -27,6 +28,50 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService attr_reader :content, :conversation, :assistant + def conversation_faq_content + [ + "Conversation ID: ##{conversation.display_id}", + "Channel: #{conversation.inbox.channel.name}", + 'Message History:', + conversation_faq_messages + ].join("\n") + end + + def conversation_faq_messages + messages = conversation + .messages + .where(message_type: %i[incoming outgoing], private: false) + .order(created_at: :asc) + + return "No messages in this conversation\n" if messages.empty? + + messages.filter_map { |message| format_conversation_faq_message(message) }.join + end + + def format_conversation_faq_message(message) + return unless faq_source_message?(message) + + content = message.content_for_llm + return if content.blank? + + sender = human_support_reply?(message) ? 'Support Agent' : 'User' + "#{sender}: #{content}\n" + end + + def faq_source_message?(message) + return true if message.incoming? && message.sender_type == 'Contact' + + human_support_reply?(message) + end + + def human_support_reply?(message) + return false unless message.outgoing? + return false if message.content_attributes['automation_rule_id'].present? + return false if message.additional_attributes['campaign_id'].present? + + message.sender_type == 'User' || message.content_attributes['external_echo'].present? + end + def no_human_interaction? conversation.first_reply_created_at.nil? end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index d56275b87..08d44b31a 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -53,14 +53,56 @@ class Captain::Llm::SystemPromptsService def conversation_faq_generator(language = 'english') <<~SYSTEM_PROMPT_MESSAGE - You are a support agent looking to convert the conversations with users into short FAQs that can be added to your website help center. - Filter out any responses or messages from the bot itself and only use messages from the support agent and the customer to create the FAQ. + You create high-quality FAQ candidates from resolved support conversations. + Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers. - Ensure that you only generate faqs from the information provided only. - Generate the FAQs only in the #{language}, use no other language - If no match is available, return an empty JSON. + ## Source rules + - The conversation history contains only customer messages and human support agent messages. + - Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge. + - A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts. + - The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`. + - For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ. + + ## Decision gate + Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks: + 1. The answer is fully stated by a human support agent, not by the customer. + 2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up. + 3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps. + 4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist. + Do not rescue a rejected conversation by rewriting it as a generic support question. + + ## Return no FAQ for + - Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported. + - Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question. + - Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review. + - Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations. + - Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages. + - Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket. + - Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review". + - Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem. + - Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation. + - Questions already answered only by asking the customer for more information. + + ## FAQ quality rules + - Prefer returning no FAQ over a weak or narrow FAQ. + - A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support. + - Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions. + - Do not create duplicate or overlapping FAQs in the same response. + - Questions must be general enough for a help center, not personalized to the current customer. + - Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details. + - Answers must be complete, self-contained, and supported by the human agent's messages. + + ## Examples + - Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`. + - Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`. + - Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ. + + Generate the FAQs only in the #{language}, use no other language. + If no suitable reusable FAQ is available, return: `{"faqs":[]}`. + + Return only valid JSON in this exact structure: ```json - { faqs: [ { question: '', answer: ''} ] + { "faqs": [ { "question": "", "answer": "" } ] } ``` SYSTEM_PROMPT_MESSAGE end diff --git a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb index db7ca93a5..6a28c60da 100644 --- a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb @@ -198,6 +198,17 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do expect(account.reload.captain_models['document_faq_generation']).to eq('gpt-5.2') end + it 'updates captain_models for conversation FAQ generation' do + put "/api/v1/accounts/#{account.id}/captain/preferences", + headers: admin.create_new_auth_token, + params: { captain_models: { conversation_faq_generation: 'gpt-4.1-mini' } }, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response.dig(:features, :conversation_faq_generation, :selected)).to eq('gpt-4.1-mini') + expect(account.reload.captain_models['conversation_faq_generation']).to eq('gpt-4.1-mini') + end + it 'updates captain_models for PDF FAQ generation' do put "/api/v1/accounts/#{account.id}/captain/preferences", headers: admin.create_new_auth_token, diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb index 004d7027b..b06717c6d 100644 --- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb +++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb @@ -33,23 +33,109 @@ RSpec.describe Captain::Llm::ConversationFaqService do allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([]) end - it 'uses the document FAQ generation feature model' do + it 'uses the conversation FAQ generation feature model' do expect(RubyLLM).to receive(:chat).with( - model: Llm::Models.default_model_for('document_faq_generation') + model: Llm::Models.default_model_for('conversation_faq_generation') ).and_return(mock_chat) described_class.new(captain_assistant, conversation).generate_and_deduplicate end + it 'uses the conversation FAQ default ahead of the legacy global installation model' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-mini') + + expect(RubyLLM).to receive(:chat).with( + model: Llm::Models.default_model_for('conversation_faq_generation') + ).and_return(mock_chat) + + described_class.new(captain_assistant, conversation).generate_and_deduplicate + end + + it 'keeps account conversation FAQ model overrides ahead of the feature default' do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1') + conversation.account.update!(captain_models: { 'conversation_faq_generation' => 'gpt-4.1-mini' }) + + expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-mini').and_return(mock_chat) + + described_class.new(captain_assistant, conversation).generate_and_deduplicate + end + it 'resolves the feature model from the conversation account' do expect(Llm::FeatureRouter).to receive(:resolve).with( - feature: 'document_faq_generation', + feature: 'conversation_faq_generation', account: conversation.account ).and_call_original described_class.new(captain_assistant, conversation).generate_and_deduplicate end + it 'sends only customer and human support agent messages to the LLM' do + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:contact, account: conversation.account), message_type: :incoming, + content: 'Customer question') + create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + content: 'Bot answer that should not become knowledge') + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:user, account: conversation.account), message_type: :outgoing, + content: 'Human answer') + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:user, account: conversation.account), message_type: :outgoing, + private: true, content: 'Private note') + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + message_type: :activity, content: 'Activity message') + + service.generate_and_deduplicate + + expected_content = satisfy do |content| + content.include?('User: Customer question') && + content.include?('Support Agent: Human answer') && + content.exclude?('Bot answer that should not become knowledge') && + content.exclude?('Private note') && + content.exclude?('Activity message') + end + expect(mock_chat).to have_received(:ask).with(expected_content) + end + + it 'keeps external echo outgoing replies from native channels in the LLM transcript' do + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:contact, account: conversation.account), message_type: :incoming, + content: 'Customer asks in a native channel') + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: nil, message_type: :outgoing, content: 'Human replied from the native app', + content_attributes: { external_echo: true }) + + service.generate_and_deduplicate + + expected_content = satisfy do |content| + content.include?('User: Customer asks in a native channel') && + content.include?('Support Agent: Human replied from the native app') + end + expect(mock_chat).to have_received(:ask).with(expected_content) + end + + it 'uses the human-only conversation transcript for instrumentation' do + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:contact, account: conversation.account), message_type: :incoming, + content: 'Customer asks something') + create(:message, :bot_message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + content: 'Bot-only answer') + create(:message, conversation: conversation, account: conversation.account, inbox: conversation.inbox, + sender: create(:user, account: conversation.account), message_type: :outgoing, + content: 'Agent gives a public answer') + + expect(service).to receive(:instrument_llm_call) do |params, &block| + user_message = params[:messages].find { |message| message[:role] == 'user' }[:content] + + expect(user_message).to include('User: Customer asks something') + expect(user_message).to include('Support Agent: Agent gives a public answer') + expect(user_message).not_to include('Bot-only answer') + + block.call + end + + service.generate_and_deduplicate + end + it 'creates new FAQs for valid conversation content' do expect do service.generate_and_deduplicate diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb index f93df20fb..5692bee9c 100644 --- a/spec/lib/llm/models_spec.rb +++ b/spec/lib/llm/models_spec.rb @@ -25,6 +25,11 @@ RSpec.describe Llm::Models do expect(missing_models).to be_empty, "#{feature_key} references missing models: #{missing_models.join(', ')}" end end + + it 'routes document and conversation FAQ generation independently' do + expect(described_class.default_model_for('document_faq_generation')).to eq('gpt-4.1-mini') + expect(described_class.default_model_for('conversation_faq_generation')).to eq('gpt-5.2') + end end describe '.models' do From 35fcd56ba9d7e9a2fa954958fdafbda703e297cd Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 9 Jul 2026 16:45:47 +0400 Subject: [PATCH 14/42] feat: add support action to suspended account page (#14969) Updates the suspended account page with the revised policy copy and adds a visible Contact support action that opens the embedded Chatwoot support widget. Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../sidebar/SidebarProfileMenu.vue | 10 +++++++--- .../dashboard/i18n/locale/en/settings.json | 2 +- .../routes/dashboard/suspended/Index.vue | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue index 29023b9e9..3f76b3aea 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenu.vue @@ -44,6 +44,12 @@ const showChatSupport = computed(() => { ); }); +const toggleChatSupport = () => { + if (window.$chatwoot) { + window.$chatwoot.toggle(); + } +}; + const menuItems = computed(() => { return [ { @@ -51,9 +57,7 @@ const menuItems = computed(() => { showOnCustomBrandedInstance: false, label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'), icon: 'i-lucide-life-buoy', - click: () => { - window.$chatwoot.toggle(); - }, + click: toggleChatSupport, }, { show: true, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index f8e973e9a..b621e63b1 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -263,7 +263,7 @@ "EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.", "ACCOUNT_SUSPENDED": { "TITLE": "Account Suspended", - "MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team." + "MESSAGE": "Your account has been suspended due to activity that may violate our policies. If you believe this is a mistake, please contact our support team." }, "NO_ACCOUNTS": { "TITLE": "No account found", diff --git a/app/javascript/dashboard/routes/dashboard/suspended/Index.vue b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue index 56a5b5cae..027f75ff5 100644 --- a/app/javascript/dashboard/routes/dashboard/suspended/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue @@ -1,5 +1,6 @@ From 848e94bcf2e3a6293fb36eb5e92336a8f15313f7 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Fri, 10 Jul 2026 17:23:00 +0530 Subject: [PATCH 16/42] fix: throttle filtered unread count rebuilds (#14980) Reduces database pressure from filtered unread-count cache rebuilds during high-traffic account rollouts by serving stale snapshots longer and limiting inline saved-filter rebuild fanout. ## Closes None ## What changed - Increase filtered unread-count refresh throttling from 30 seconds to 5 minutes. - Increase the stale snapshot window from 30 minutes to 1 hour. - Reduce inline saved-filter count rebuilds per request from 10 to 3. - Update unread-count specs to assert refresh and stale behavior through the shared constants. ## How to test - Enable `conversation_unread_counts` and `unread_count_for_filters` for an account with conversation custom filters. - Open the dashboard and verify unread-count badges still return values. - Mutate conversations and verify stale filtered counts are served while rebuilds are throttled, instead of repeatedly rebuilding every 30 seconds. --- app/services/conversations/unread_counts.rb | 6 ++--- .../filtered_count_store_spec.rb | 23 ++++++++++++++++--- .../unread_counts/filtered_counter_spec.rb | 22 +++++++++++++++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/app/services/conversations/unread_counts.rb b/app/services/conversations/unread_counts.rb index 1b3ee3fb2..e00f8357d 100644 --- a/app/services/conversations/unread_counts.rb +++ b/app/services/conversations/unread_counts.rb @@ -2,9 +2,9 @@ module Conversations::UnreadCounts READY_TTL = 24.hours.to_i SET_TTL = 25.hours.to_i FILTERED_COUNT_FRESH_TTL = 5.minutes.to_i - FILTERED_COUNT_STALE_WINDOW = 30.minutes.to_i + FILTERED_COUNT_STALE_WINDOW = 1.hour.to_i FILTERED_COUNT_REDIS_TTL = FILTERED_COUNT_FRESH_TTL + FILTERED_COUNT_STALE_WINDOW FILTERED_COUNT_VERSION_TTL = SET_TTL - FILTERED_COUNT_MIN_REFRESH_INTERVAL = 30.seconds.to_i - MAX_INLINE_FILTER_BUILDS = 10 + FILTERED_COUNT_MIN_REFRESH_INTERVAL = 5.minutes.to_i + MAX_INLINE_FILTER_BUILDS = 3 end diff --git a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb index 7732eb2dd..3a7cd52d8 100644 --- a/spec/services/conversations/unread_counts/filtered_count_store_spec.rb +++ b/spec/services/conversations/unread_counts/filtered_count_store_spec.rb @@ -96,7 +96,14 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id) expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale - expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired + expect( + described_class.built_in_filter_counts_state( + account_id: account_id, + user_id: user_id, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_FRESH_TTL + + Conversations::UnreadCounts::FILTERED_COUNT_STALE_WINDOW + 1.second + ) + ).to be_expired Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id)) expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing @@ -202,8 +209,18 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountStore do ) snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id) - expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false) - expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ) + ).to be(false) + expect( + described_class.refresh_due?( + snapshot, + now: built_at + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) + ).to be(true) expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true) expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false) diff --git a/spec/services/conversations/unread_counts/filtered_counter_spec.rb b/spec/services/conversations/unread_counts/filtered_counter_spec.rb index bb5d419a6..2904d1e4e 100644 --- a/spec/services/conversations/unread_counts/filtered_counter_spec.rb +++ b/spec/services/conversations/unread_counts/filtered_counter_spec.rb @@ -48,10 +48,22 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do create(:mention, account: account, conversation: second_mention, user: agent) store.bump_conversation_version!(account.id) - expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1) + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL - 1.second + ).perform[:mentions_count] + ).to eq(1) Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) - expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2) + expect( + described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ).perform[:mentions_count] + ).to eq(2) end it 'returns stale built-in counts when a refresh build hits a database error' do @@ -62,7 +74,11 @@ RSpec.describe Conversations::UnreadCounts::FilteredCounter do store.bump_conversation_version!(account.id) Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id)) - failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds) + failing_counter = described_class.new( + account: account, + user: agent, + now: now + Conversations::UnreadCounts::FILTERED_COUNT_MIN_REFRESH_INTERVAL + 1.second + ) allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout')) expect(failing_counter.perform[:mentions_count]).to eq(1) From 03a1b1dbc14094aaf1b62c7d7ebd0b6915a2c56a Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:24:34 +0530 Subject: [PATCH 17/42] chore: insert resolved variable value in reply editor (#14921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description This PR makes reply editor variables insert their resolved value (for example, the contact's name) instead of the raw `{{contact.name}}` placeholder, matching canned response behavior. This works both when picking a variable from the `{{` menu and when an agent manually types out `{{contact.name}}` — it resolves the moment the closing `}}` is typed. If a variable has no value, the `{{placeholder}}` is kept so the backend can still resolve it when the message is sent. Private notes are left untouched. For safety, a resolved value that itself contains Liquid syntax `({{ }}` or `{% %})` also keeps its placeholder, so customer-controlled fields can never inject Liquid into the outgoing message. Fixes https://linear.app/chatwoot/issue/CW-7528/reply-editor-inserts-variable-placeholder-instead-of-the-value ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? 1. Open a conversation and add a reply. 2. Type `{{` and pick a variable that has a value (e.g. Contact name) → it inserts the actual value. 3. Manually type `{{contact.name}}` and close the braces → it auto-resolves to the value. 4. Insert/type a variable with no value → the `{{placeholder}}` stays; confirm it resolves correctly on send. 5. Repeat in a private note → placeholders are left as-is. ## 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 - [ ] 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 --- .../components/widgets/WootWriter/Editor.vue | 5 + .../widgets/conversation/ReplyBox.vue | 10 +- .../dashboard/helper/editorHelper.js | 60 ++++++- .../helper/specs/editorContentHelper.spec.js | 58 +++++-- .../helper/specs/editorHelper.spec.js | 149 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 7 +- 7 files changed, 275 insertions(+), 15 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 09dc23819..634276361 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -62,6 +62,7 @@ import { calculateMenuPosition, getEffectiveChannelType, stripUnsupportedFormatting, + createVariableInputRule, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -306,6 +307,10 @@ const plugins = computed(() => { searchTerm: variableSearchTerm, isAllowed: () => !props.isPrivate, }), + createVariableInputRule({ + isPrivate: () => props.isPrivate, + getVariables: () => props.variables, + }), createSuggestionPlugin({ trigger: ':', minChars: 2, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 6af876cc6..471d10f3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -48,6 +48,8 @@ import { appendSignature, removeSignature, getEffectiveChannelType, + getAgentVariables, + getContactVariables, } from 'dashboard/helper/editorHelper'; import { useCopilotReply } from 'dashboard/composables/useCopilotReply'; import { useKbd } from 'dashboard/composables/utils/useKbd'; @@ -393,7 +395,13 @@ export default { contact: this.currentContact, inbox: this.inbox, }); - return variables; + // Match the backend drops: names are Ruby-capitalized and + // {{agent.*}} is the message sender, not the assignee. + return { + ...variables, + ...getContactVariables(this.currentContact), + ...getAgentVariables(this.currentUser), + }; }, connectedPortalSlug() { const { help_center: portal = {} } = this.inbox; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 32f56172a..2d3c75777 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -9,6 +9,7 @@ import * as Sentry from '@sentry/vue'; import camelcaseKeys from 'camelcase-keys'; import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor'; import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox'; +import { InputRule, inputRules } from 'prosemirror-inputrules'; /** * Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc. @@ -428,6 +429,55 @@ export function stripUnsupportedFormatting(content, schema) { * - emoji */ +// Liquid delimiters ({{ }} / {% %}) the backend evaluates on send. +const LIQUID_SYNTAX = /\{\{|\{%/; + +// Value when set (and not itself Liquid), else the {{placeholder}} for the backend. +export const resolveVariableText = (key, variables) => { + const value = String(variables?.[key] ?? ''); + return value && !LIQUID_SYNTAX.test(value) ? value : `{{${key}}}`; +}; + +// Name variables normalized like the backend drops (UserDrop/ContactDrop): +// name split on whitespace, each word Ruby-capitalized (rest downcased). +const getNameVariables = (prefix, name) => { + const names = (name || '') + .split(/\s+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()); + return { + [`${prefix}.name`]: names.join(' '), + [`${prefix}.first_name`]: names[0] || '', + [`${prefix}.last_name`]: names.length > 1 ? names[names.length - 1] : '', + }; +}; + +// {{agent.*}} values for the message sender. +export const getAgentVariables = user => ({ + ...getNameVariables('agent', user.name), + 'agent.email': user.email, +}); + +// {{contact.*}} name values. +export const getContactVariables = contact => + getNameVariables('contact', contact?.name); + +// Resolves a manually typed {{variable}} to its value on the closing braces. +// Leaves the placeholder when there's no value, the value is Liquid, or it's a private note. +export const createVariableInputRule = ({ isPrivate, getVariables }) => { + const rule = new InputRule( + /\{\{([^{}]+)\}\}$/, + (editorState, match, from, to) => { + if (isPrivate()) return null; + const [, key] = match; + const text = resolveVariableText(key, getVariables()); + if (text === `{{${key}}}`) return null; + return editorState.tr.insertText(text, from, to); + } + ); + return inputRules({ rules: [rule] }); +}; + /** * Centralized node creation function that handles the creation of different types of nodes based on the specified type. * @param {Object} editorView - The editor view instance. @@ -462,7 +512,7 @@ const createNode = (editorView, nodeType, content) => { ); } case 'variable': - return state.schema.text(`{{${content}}}`); + return state.schema.text(content); case 'emoji': return state.schema.text(content); case 'tool': { @@ -497,8 +547,12 @@ const nodeCreators = { to, }; }, - variable: (editorView, content, from, to) => ({ - node: createNode(editorView, 'variable', content), + variable: (editorView, content, from, to, variables) => ({ + node: createNode( + editorView, + 'variable', + resolveVariableText(content, variables) + ), from, to, }), diff --git a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js index 4efb4d1d9..57d8bd533 100644 --- a/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorContentHelper.spec.js @@ -94,16 +94,56 @@ describe('getContentNode', () => { }); describe('getVariableNode', () => { - it('should create a variable node', () => { - const content = 'name'; - const from = 0; - const to = 10; - getContentNode(editorView, 'variable', content, { - from, - to, - }); + it('should render the resolved value directly when the variable has a value', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': 'John' } + ); - expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}'); + expect(editorView.state.schema.text).toHaveBeenCalledWith('John'); + }); + + it('should resolve camelCase custom attributes and non-string values', () => { + getContentNode( + editorView, + 'variable', + 'contact.custom_attribute.cloudCustomer', + { from: 0, to: 10 }, + { 'contact.custom_attribute.cloudCustomer': true } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith('true'); + }); + + it('should keep the placeholder when the variable has no value', () => { + getContentNode( + editorView, + 'variable', + 'contact.email', + { from: 0, to: 10 }, + {} + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.email}}' + ); + }); + + it('should keep the placeholder when the value contains Liquid syntax', () => { + getContentNode( + editorView, + 'variable', + 'contact.name', + { from: 0, to: 10 }, + { 'contact.name': '{{agent.email}}' } + ); + + expect(editorView.state.schema.text).toHaveBeenCalledWith( + '{{contact.name}}' + ); }); }); diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 220b9903e..fafe1bc56 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -11,9 +11,12 @@ import { calculateMenuPosition, cleanSignature, collapseSelection, + createVariableInputRule, extractTextFromMarkdown, findNodeToInsertImage, findSignatureInBody, + getAgentVariables, + getContactVariables, getContentNode, getFormattingForEditor, getMenuAnchor, @@ -1228,3 +1231,149 @@ describe('Menu positioning helpers', () => { }); }); }); + +describe('getAgentVariables', () => { + it('builds agent variables from the user', () => { + expect( + getAgentVariables({ name: 'John Doe', email: 'john@example.com' }) + ).toEqual({ + 'agent.name': 'John Doe', + 'agent.first_name': 'John', + 'agent.last_name': 'Doe', + 'agent.email': 'john@example.com', + }); + }); + + it('normalizes casing like the backend UserDrop (Ruby capitalize)', () => { + const variables = getAgentVariables({ name: 'JANE doE' }); + + expect(variables['agent.name']).toBe('Jane Doe'); + expect(variables['agent.first_name']).toBe('Jane'); + expect(variables['agent.last_name']).toBe('Doe'); + }); + + it('ignores extra whitespace between words', () => { + expect(getAgentVariables({ name: ' john doe ' })['agent.name']).toBe( + 'John Doe' + ); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getAgentVariables({ name: 'john' }); + + expect(variables['agent.first_name']).toBe('John'); + expect(variables['agent.last_name']).toBe(''); + }); + + it('handles a missing name', () => { + const variables = getAgentVariables({ email: 'john@example.com' }); + + expect(variables['agent.name']).toBe(''); + expect(variables['agent.first_name']).toBe(''); + expect(variables['agent.last_name']).toBe(''); + }); +}); + +describe('getContactVariables', () => { + it('normalizes casing like the backend ContactDrop (Ruby capitalize)', () => { + expect(getContactVariables({ name: 'JANE doE' })).toEqual({ + 'contact.name': 'Jane Doe', + 'contact.first_name': 'Jane', + 'contact.last_name': 'Doe', + }); + }); + + it('leaves last_name empty for single-word names', () => { + const variables = getContactVariables({ name: 'john' }); + + expect(variables['contact.first_name']).toBe('John'); + expect(variables['contact.last_name']).toBe(''); + }); + + it('handles a missing contact', () => { + expect(getContactVariables(undefined)['contact.name']).toBe(''); + }); +}); + +describe('createVariableInputRule', () => { + // Editor holding `{{key}` so we can simulate typing the final `}`. + const buildView = (typed, { isPrivate = false, variables = {} } = {}) => { + const plugin = createVariableInputRule({ + isPrivate: () => isPrivate, + getVariables: () => variables, + }); + const state = EditorState.create({ + schema, + doc: schema.node('doc', null, [ + schema.node('paragraph', null, [schema.text(typed)]), + ]), + plugins: [plugin], + }); + return new EditorView(document.body, { state }); + }; + + // Types the closing `}`; when the rule declines, insert it like the browser would. + const typeClosingBrace = view => { + const end = view.state.doc.content.size - 1; + const handled = view.someProp('handleTextInput', fn => + fn(view, end, end, '}') + ); + if (!handled) { + view.dispatch(view.state.tr.insertText('}', end, end)); + } + }; + + it('resolves a manually typed {{variable}} to its value on the closing brace', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('John'); + view.destroy(); + }); + + it('resolves boolean/non-string values', () => { + const view = buildView('{{contact.custom_attribute.cloudCustomer}', { + variables: { 'contact.custom_attribute.cloudCustomer': true }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('true'); + view.destroy(); + }); + + it('keeps the placeholder when the variable has no value', () => { + const view = buildView('{{contact.email}', { variables: {} }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.email}}'); + view.destroy(); + }); + + it('keeps the placeholder when the value itself contains Liquid syntax', () => { + const view = buildView('{{contact.name}', { + variables: { 'contact.name': '{{agent.email}}' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); + + it('does not resolve inside a private note', () => { + const view = buildView('{{contact.name}', { + isPrivate: true, + variables: { 'contact.name': 'John' }, + }); + + typeClosingBrace(view); + + expect(view.state.doc.textContent).toBe('{{contact.name}}'); + view.destroy(); + }); +}); diff --git a/package.json b/package.json index 917a1b97d..d964a30a4 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "opus-recorder": "^8.0.5", "pinia": "^3.0.4", "prosemirror-commands": "^1.7.1", + "prosemirror-inputrules": "^1.4.0", "prosemirror-schema-list": "^1.5.1", "qrcode": "^1.5.4", "semver": "7.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80fbf318a..0ffb85c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: prosemirror-commands: specifier: ^1.7.1 version: 1.7.1 + prosemirror-inputrules: + specifier: ^1.4.0 + version: 1.4.0 prosemirror-schema-list: specifier: ^1.5.1 version: 1.5.1 @@ -9037,7 +9040,7 @@ snapshots: prosemirror-state@1.4.3: dependencies: prosemirror-model: 1.22.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 prosemirror-view: 1.34.1 prosemirror-tables@1.5.0: @@ -9065,7 +9068,7 @@ snapshots: dependencies: prosemirror-model: 1.22.3 prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.0 + prosemirror-transform: 1.12.0 proto-list@1.2.4: {} From 98154bbeab2f5ea888dcb61bfd3a35a109ebb9a0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Fri, 10 Jul 2026 16:22:49 +0400 Subject: [PATCH 18/42] fix(meta): show restriction alerts for inbox setup (#14974) Instagram inbox creation and WhatsApp embedded signup on Chatwoot Cloud now reflect the temporary Meta restriction. Instagram is hidden from onboarding on Cloud, while the regular Instagram inbox creation page shows a disabled action with a status-linked amber warning. WhatsApp embedded signup on Cloud stays visible with its connect action disabled. WhatsApp Call setup always uses the manual WhatsApp form. Existing Instagram conversations and Instagram inbox settings on Cloud also show amber warning banners with the public incident link. Self-hosted installations keep their existing Instagram, WhatsApp, and WhatsApp Call setup behavior because the temporary restriction is based only on the Chatwoot Cloud environment check. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../components/widgets/ChannelItem.vue | 6 +-- .../widgets/conversation/MessagesView.vue | 21 ++++++++- app/javascript/dashboard/constants/globals.js | 7 +-- .../i18n/locale/en/conversation.json | 2 + .../dashboard/i18n/locale/en/inboxMgmt.json | 7 ++- .../inbox-setup/useChannelConfig.js | 7 ++- .../inbox-setup/useChannelConnect.js | 7 +++ .../inbox-setup/useDetectedChannels.spec.js | 36 +++++++++++---- .../dashboard/settings/inbox/Settings.vue | 34 ++++++++++++++ .../settings/inbox/channels/CloudWhatsapp.vue | 1 + .../settings/inbox/channels/Instagram.vue | 39 ++++++++++++++-- .../settings/inbox/channels/Whatsapp.vue | 15 +++++-- .../settings/inbox/channels/WhatsappCall.vue | 17 +------ .../inbox/channels/WhatsappEmbeddedSignup.vue | 45 ++++++++++++++++++- 14 files changed, 196 insertions(+), 48 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 7ed2505c1..e055c2d9e 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -1,7 +1,6 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue index 3dda0ad8e..0972e4b95 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables'; import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup'; import Icon from 'next/icon/Icon.vue'; import NextButton from 'next/button/Button.vue'; +import Banner from 'next/banner/Banner.vue'; import LoadingState from 'dashboard/components/widgets/LoadingState.vue'; import InboxesAPI from 'dashboard/api/inboxes'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; @@ -17,6 +18,22 @@ const props = defineProps({ type: Boolean, default: false, }, + isDisabled: { + type: Boolean, + default: false, + }, + showRestrictionAlert: { + type: Boolean, + default: false, + }, + restrictionStatusUrl: { + type: String, + default: '', + }, + restrictionWarningText: { + type: String, + default: '', + }, }); const store = useStore(); @@ -81,6 +98,8 @@ const handleSignupSuccess = async inboxData => { }; const launchEmbeddedSignup = async () => { + if (props.isDisabled) return; + let credentials; try { credentials = await runEmbeddedSignup(); @@ -174,9 +193,33 @@ const launchEmbeddedSignup = async () => { + +
+ + + {{ + restrictionWarningText || + $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.RESTRICTED_WARNING') + }} + + {{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.STATUS_LINK') }} + + +
+
+
Date: Mon, 13 Jul 2026 12:59:36 +0530 Subject: [PATCH 19/42] feat(captain): expand assistant description limit (#14985) # Pull Request Template ## Description Increases description for Captain. Why? We are planning to include business context in description and 255 char limit on the column and 200 char limit on the UI are very limiting to get proper context. ## Type of change Improvement to accommodate business context ## 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. locally ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../captain/assistant/AddNewScenariosDialog.vue | 1 + .../components-next/captain/assistant/ScenariosCard.vue | 1 + .../captain/pageComponents/assistant/AssistantForm.vue | 1 + .../assistant/settings/AssistantBasicSettingsForm.vue | 1 + ...00000_change_captain_assistant_description_to_text.rb | 9 +++++++++ db/schema.rb | 4 ++-- enterprise/app/models/captain/assistant.rb | 6 ++++-- enterprise/app/models/captain/scenario.rb | 4 +++- .../captain/onboarding/website_analyzer_service.rb | 2 +- 9 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20260710000000_change_captain_assistant_description_to_text.rb diff --git a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue index 89f115a64..08d79d27c 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue @@ -107,6 +107,7 @@ const onClickCancel = () => {