From b2ffad1998ce51af7b59398af7714c05a1b3c2ec Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 19 Jan 2026 00:38:32 -0800 Subject: [PATCH 01/96] fix: Validate status and priority params in search conversations tool (#13295) Co-authored-by: Claude Opus 4.5 --- .../copilot/search_conversations_service.rb | 18 ++++++--- .../search_conversations_service_spec.rb | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb index 9e824d1f5..d4acb9837 100644 --- a/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_conversations_service.rb @@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base end description 'Search conversations based on parameters' - param :status, type: :string, desc: 'Status of the conversation' + param :status, type: :string, desc: 'Status of the conversation (open, resolved, pending, snoozed). Leave empty to search all statuses.' param :contact_id, type: :number, desc: 'Contact id' - param :priority, type: :string, desc: 'Priority of conversation' + param :priority, type: :string, desc: 'Priority of conversation (low, medium, high, urgent). Leave empty to search all priorities.' param :labels, type: :string, desc: 'Labels available' def execute(status: nil, contact_id: nil, priority: nil, labels: nil) @@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base <<~RESPONSE #{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"} - #{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")} + #{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true, include_private_messages: true) }.join("\n---\n")} RESPONSE end @@ -34,12 +34,20 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base def get_conversations(status, contact_id, priority, labels) conversations = permissible_conversations conversations = conversations.where(contact_id: contact_id) if contact_id.present? - conversations = conversations.where(status: status) if status.present? - conversations = conversations.where(priority: priority) if priority.present? + conversations = conversations.where(status: status) if valid_status?(status) + conversations = conversations.where(priority: priority) if valid_priority?(priority) conversations = conversations.tagged_with(labels, any: true) if labels.present? conversations end + def valid_status?(status) + status.present? && Conversation.statuses.key?(status) + end + + def valid_priority?(priority) + priority.present? && Conversation.priorities.key?(priority) + end + def permissible_conversations Conversations::PermissionFilterService.new( @assistant.account.conversations, diff --git a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb index a02d05404..0835b6125 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_conversations_service_spec.rb @@ -119,5 +119,42 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do result = service.execute(status: 'snoozed') expect(result).to eq('No conversations found') end + + context 'when invalid status is provided' do + it 'ignores invalid status and returns all conversations' do + result = service.execute(status: 'all') + expect(result).to include('Total number of conversations: 2') + expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) + expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true)) + end + + it 'ignores random invalid status values' do + result = service.execute(status: 'invalid_status') + expect(result).to include('Total number of conversations: 2') + end + end + + context 'when invalid priority is provided' do + it 'ignores invalid priority and returns all conversations' do + result = service.execute(priority: 'all') + expect(result).to include('Total number of conversations: 2') + expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) + expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true)) + end + + it 'ignores random invalid priority values' do + result = service.execute(priority: 'invalid_priority') + expect(result).to include('Total number of conversations: 2') + end + end + + context 'when combining valid and invalid parameters' do + it 'applies valid filters and ignores invalid ones' do + result = service.execute(status: 'all', contact_id: contact.id) + expect(result).to include('Total number of conversations: 1') + expect(result).to include(open_conversation.to_llm_text(include_contact_details: true)) + expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true)) + end + end end end From 7e4d93f64994ac87cf850f1fc975667654839b07 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 19 Jan 2026 14:12:36 +0400 Subject: [PATCH 02/96] fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278) Fixes https://github.com/chatwoot/chatwoot/issues/13097 ### Problem The PR #12176 removed the `before_save :setup_webhooks` callback to fix a race condition where Meta's webhook verification request arrived before the channel was saved to the database. This change broke manual WhatsApp Cloud channel setup. While embedded signup explicitly calls `channel.setup_webhooks` in `EmbeddedSignupService`, manual setup had no equivalent call - meaning the `subscribed_apps` endpoint was never invoked and Meta never sent webhook events to Chatwoot. ### Solution Added an `after_commit` callback that triggers webhook setup for manual WhatsApp Cloud channels --- app/models/channel/whatsapp.rb | 7 +++ .../whatsapp/embedded_signup_service.rb | 4 ++ spec/factories/channel/channel_whatsapp.rb | 12 ++++- spec/models/channel/whatsapp_spec.rb | 49 +++++++++++++++---- .../whatsapp/webhook_setup_service_spec.rb | 6 ++- 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 7318cd978..5905c54f7 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord after_create :sync_templates before_destroy :teardown_webhooks + after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks? def name 'Whatsapp' @@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord def teardown_webhooks Whatsapp::WebhookTeardownService.new(self).perform end + + def should_auto_setup_webhooks? + # Only auto-setup webhooks for whatsapp_cloud provider with manual setup + # Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService + provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup' + end end diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index 4379d0b74..52273bc5d 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService validate_token_access(access_token) channel = create_or_reauthorize_channel(access_token, phone_info) + # NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because: + # 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger + # 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes + # 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback channel.setup_webhooks check_channel_health_and_prompt_reauth(channel) channel diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index dae7eb04f..4282a374d 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -96,8 +96,16 @@ FactoryBot.define do channel_whatsapp.define_singleton_method(:sync_templates) { nil } unless options.sync_templates channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config if channel_whatsapp.provider == 'whatsapp_cloud' - channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789', - 'business_account_id' => '123456789' }) + # Add 'source' => 'embedded_signup' to skip after_commit :setup_webhooks callback in tests + # The callback is for manual setup flow; embedded signup handles webhook setup explicitly + # Only set source if not already provided (allows tests to override) + default_config = { + 'api_key' => 'test_key', + 'phone_number_id' => '123456789', + 'business_account_id' => '123456789' + } + default_config['source'] = 'embedded_signup' unless channel_whatsapp.provider_config.key?('source') + channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge(default_config) end end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index b46c984d1..dcc010d88 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -47,16 +47,39 @@ RSpec.describe Channel::Whatsapp do end describe 'webhook_verify_token' do + before do + # Stub webhook setup to prevent HTTP calls during channel creation + setup_service = instance_double(Whatsapp::WebhookSetupService) + allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service) + allow(setup_service).to receive(:perform) + end + it 'generates webhook_verify_token if not present' do - channel = create(:channel_whatsapp, provider_config: { webhook_verify_token: nil }, provider: 'whatsapp_cloud', account: create(:account), - validate_provider_config: false, sync_templates: false) + channel = create(:channel_whatsapp, + provider_config: { + 'webhook_verify_token' => nil, + 'api_key' => 'test_key', + 'business_account_id' => '123456789' + }, + provider: 'whatsapp_cloud', + account: create(:account), + validate_provider_config: false, + sync_templates: false) expect(channel.provider_config['webhook_verify_token']).not_to be_nil end it 'does not generate webhook_verify_token if present' do - channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', provider_config: { webhook_verify_token: '123' }, account: create(:account), - validate_provider_config: false, sync_templates: false) + channel = create(:channel_whatsapp, + provider: 'whatsapp_cloud', + provider_config: { + 'webhook_verify_token' => '123', + 'api_key' => 'test_key', + 'business_account_id' => '123456789' + }, + account: create(:account), + validate_provider_config: false, + sync_templates: false) expect(channel.provider_config['webhook_verify_token']).to eq '123' end @@ -91,15 +114,18 @@ RSpec.describe Channel::Whatsapp do end context 'when channel is created through manual setup' do - it 'does not setup webhooks' do - expect(Whatsapp::WebhookSetupService).not_to receive(:new) + it 'setups webhooks via after_commit callback' do + expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service) + expect(webhook_service).to receive(:perform) + # Explicitly set source to nil to test manual setup behavior (not embedded_signup) create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', provider_config: { 'business_account_id' => 'test_waba_id', - 'api_key' => 'test_access_token' + 'api_key' => 'test_access_token', + 'source' => nil }, validate_provider_config: false, sync_templates: false) @@ -157,12 +183,17 @@ RSpec.describe Channel::Whatsapp do end context 'when channel is not embedded_signup' do - it 'does not call WebhookTeardownService on destroy' do + it 'calls WebhookTeardownService on destroy' do + # Mock the setup service to prevent HTTP calls during creation + setup_service = instance_double(Whatsapp::WebhookSetupService) + allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service) + allow(setup_service).to receive(:perform) + channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', provider_config: { - 'source' => 'manual', + 'business_account_id' => 'test_waba_id', 'api_key' => 'test_access_token' }, validate_provider_config: false, diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb index 38856e252..d35d14cb9 100644 --- a/spec/services/whatsapp/webhook_setup_service_spec.rb +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -6,7 +6,8 @@ describe Whatsapp::WebhookSetupService do phone_number: '+1234567890', provider_config: { 'phone_number_id' => '123456789', - 'webhook_verify_token' => 'test_verify_token' + 'webhook_verify_token' => 'test_verify_token', + 'source' => 'embedded_signup' }, provider: 'whatsapp_cloud', sync_templates: false, @@ -261,7 +262,8 @@ describe Whatsapp::WebhookSetupService do 'phone_number_id' => '123456789', 'webhook_verify_token' => 'existing_verify_token', 'business_id' => 'existing_business_id', - 'waba_id' => 'existing_waba_id' + 'waba_id' => 'existing_waba_id', + 'source' => 'embedded_signup' }, provider: 'whatsapp_cloud', sync_templates: false, From 0346e9a2c728321b79c11375b032d1a6c12c8847 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 19 Jan 2026 18:31:46 +0530 Subject: [PATCH 03/96] fix: captain inbox modal shows wrong assistant data (#13302) --- .../dashboard/captain/assistants/inboxes/Index.vue | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/inboxes/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/inboxes/Index.vue index 62db5e495..aff213807 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/inboxes/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/inboxes/Index.vue @@ -1,5 +1,5 @@ From e13e3c873aaf812262a638ad728197fcc72bf515 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 19 Jan 2026 18:31:52 +0530 Subject: [PATCH 04/96] feat: add report download task (#13250) --- lib/tasks/download_report.rake | 183 +++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 lib/tasks/download_report.rake diff --git a/lib/tasks/download_report.rake b/lib/tasks/download_report.rake new file mode 100644 index 000000000..c68418432 --- /dev/null +++ b/lib/tasks/download_report.rake @@ -0,0 +1,183 @@ +# Download Report Rake Tasks +# +# Usage: +# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent +# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox +# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label +# +# The task will prompt for: +# - Account ID +# - Start Date (YYYY-MM-DD) +# - End Date (YYYY-MM-DD) +# - Timezone Offset (e.g., 0, 5.5, -5) +# - Business Hours (y/n) - whether to use business hours for time metrics +# +# Output: ___.csv + +require 'csv' + +# rubocop:disable Metrics/CyclomaticComplexity +# rubocop:disable Metrics/AbcSize +# rubocop:disable Metrics/MethodLength +# rubocop:disable Metrics/ModuleLength +module DownloadReportTasks + def self.prompt(message) + print "#{message}: " + $stdin.gets.chomp + end + + def self.collect_params + account_id = prompt('Enter Account ID') + abort 'Error: Account ID is required' if account_id.blank? + + account = Account.find_by(id: account_id) + abort "Error: Account with ID '#{account_id}' not found" unless account + + start_date = prompt('Enter Start Date (YYYY-MM-DD)') + abort 'Error: Start date is required' if start_date.blank? + + end_date = prompt('Enter End Date (YYYY-MM-DD)') + abort 'Error: End date is required' if end_date.blank? + + timezone_offset = prompt('Enter Timezone Offset (e.g., 0, 5.5, -5)') + timezone_offset = timezone_offset.blank? ? 0 : timezone_offset.to_f + + business_hours = prompt('Use Business Hours? (y/n)') + business_hours = business_hours.downcase == 'y' + + begin + tz = ActiveSupport::TimeZone[timezone_offset] + abort "Error: Invalid timezone offset '#{timezone_offset}'" unless tz + + since = tz.parse("#{start_date} 00:00:00").to_i.to_s + until_date = tz.parse("#{end_date} 23:59:59").to_i.to_s + rescue StandardError => e + abort "Error parsing dates: #{e.message}" + end + + { + account: account, + params: { since: since, until: until_date, timezone_offset: timezone_offset, business_hours: business_hours }, + start_date: start_date, + end_date: end_date + } + end + + def self.save_csv(filename, headers, rows) + CSV.open(filename, 'w') do |csv| + csv << headers + rows.each { |row| csv << row } + end + puts "Report saved to: #{filename}" + end + + def self.format_time(seconds) + return '' if seconds.nil? || seconds.zero? + + seconds.round(2) + end + + def self.download_agent_report + data = collect_params + account = data[:account] + + puts "\nGenerating agent report..." + builder = V2::Reports::AgentSummaryBuilder.new(account: account, params: data[:params]) + report = builder.build + + users = account.users.index_by(&:id) + headers = %w[id name email conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time] + + rows = report.map do |row| + user = users[row[:id]] + [ + row[:id], + user&.name || 'Unknown', + user&.email || 'Unknown', + row[:conversations_count], + row[:resolved_conversations_count], + format_time(row[:avg_resolution_time]), + format_time(row[:avg_first_response_time]), + format_time(row[:avg_reply_time]) + ] + end + + filename = "#{account.id}_agent_#{data[:start_date]}_#{data[:end_date]}.csv" + save_csv(filename, headers, rows) + end + + def self.download_inbox_report + data = collect_params + account = data[:account] + + puts "\nGenerating inbox report..." + builder = V2::Reports::InboxSummaryBuilder.new(account: account, params: data[:params]) + report = builder.build + + inboxes = account.inboxes.index_by(&:id) + headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time] + + rows = report.map do |row| + inbox = inboxes[row[:id]] + [ + row[:id], + inbox&.name || 'Unknown', + row[:conversations_count], + row[:resolved_conversations_count], + format_time(row[:avg_resolution_time]), + format_time(row[:avg_first_response_time]), + format_time(row[:avg_reply_time]) + ] + end + + filename = "#{account.id}_inbox_#{data[:start_date]}_#{data[:end_date]}.csv" + save_csv(filename, headers, rows) + end + + def self.download_label_report + data = collect_params + account = data[:account] + + puts "\nGenerating label report..." + builder = V2::Reports::LabelSummaryBuilder.new(account: account, params: data[:params]) + report = builder.build + + headers = %w[id name conversations_count resolved_conversations_count avg_resolution_time avg_first_response_time avg_reply_time] + + rows = report.map do |row| + [ + row[:id], + row[:name], + row[:conversations_count], + row[:resolved_conversations_count], + format_time(row[:avg_resolution_time]), + format_time(row[:avg_first_response_time]), + format_time(row[:avg_reply_time]) + ] + end + + filename = "#{account.id}_label_#{data[:start_date]}_#{data[:end_date]}.csv" + save_csv(filename, headers, rows) + end +end +# rubocop:enable Metrics/CyclomaticComplexity +# rubocop:enable Metrics/AbcSize +# rubocop:enable Metrics/MethodLength +# rubocop:enable Metrics/ModuleLength + +namespace :download_report do + desc 'Download agent summary report as CSV' + task agent: :environment do + DownloadReportTasks.download_agent_report + end + + desc 'Download inbox summary report as CSV' + task inbox: :environment do + DownloadReportTasks.download_inbox_report + end + + desc 'Download label summary report as CSV' + task label: :environment do + DownloadReportTasks.download_label_report + end +end From 457430e8d9f137a226d2f60459e6129c1dd63858 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 20 Jan 2026 20:32:23 +0400 Subject: [PATCH 05/96] fix: Remove `phone_number_id` param from WhatsApp media retrieval for incoming messages (#13319) Fixes https://github.com/chatwoot/chatwoot/issues/13317 Fixes an issue where WhatsApp attachment messages (images, audio, video, documents) were failing to download. Messages were being created but without attachments. The `phone_number_id` parameter was being passed to the `GET /` endpoint when downloading incoming media. According to Meta's documentation: > "Note that `phone_number_id` is optional. If included, the request will only be processed if the business phone number ID included in the query matches the ID of the business phone number **that the media was uploaded on**." For incoming messages, media is uploaded by the customer, not by the business phone number. Passing the business's `phone_number_id` causes validation to fail with error: `Param phone_number_id is not a valid whatsapp business phone number id ID` This PR removes the `phone_number_id` parameter from the media URL request for incoming messages. --- .../incoming_message_whatsapp_cloud_service.rb | 5 +---- .../whatsapp/providers/whatsapp_cloud_service.rb | 6 ++---- .../incoming_message_whatsapp_cloud_service_spec.rb | 10 ++-------- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb index f8ac8c85a..164c3ac12 100644 --- a/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb +++ b/app/services/whatsapp/incoming_message_whatsapp_cloud_service.rb @@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB def download_attachment_file(attachment_payload) url_response = HTTParty.get( - inbox.channel.media_url( - attachment_payload[:id], - inbox.channel.provider_config['phone_number_id'] - ), + inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers ) # This url response will be failure if the access token has expired. diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 6f2ead579..5b4c26196 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi csat_template_service.get_template_status(template_name) end - def media_url(media_id, phone_number_id = nil) - url = "#{api_base_path}/v13.0/#{media_id}" - url += "?phone_number_id=#{phone_number_id}" if phone_number_id - url + def media_url(media_id) + "#{api_base_path}/v13.0/#{media_id}" end private diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb index b162250bf..2ac3bb651 100644 --- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb @@ -41,10 +41,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do it 'increments reauthorization count if fetching attachment fails' do stub_request( :get, - whatsapp_channel.media_url( - 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', - whatsapp_channel.provider_config['phone_number_id'] - ) + whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683') ).to_return( status: 401 ) @@ -112,10 +109,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do def stub_media_url_request stub_request( :get, - whatsapp_channel.media_url( - 'b1c68f38-8734-4ad3-b4a1-ef0c10d683', - whatsapp_channel.provider_config['phone_number_id'] - ) + whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683') ).to_return( status: 200, body: { From ecd4892a23535c92c4f3752340e4ad677b1433c9 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 20 Jan 2026 08:43:11 -0800 Subject: [PATCH 06/96] Bump version to 4.10.1 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index 2da431623..ad96464c4 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.10.0 +4.10.1 diff --git a/config/app.yml b/config/app.yml index 98e523795..c81f2102f 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.10.0' + version: '4.10.1' development: <<: *shared diff --git a/package.json b/package.json index 75b6c3d38..4e263a80a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.10.0", + "version": "4.10.1", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From 6a482926b4abca4792bd228001021ce190ae8187 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 21 Jan 2026 13:39:07 +0530 Subject: [PATCH 07/96] feat: new Captain Editor (#13235) Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Co-authored-by: Vishnu Narayanan Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin Co-authored-by: aakashb95 --- .../super_admin/app_configs_controller.rb | 3 +- app/helpers/super_admin/features.yml | 13 +- app/javascript/dashboard/api/captain/tasks.js | 107 ++++++ .../dashboard/api/integrations/openapi.js | 81 ----- .../dashboard/assets/scss/_next-colors.scss | 26 ++ .../components/widgets/AIAssistanceButton.vue | 160 --------- .../widgets/AIAssistanceCTAButton.vue | 103 ------ .../components/widgets/AIAssistanceModal.vue | 118 ------- .../components/widgets/AICTAModal.vue | 130 ------- .../components/widgets/AttachmentsPreview.vue | 4 +- .../widgets/WootWriter/CopilotEditor.vue | 253 ++++++++++++++ .../widgets/WootWriter/CopilotMenuBar.vue | 259 ++++++++++++++ .../WootWriter/CopilotReplyBottomPanel.vue | 51 +++ .../components/widgets/WootWriter/Editor.vue | 73 +++- .../widgets/WootWriter/EditorModeToggle.vue | 20 +- .../widgets/WootWriter/ReplyBottomPanel.vue | 11 +- .../widgets/WootWriter/ReplyTopPanel.vue | 79 ++++- .../conversation/CopilotEditorSection.vue | 99 ++++++ .../MessageSignatureMissingAlert.vue | 2 +- .../widgets/conversation/MessagesView.vue | 36 +- .../widgets/conversation/ReplyBox.vue | 305 ++++++++++------ .../conversation/LabelSuggestion.vue | 8 +- .../conversation/copilot/CaptainLoader.vue | 33 ++ .../spec/useConversationHotKeys.spec.js | 12 +- .../commands/useConversationHotKeys.js | 29 +- .../dashboard/composables/spec/useAI.spec.js | 122 ------- .../composables/spec/useCaptain.spec.js | 213 ++++++++++++ app/javascript/dashboard/composables/useAI.js | 203 ----------- .../dashboard/composables/useCaptain.js | 226 +++++++++++- .../dashboard/composables/useCopilotReply.js | 162 +++++++++ .../dashboard/composables/utils/useKbd.js | 21 +- app/javascript/dashboard/constants/editor.js | 27 +- app/javascript/dashboard/featureFlags.js | 1 + .../helper/AnalyticsHelper/events.js | 1 + .../dashboard/helper/editorHelper.js | 11 +- .../helper/specs/editorHelper.spec.js | 56 --- .../i18n/locale/en/conversation.json | 3 +- .../dashboard/i18n/locale/en/general.json | 2 + .../i18n/locale/en/integrations.json | 24 +- app/javascript/shared/constants/openai.js | 11 - app/models/integrations/hook.rb | 11 +- app/policies/captain/tasks_policy.rb | 21 ++ .../conversation_llm_formatter.rb | 32 +- config/features.yml | 3 + config/locales/en.yml | 3 + config/routes.rb | 7 + ...ble_captain_tasks_for_existing_accounts.rb | 12 + db/schema.rb | 2 +- .../v1/accounts/captain/tasks_controller.rb | 71 ++++ enterprise/config/premium_features.yml | 2 +- .../enterprise/captain/base_task_service.rb | 32 ++ .../integrations/openai_processor_service.rb | 82 ----- lib/captain/base_task_service.rb | 181 ++++++++++ lib/captain/follow_up_service.rb | 106 ++++++ lib/captain/label_suggestion_service.rb | 93 +++++ lib/captain/reply_suggestion_service.rb | 40 +++ lib/captain/rewrite_service.rb | 59 ++++ lib/captain/summary_service.rb | 19 + lib/integrations/llm_base_service.rb | 3 +- .../fix_spelling_grammar.liquid | 17 + .../openai/openai_prompts/improve.liquid | 43 +++ .../openai_prompts/label_suggestion.liquid | 2 +- .../openai/openai_prompts/reply.liquid | 35 ++ .../openai/openai_prompts/reply.txt | 1 - .../openai/openai_prompts/summary.liquid | 10 +- .../openai/openai_prompts/summary.txt | 1 - .../openai/openai_prompts/tone_rewrite.liquid | 35 ++ lib/integrations/openai/processor_service.rb | 138 -------- lib/llm/config.rb | 3 +- package.json | 5 +- pnpm-lock.yaml | 20 +- .../lib/captain/base_task_service_spec.rb | 169 +++++++++ .../openai/processor_service_spec.rb | 120 ------- spec/lib/captain/base_task_service_spec.rb | 325 ++++++++++++++++++ spec/lib/captain/follow_up_service_spec.rb | 164 +++++++++ .../captain/label_suggestion_service_spec.rb | 169 +++++++++ .../captain/reply_suggestion_service_spec.rb | 92 +++++ spec/lib/captain/rewrite_service_spec.rb | 166 +++++++++ spec/lib/captain/summary_service_spec.rb | 55 +++ .../openai/processor_service_spec.rb | 201 ----------- spec/models/integrations/hook_spec.rb | 21 -- tailwind.config.js | 1 + theme/colors.js | 15 + 83 files changed, 3887 insertions(+), 1798 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/tasks.js delete mode 100644 app/javascript/dashboard/api/integrations/openapi.js delete mode 100644 app/javascript/dashboard/components/widgets/AIAssistanceButton.vue delete mode 100644 app/javascript/dashboard/components/widgets/AIAssistanceCTAButton.vue delete mode 100644 app/javascript/dashboard/components/widgets/AIAssistanceModal.vue delete mode 100644 app/javascript/dashboard/components/widgets/AICTAModal.vue create mode 100644 app/javascript/dashboard/components/widgets/WootWriter/CopilotEditor.vue create mode 100644 app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue create mode 100644 app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/CopilotEditorSection.vue create mode 100644 app/javascript/dashboard/components/widgets/conversation/copilot/CaptainLoader.vue delete mode 100644 app/javascript/dashboard/composables/spec/useAI.spec.js create mode 100644 app/javascript/dashboard/composables/spec/useCaptain.spec.js delete mode 100644 app/javascript/dashboard/composables/useAI.js create mode 100644 app/javascript/dashboard/composables/useCopilotReply.js delete mode 100644 app/javascript/shared/constants/openai.js create mode 100644 app/policies/captain/tasks_policy.rb create mode 100644 db/migrate/20260120121402_enable_captain_tasks_for_existing_accounts.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb create mode 100644 enterprise/lib/enterprise/captain/base_task_service.rb delete mode 100644 enterprise/lib/enterprise/integrations/openai_processor_service.rb create mode 100644 lib/captain/base_task_service.rb create mode 100644 lib/captain/follow_up_service.rb create mode 100644 lib/captain/label_suggestion_service.rb create mode 100644 lib/captain/reply_suggestion_service.rb create mode 100644 lib/captain/rewrite_service.rb create mode 100644 lib/captain/summary_service.rb create mode 100644 lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid create mode 100644 lib/integrations/openai/openai_prompts/improve.liquid rename enterprise/lib/enterprise/integrations/openai_prompts/label_suggestion.txt => lib/integrations/openai/openai_prompts/label_suggestion.liquid (88%) create mode 100644 lib/integrations/openai/openai_prompts/reply.liquid delete mode 100644 lib/integrations/openai/openai_prompts/reply.txt rename enterprise/lib/enterprise/integrations/openai_prompts/summary.txt => lib/integrations/openai/openai_prompts/summary.liquid (93%) delete mode 100644 lib/integrations/openai/openai_prompts/summary.txt create mode 100644 lib/integrations/openai/openai_prompts/tone_rewrite.liquid delete mode 100644 lib/integrations/openai/processor_service.rb create mode 100644 spec/enterprise/lib/captain/base_task_service_spec.rb delete mode 100644 spec/enterprise/lib/integrations/openai/processor_service_spec.rb create mode 100644 spec/lib/captain/base_task_service_spec.rb create mode 100644 spec/lib/captain/follow_up_service_spec.rb create mode 100644 spec/lib/captain/label_suggestion_service_spec.rb create mode 100644 spec/lib/captain/reply_suggestion_service_spec.rb create mode 100644 spec/lib/captain/rewrite_service_spec.rb create mode 100644 spec/lib/captain/summary_service_spec.rb delete mode 100644 spec/lib/integrations/openai/processor_service_spec.rb diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index ec51305b5..e9d27c67a 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -49,7 +49,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], - 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] + 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN], + 'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT] } @allowed_configs = mapping.fetch( diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index 34c7a8138..f21a97f78 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -2,13 +2,6 @@ # No need to replicate the same values in two places # ------- Premium Features ------- # -captain: - name: 'Captain' - description: 'Enable AI-powered conversations with your customers.' - enabled: <%= (ChatwootHub.pricing_plan != 'community') %> - icon: 'icon-captain' - config_key: 'captain' - enterprise: true saml: name: 'SAML SSO' description: 'Configuration for controlling SAML Single Sign-On availability' @@ -48,6 +41,12 @@ help_center: description: 'Allow agents to create help center articles and publish them in a portal.' enabled: true icon: 'icon-book-2-line' +captain: + name: 'Captain' + description: 'Enable AI-powered conversations with your customers.' + enabled: true + icon: 'icon-captain' + config_key: 'captain' # ------- Communication Channels ------- # live_chat: diff --git a/app/javascript/dashboard/api/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js new file mode 100644 index 000000000..1b5a38335 --- /dev/null +++ b/app/javascript/dashboard/api/captain/tasks.js @@ -0,0 +1,107 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +/** + * A client for the Captain Tasks API. + * @extends ApiClient + */ +class TasksAPI extends ApiClient { + /** + * Creates a new TasksAPI instance. + */ + constructor() { + super('captain/tasks', { accountScoped: true }); + } + + /** + * Rewrites content with a specific operation. + * @param {Object} options - The rewrite options. + * @param {string} options.content - The content to rewrite. + * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc). + * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve'). + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the rewritten content. + */ + rewrite({ content, operation, conversationId }, signal) { + return axios.post( + `${this.url}/rewrite`, + { + content, + operation, + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Summarizes a conversation. + * @param {string} conversationId - The conversation ID to summarize. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the summary. + */ + summarize(conversationId, signal) { + return axios.post( + `${this.url}/summarize`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets a reply suggestion for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the reply suggestion. + */ + replySuggestion(conversationId, signal) { + return axios.post( + `${this.url}/reply_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets label suggestions for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with label suggestions. + */ + labelSuggestion(conversationId, signal) { + return axios.post( + `${this.url}/label_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Sends a follow-up message to continue refining a previous task result. + * @param {Object} options - The follow-up options. + * @param {Object} options.followUpContext - The follow-up context from a previous task. + * @param {string} options.message - The follow-up message/request from the user. + * @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context. + */ + followUp({ followUpContext, message, conversationId }, signal) { + return axios.post( + `${this.url}/follow_up`, + { + follow_up_context: followUpContext, + message, + conversation_display_id: conversationId, + }, + { signal } + ); + } +} + +export default new TasksAPI(); diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js deleted file mode 100644 index 3fcf241ee..000000000 --- a/app/javascript/dashboard/api/integrations/openapi.js +++ /dev/null @@ -1,81 +0,0 @@ -/* global axios */ - -import ApiClient from '../ApiClient'; - -/** - * Represents the data object for a OpenAI hook. - * @typedef {Object} ConversationMessageData - * @property {string} [tone] - The tone of the message. - * @property {string} [content] - The content of the message. - * @property {string} [conversation_display_id] - The display ID of the conversation (optional). - */ - -/** - * A client for the OpenAI API. - * @extends ApiClient - */ -class OpenAIAPI extends ApiClient { - /** - * Creates a new OpenAIAPI instance. - */ - constructor() { - super('integrations', { accountScoped: true }); - - /** - * The conversation events supported by the API. - * @type {string[]} - */ - this.conversation_events = [ - 'summarize', - 'reply_suggestion', - 'label_suggestion', - ]; - - /** - * The message events supported by the API. - * @type {string[]} - */ - this.message_events = ['rephrase']; - } - - /** - * Processes an event using the OpenAI API. - * @param {Object} options - The options for the event. - * @param {string} [options.type='rephrase'] - The type of event to process. - * @param {string} [options.content] - The content of the event. - * @param {string} [options.tone] - The tone of the event. - * @param {string} [options.conversationId] - The ID of the conversation to process the event for. - * @param {string} options.hookId - The ID of the hook to use for processing the event. - * @returns {Promise} A promise that resolves with the result of the event processing. - */ - processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) { - /** - * @type {ConversationMessageData} - */ - let data = { - tone, - content, - }; - - // Always include conversation_display_id when available for session tracking - if (conversationId) { - data.conversation_display_id = conversationId; - } - - // For conversation-level events, only send conversation_display_id - if (this.conversation_events.includes(type)) { - data = { - conversation_display_id: conversationId, - }; - } - - return axios.post(`${this.url}/hooks/${hookId}/process_event`, { - event: { - name: type, - data, - }, - }); - } -} - -export default new OpenAIAPI(); diff --git a/app/javascript/dashboard/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index f23c01d42..4d7975a32 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -94,6 +94,19 @@ --gray-11: 100 100 100; --gray-12: 32 32 32; + --violet-1: 253 252 254; + --violet-2: 250 248 255; + --violet-3: 244 240 254; + --violet-4: 235 228 255; + --violet-5: 225 217 255; + --violet-6: 212 202 254; + --violet-7: 194 178 248; + --violet-8: 169 153 236; + --violet-9: 110 86 207; + --violet-10: 100 84 196; + --violet-11: 101 85 183; + --violet-12: 47 38 95; + --background-color: 253 253 253; --text-blue: 8 109 224; --border-container: 236 236 236; @@ -209,6 +222,19 @@ --gray-11: 180 180 180; --gray-12: 238 238 238; + --violet-1: 20 17 31; + --violet-2: 27 21 37; + --violet-3: 41 31 67; + --violet-4: 50 37 85; + --violet-5: 60 46 105; + --violet-6: 71 56 135; + --violet-7: 86 70 151; + --violet-8: 110 86 171; + --violet-9: 110 86 207; + --violet-10: 125 109 217; + --violet-11: 169 153 236; + --violet-12: 226 221 254; + --background-color: 18 18 19; --border-strong: 52 52 52; --border-weak: 38 38 42; diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue deleted file mode 100644 index f7a94fb85..000000000 --- a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceCTAButton.vue b/app/javascript/dashboard/components/widgets/AIAssistanceCTAButton.vue deleted file mode 100644 index 6fbdfe6e7..000000000 --- a/app/javascript/dashboard/components/widgets/AIAssistanceCTAButton.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue b/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue deleted file mode 100644 index 04bba8f59..000000000 --- a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue +++ /dev/null @@ -1,118 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/components/widgets/AICTAModal.vue b/app/javascript/dashboard/components/widgets/AICTAModal.vue deleted file mode 100644 index 13e201326..000000000 --- a/app/javascript/dashboard/components/widgets/AICTAModal.vue +++ /dev/null @@ -1,130 +0,0 @@ - - - diff --git a/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue b/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue index 75afb5263..c924dd65d 100644 --- a/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue +++ b/app/javascript/dashboard/components/widgets/AttachmentsPreview.vue @@ -46,11 +46,11 @@ const fileName = file => {