Merge branch 'release/4.10.1'

This commit is contained in:
Sojan Jose
2026-01-20 08:44:14 -08:00
15 changed files with 315 additions and 42 deletions

View File

@@ -1 +1 @@
4.10.0 4.10.1

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref, nextTick } from 'vue'; import { computed, watch, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store'; import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -49,10 +49,14 @@ const handleCreateClose = () => {
selectedInbox.value = null; selectedInbox.value = null;
}; };
onMounted(() => watch(
store.dispatch('captainInboxes/get', { assistantId,
assistantId: assistantId.value, newId => {
}) store.dispatch('captainInboxes/get', {
assistantId: newId,
});
},
{ immediate: true }
); );
</script> </script>

View File

@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates after_create :sync_templates
before_destroy :teardown_webhooks before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name def name
'Whatsapp' 'Whatsapp'
@@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord
def teardown_webhooks def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform Whatsapp::WebhookTeardownService.new(self).perform
end 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 end

View File

@@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService
validate_token_access(access_token) validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info) 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 channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel) check_channel_health_and_prompt_reauth(channel)
channel channel

View File

@@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
def download_attachment_file(attachment_payload) def download_attachment_file(attachment_payload)
url_response = HTTParty.get( url_response = HTTParty.get(
inbox.channel.media_url( inbox.channel.media_url(attachment_payload[:id]),
attachment_payload[:id],
inbox.channel.provider_config['phone_number_id']
),
headers: inbox.channel.api_headers headers: inbox.channel.api_headers
) )
# This url response will be failure if the access token has expired. # This url response will be failure if the access token has expired.

View File

@@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
csat_template_service.get_template_status(template_name) csat_template_service.get_template_status(template_name)
end end
def media_url(media_id, phone_number_id = nil) def media_url(media_id)
url = "#{api_base_path}/v13.0/#{media_id}" "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end end
private private

View File

@@ -1,5 +1,5 @@
shared: &shared shared: &shared
version: '4.10.0' version: '4.10.1'
development: development:
<<: *shared <<: *shared

View File

