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/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 @@
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/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/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/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/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
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}",
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
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/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: {
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,