@@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
end end
description 'Search conversations based on parameters' 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 :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' param :labels, type: :string, desc: 'Labels available'
def execute(status: nil, contact_id: nil, priority: nil, labels: nil) def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
@@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
<<~RESPONSE <<~RESPONSE
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"} #{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 RESPONSE
end end
@@ -34,12 +34,20 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
def get_conversations(status, contact_id, priority, labels) def get_conversations(status, contact_id, priority, labels)
conversations = permissible_conversations conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present? conversations = conversations.where(contact_id: contact_id) if contact_id.present?
conversations = conversations.where(status: status) if status.present? conversations = conversations.where(status: status) if valid_status?(status)
conversations = conversations.where(priority: priority) if priority.present? conversations = conversations.where(priority: priority) if valid_priority?(priority)
conversations = conversations.tagged_with(labels, any: true) if labels.present? conversations = conversations.tagged_with(labels, any: true) if labels.present?
conversations conversations
end 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 def permissible_conversations
Conversations::PermissionFilterService.new( Conversations::PermissionFilterService.new(
@assistant.account.conversations, @assistant.account.conversations,

View File

@@ -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: <account_id>_<type>_<start_date>_<end_date>.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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@chatwoot/chatwoot", "name": "@chatwoot/chatwoot",
"version": "4.10.0", "version": "4.10.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"eslint": "eslint app/**/*.{js,vue}", "eslint": "eslint app/**/*.{js,vue}",

View File

@@ -119,5 +119,42 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
result = service.execute(status: 'snoozed') result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found') expect(result).to eq('No conversations found')
end 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
end end

View File

@@ -96,8 +96,16 @@ FactoryBot.define do
channel_whatsapp.define_singleton_method(:sync_templates) { nil } unless options.sync_templates 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 channel_whatsapp.define_singleton_method(:validate_provider_config) { nil } unless options.validate_provider_config
if channel_whatsapp.provider == 'whatsapp_cloud' if channel_whatsapp.provider == 'whatsapp_cloud'
channel_whatsapp.provider_config = channel_whatsapp.provider_config.merge({ 'api_key' => 'test_key', 'phone_number_id' => '123456789', # Add 'source' => 'embedded_signup' to skip after_commit :setup_webhooks callback in tests
'business_account_id' => '123456789' }) # 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
end end

View File

@@ -47,16 +47,39 @@ RSpec.describe Channel::Whatsapp do
end end
describe 'webhook_verify_token' do 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 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), channel = create(:channel_whatsapp,
validate_provider_config: false, sync_templates: false) 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 expect(channel.provider_config['webhook_verify_token']).not_to be_nil
end end
it 'does not generate webhook_verify_token if present' do 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), channel = create(:channel_whatsapp,
validate_provider_config: false, sync_templates: false) 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' expect(channel.provider_config['webhook_verify_token']).to eq '123'
end end
@@ -91,15 +114,18 @@ RSpec.describe Channel::Whatsapp do
end end
context 'when channel is created through manual setup' do context 'when channel is created through manual setup' do
it 'does not setup webhooks' do it 'setups webhooks via after_commit callback' do
expect(Whatsapp::WebhookSetupService).not_to receive(:new) 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, create(:channel_whatsapp,
account: account, account: account,
provider: 'whatsapp_cloud', provider: 'whatsapp_cloud',
provider_config: { provider_config: {
'business_account_id' => 'test_waba_id', 'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token' 'api_key' => 'test_access_token',
'source' => nil
}, },
validate_provider_config: false, validate_provider_config: false,
sync_templates: false) sync_templates: false)
@@ -157,12 +183,17 @@ RSpec.describe Channel::Whatsapp do
end end
context 'when channel is not embedded_signup' do 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, channel = create(:channel_whatsapp,
account: account, account: account,
provider: 'whatsapp_cloud', provider: 'whatsapp_cloud',
provider_config: { provider_config: {
'source' => 'manual', 'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token' 'api_key' => 'test_access_token'
}, },
validate_provider_config: false, validate_provider_config: false,

View File

@@ -41,10 +41,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
it 'increments reauthorization count if fetching attachment fails' do it 'increments reauthorization count if fetching attachment fails' do
stub_request( stub_request(
:get, :get,
whatsapp_channel.media_url( whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
whatsapp_channel.provider_config['phone_number_id']
)
).to_return( ).to_return(
status: 401 status: 401
) )
@@ -112,10 +109,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
def stub_media_url_request def stub_media_url_request
stub_request( stub_request(
:get, :get,
whatsapp_channel.media_url( whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
whatsapp_channel.provider_config['phone_number_id']
)
).to_return( ).to_return(
status: 200, status: 200,
body: { body: {

View File

@@ -6,7 +6,8 @@ describe Whatsapp::WebhookSetupService do
phone_number: '+1234567890', phone_number: '+1234567890',
provider_config: { provider_config: {
'phone_number_id' => '123456789', 'phone_number_id' => '123456789',
'webhook_verify_token' => 'test_verify_token' 'webhook_verify_token' => 'test_verify_token',
'source' => 'embedded_signup'
}, },
provider: 'whatsapp_cloud', provider: 'whatsapp_cloud',
sync_templates: false, sync_templates: false,
@@ -261,7 +262,8 @@ describe Whatsapp::WebhookSetupService do
'phone_number_id' => '123456789', 'phone_number_id' => '123456789',
'webhook_verify_token' => 'existing_verify_token', 'webhook_verify_token' => 'existing_verify_token',
'business_id' => 'existing_business_id', 'business_id' => 'existing_business_id',
'waba_id' => 'existing_waba_id' 'waba_id' => 'existing_waba_id',
'source' => 'embedded_signup'
}, },
provider: 'whatsapp_cloud', provider: 'whatsapp_cloud',
sync_templates: false, sync_templates: false,