Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket
This commit is contained in:
@@ -76,7 +76,7 @@ jobs:
|
|||||||
bundle install
|
bundle install
|
||||||
|
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '23.7'
|
node-version: '24.12'
|
||||||
- node/install-pnpm
|
- node/install-pnpm
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '23.7'
|
node-version: '24.12'
|
||||||
- node/install-pnpm
|
- node/install-pnpm
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '23.7'
|
node-version: '24.12'
|
||||||
- node/install-pnpm
|
- node/install-pnpm
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
dockerfile: .devcontainer/Dockerfile.base
|
dockerfile: .devcontainer/Dockerfile.base
|
||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '23.7.0'
|
NODE_VERSION: '24.12.0'
|
||||||
RUBY_VERSION: '3.4.4'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
dockerfile: .devcontainer/Dockerfile
|
dockerfile: .devcontainer/Dockerfile
|
||||||
args:
|
args:
|
||||||
VARIANT: 'ubuntu-22.04'
|
VARIANT: 'ubuntu-22.04'
|
||||||
NODE_VERSION: '23.7.0'
|
NODE_VERSION: '24.12.0'
|
||||||
RUBY_VERSION: '3.4.4'
|
RUBY_VERSION: '3.4.4'
|
||||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||||
USER_UID: '1000'
|
USER_UID: '1000'
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 23
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 23
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
run: pnpm i
|
run: pnpm i
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 23
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
run: pnpm i
|
run: pnpm i
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 23
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install pnpm dependencies
|
- name: Install pnpm dependencies
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 23
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: pnpm
|
- name: pnpm
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.9.1
|
4.9.2
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.3
|
3.5.0
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
class V2::Reports::ChannelSummaryBuilder
|
||||||
|
include DateRangeHelper
|
||||||
|
|
||||||
|
pattr_initialize [:account!, :params!]
|
||||||
|
|
||||||
|
def build
|
||||||
|
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def conversations_by_channel_and_status
|
||||||
|
account.conversations
|
||||||
|
.joins(:inbox)
|
||||||
|
.where(created_at: range)
|
||||||
|
.group('inboxes.channel_type', 'conversations.status')
|
||||||
|
.count
|
||||||
|
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||||
|
grouped[channel_type] ||= {}
|
||||||
|
grouped[channel_type][status] = count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_channel_stats(status_counts)
|
||||||
|
open_count = status_counts['open'] || 0
|
||||||
|
resolved_count = status_counts['resolved'] || 0
|
||||||
|
pending_count = status_counts['pending'] || 0
|
||||||
|
snoozed_count = status_counts['snoozed'] || 0
|
||||||
|
|
||||||
|
{
|
||||||
|
open: open_count,
|
||||||
|
resolved: resolved_count,
|
||||||
|
pending: pending_count,
|
||||||
|
snoozed: snoozed_count,
|
||||||
|
total: open_count + resolved_count + pending_count + snoozed_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :current_account
|
||||||
|
before_action :authorize_account_update, only: [:update]
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: preferences_payload
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
params_to_update = captain_params
|
||||||
|
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
||||||
|
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
||||||
|
@current_account.save!
|
||||||
|
|
||||||
|
render json: preferences_payload
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def preferences_payload
|
||||||
|
{
|
||||||
|
providers: Llm::Models.providers,
|
||||||
|
models: Llm::Models.models,
|
||||||
|
features: features_with_account_preferences
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_account_update
|
||||||
|
authorize @current_account, :update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_params
|
||||||
|
permitted = {}
|
||||||
|
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
|
||||||
|
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
|
||||||
|
permitted
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_captain_models
|
||||||
|
existing_models = @current_account.captain_models || {}
|
||||||
|
existing_models.merge(permitted_captain_models)
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_captain_features
|
||||||
|
existing_features = @current_account.captain_features || {}
|
||||||
|
existing_features.merge(permitted_captain_features)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_captain_models
|
||||||
|
params.require(:captain_models).permit(
|
||||||
|
:editor, :assistant, :copilot, :label_suggestion,
|
||||||
|
:audio_transcription, :help_center_search
|
||||||
|
).to_h.stringify_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_captain_features
|
||||||
|
params.require(:captain_features).permit(
|
||||||
|
:editor, :assistant, :copilot, :label_suggestion,
|
||||||
|
:audio_transcription, :help_center_search
|
||||||
|
).to_h.stringify_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def features_with_account_preferences
|
||||||
|
preferences = Current.account.captain_preferences
|
||||||
|
account_features = preferences[:features] || {}
|
||||||
|
account_models = preferences[:models] || {}
|
||||||
|
|
||||||
|
Llm::Models.feature_keys.index_with do |feature_key|
|
||||||
|
config = Llm::Models.feature_config(feature_key)
|
||||||
|
config.merge(
|
||||||
|
enabled: account_features[feature_key] == true,
|
||||||
|
selected: account_models[feature_key] || config[:default]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,38 +1,27 @@
|
|||||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
|
||||||
DEFAULT_LANGUAGE = 'en'.freeze
|
|
||||||
|
|
||||||
before_action :fetch_inbox
|
before_action :fetch_inbox
|
||||||
before_action :validate_whatsapp_channel
|
before_action :validate_whatsapp_channel
|
||||||
|
|
||||||
def show
|
def show
|
||||||
template = @inbox.csat_config&.dig('template')
|
service = CsatTemplateManagementService.new(@inbox)
|
||||||
return render json: { template_exists: false } unless template
|
result = service.template_status
|
||||||
|
|
||||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
if result[:service_error]
|
||||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||||
|
else
|
||||||
render_template_status_response(status_result, template_name)
|
render json: result
|
||||||
rescue StandardError => e
|
end
|
||||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
|
||||||
render json: { error: e.message }, status: :internal_server_error
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
template_params = extract_template_params
|
template_params = extract_template_params
|
||||||
return render_missing_message_error if template_params[:message].blank?
|
return render_missing_message_error if template_params[:message].blank?
|
||||||
|
|
||||||
# Delete existing template even though we are using a new one.
|
service = CsatTemplateManagementService.new(@inbox)
|
||||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
result = service.create_template(template_params)
|
||||||
delete_existing_template_if_needed
|
|
||||||
|
|
||||||
result = create_template_via_provider(template_params)
|
|
||||||
render_template_creation_result(result)
|
render_template_creation_result(result)
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
|
||||||
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_whatsapp_channel
|
def validate_whatsapp_channel
|
||||||
return if @inbox.whatsapp?
|
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
|
||||||
|
|
||||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
|
||||||
status: :bad_request
|
status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_template_via_provider(template_params)
|
|
||||||
template_config = {
|
|
||||||
message: template_params[:message],
|
|
||||||
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
|
||||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
|
||||||
language: template_params[:language] || DEFAULT_LANGUAGE,
|
|
||||||
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@inbox.channel.provider_service.create_csat_template(template_config)
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_template_creation_result(result)
|
def render_template_creation_result(result)
|
||||||
if result[:success]
|
if result[:success]
|
||||||
render_successful_template_creation(result)
|
render_successful_template_creation(result)
|
||||||
|
elsif result[:service_error]
|
||||||
|
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||||
else
|
else
|
||||||
render_failed_template_creation(result)
|
render_failed_template_creation(result)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_successful_template_creation(result)
|
def render_successful_template_creation(result)
|
||||||
render json: {
|
if @inbox.twilio_whatsapp?
|
||||||
template: {
|
render json: {
|
||||||
name: result[:template_name],
|
template: {
|
||||||
template_id: result[:template_id],
|
friendly_name: result[:friendly_name],
|
||||||
status: 'PENDING',
|
content_sid: result[:content_sid],
|
||||||
language: result[:language] || DEFAULT_LANGUAGE
|
status: result[:status] || 'pending',
|
||||||
}
|
language: result[:language] || 'en'
|
||||||
}, status: :created
|
}
|
||||||
|
}, status: :created
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
template: {
|
||||||
|
name: result[:template_name],
|
||||||
|
template_id: result[:template_id],
|
||||||
|
status: 'PENDING',
|
||||||
|
language: result[:language] || 'en'
|
||||||
|
}
|
||||||
|
}, status: :created
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_failed_template_creation(result)
|
def render_failed_template_creation(result)
|
||||||
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
|||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_existing_template_if_needed
|
|
||||||
template = @inbox.csat_config&.dig('template')
|
|
||||||
return true if template.blank?
|
|
||||||
|
|
||||||
template_name = template['name']
|
|
||||||
return true if template_name.blank?
|
|
||||||
|
|
||||||
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
|
||||||
return true unless template_status[:success]
|
|
||||||
|
|
||||||
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
|
||||||
if deletion_result[:success]
|
|
||||||
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
|
||||||
true
|
|
||||||
else
|
|
||||||
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
|
||||||
false
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_template_status_response(status_result, template_name)
|
|
||||||
if status_result[:success]
|
|
||||||
render json: {
|
|
||||||
template_exists: true,
|
|
||||||
template_name: template_name,
|
|
||||||
status: status_result[:template][:status],
|
|
||||||
template_id: status_result[:template][:id]
|
|
||||||
}
|
|
||||||
else
|
|
||||||
render json: {
|
|
||||||
template_exists: false,
|
|
||||||
error: 'Template not found'
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_whatsapp_error(response_body)
|
def parse_whatsapp_error(response_body)
|
||||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||||
{ survey_rules: [:operator, { values: [] }],
|
{ survey_rules: [:operator, { values: [] }],
|
||||||
template: [:name, :template_id, :created_at, :language] }] }]
|
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params(channel_attributes = [])
|
def permitted_params(channel_attributes = [])
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conversations_summary
|
||||||
|
@report_data = generate_conversations_report
|
||||||
|
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_traffic
|
def conversation_traffic
|
||||||
@report_data = generate_conversations_heatmap_report
|
@report_data = generate_conversations_heatmap_report
|
||||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||||
|
|
||||||
def agent
|
def agent
|
||||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||||
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def channel
|
||||||
|
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
|
||||||
|
|
||||||
|
render_report_with(V2::Reports::ChannelSummaryBuilder)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
|||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:since, :until, :business_hours)
|
params.permit(:since, :until, :business_hours)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def date_range_too_long?
|
||||||
|
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
|
||||||
|
|
||||||
|
since_time = Time.zone.at(permitted_params[:since].to_i)
|
||||||
|
until_time = Time.zone.at(permitted_params[:until].to_i)
|
||||||
|
(until_time - since_time) > 6.months
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_conversations_report
|
||||||
|
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
|
||||||
|
summary = builder.summary
|
||||||
|
|
||||||
|
[generate_conversation_report_metrics(summary)]
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_params(base_params)
|
def build_params(base_params)
|
||||||
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
|
|||||||
report[:resolved_conversations_count]
|
report[:resolved_conversations_count]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_conversation_report_metrics(summary)
|
||||||
|
[
|
||||||
|
summary[:conversations_count],
|
||||||
|
summary[:incoming_messages_count],
|
||||||
|
summary[:outgoing_messages_count],
|
||||||
|
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
|
||||||
|
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
|
||||||
|
summary[:resolutions_count],
|
||||||
|
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainPreferences extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/preferences', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get() {
|
||||||
|
return axios.get(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences(data) {
|
||||||
|
return axios.put(this.url, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainPreferences();
|
||||||
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
|
||||||
|
return axios.get(`${this.url}/conversations_summary`, {
|
||||||
|
params: { since, until, business_hours: businessHours },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||||
return axios.get(`${this.url}/conversation_traffic`, {
|
return axios.get(`${this.url}/conversation_traffic`, {
|
||||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
|
editorKey: { type: String, default: '' },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
placeholder: { type: String, default: '' },
|
placeholder: { type: String, default: '' },
|
||||||
focusOnMount: { type: Boolean, default: false },
|
focusOnMount: { type: Boolean, default: false },
|
||||||
@@ -96,6 +97,7 @@ watch(
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<WootEditor
|
<WootEditor
|
||||||
|
:editor-id="editorKey"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:focus-on-mount="focusOnMount"
|
:focus-on-mount="focusOnMount"
|
||||||
@@ -152,6 +154,13 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror-menubar {
|
||||||
|
width: fit-content !important;
|
||||||
|
position: relative !important;
|
||||||
|
top: unset !important;
|
||||||
|
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -44,6 +44,12 @@ const emit = defineEmits([
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attachmentId = ref(0);
|
||||||
|
const generateUid = () => {
|
||||||
|
attachmentId.value += 1;
|
||||||
|
return `attachment-${attachmentId.value}`;
|
||||||
|
};
|
||||||
|
|
||||||
const uploadAttachment = ref(null);
|
const uploadAttachment = ref(null);
|
||||||
const isEmojiPickerOpen = ref(false);
|
const isEmojiPickerOpen = ref(false);
|
||||||
|
|
||||||
@@ -176,7 +182,8 @@ const onPaste = e => {
|
|||||||
.filter(file => file.size > 0)
|
.filter(file => file.size > 0)
|
||||||
.forEach(file => {
|
.forEach(file => {
|
||||||
const { name, type, size } = file;
|
const { name, type, size } = file;
|
||||||
onFileUpload({ file, name, type, size });
|
// Add unique ID for clipboard-pasted files
|
||||||
|
onFileUpload({ file, name, type, size, id: generateUid() });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+79
-61
@@ -7,6 +7,7 @@ import {
|
|||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
getEffectiveChannelType,
|
getEffectiveChannelType,
|
||||||
|
stripUnsupportedMarkdown,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
import {
|
import {
|
||||||
buildContactableInboxesList,
|
buildContactableInboxesList,
|
||||||
@@ -47,6 +48,8 @@ const emit = defineEmits([
|
|||||||
'createConversation',
|
'createConversation',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_FORMATTING = 'Context::Default';
|
||||||
|
|
||||||
const showContactsDropdown = ref(false);
|
const showContactsDropdown = ref(false);
|
||||||
const showInboxesDropdown = ref(false);
|
const showInboxesDropdown = ref(false);
|
||||||
const showCcEmailsDropdown = ref(false);
|
const showCcEmailsDropdown = ref(false);
|
||||||
@@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
showInboxesDropdown.value = true;
|
showInboxesDropdown.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
const stripMessageFormatting = channelType => {
|
||||||
|
if (!state.message || !channelType) return;
|
||||||
|
|
||||||
|
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
state.message = '';
|
|
||||||
emit('updateTargetInbox', { ...rest });
|
// Strip unsupported formatting when changing the target inbox
|
||||||
|
if (channelType) {
|
||||||
|
const newChannelType = getEffectiveChannelType(channelType, medium);
|
||||||
|
stripMessageFormatting(newChannelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('updateTargetInbox', { ...rest, channelType, medium });
|
||||||
showInboxesDropdown.value = false;
|
showInboxesDropdown.value = false;
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
@@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
|
|||||||
const removeTargetInbox = value => {
|
const removeTargetInbox = value => {
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
removeSignatureFromMessage();
|
removeSignatureFromMessage();
|
||||||
state.message = '';
|
|
||||||
|
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||||
|
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
@@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||||
>
|
>
|
||||||
<ContactSelector
|
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||||
:contacts="contacts"
|
<ContactSelector
|
||||||
:selected-contact="selectedContact"
|
:contacts="contacts"
|
||||||
:show-contacts-dropdown="showContactsDropdown"
|
:selected-contact="selectedContact"
|
||||||
:is-loading="isLoading"
|
:show-contacts-dropdown="showContactsDropdown"
|
||||||
:is-creating-contact="isCreatingContact"
|
:is-loading="isLoading"
|
||||||
:contact-id="contactId"
|
:is-creating-contact="isCreatingContact"
|
||||||
:contactable-inboxes-list="contactableInboxesList"
|
:contact-id="contactId"
|
||||||
:show-inboxes-dropdown="showInboxesDropdown"
|
:contactable-inboxes-list="contactableInboxesList"
|
||||||
:has-errors="validationStates.isContactInvalid"
|
:show-inboxes-dropdown="showInboxesDropdown"
|
||||||
@search-contacts="handleContactSearch"
|
:has-errors="validationStates.isContactInvalid"
|
||||||
@set-selected-contact="setSelectedContact"
|
@search-contacts="handleContactSearch"
|
||||||
@clear-selected-contact="clearSelectedContact"
|
@set-selected-contact="setSelectedContact"
|
||||||
@update-dropdown="handleDropdownUpdate"
|
@clear-selected-contact="clearSelectedContact"
|
||||||
/>
|
@update-dropdown="handleDropdownUpdate"
|
||||||
<InboxEmptyState v-if="showNoInboxAlert" />
|
/>
|
||||||
<InboxSelector
|
<InboxEmptyState v-if="showNoInboxAlert" />
|
||||||
v-else
|
<InboxSelector
|
||||||
:target-inbox="targetInbox"
|
v-else
|
||||||
:selected-contact="selectedContact"
|
:target-inbox="targetInbox"
|
||||||
:show-inboxes-dropdown="showInboxesDropdown"
|
:selected-contact="selectedContact"
|
||||||
:contactable-inboxes-list="contactableInboxesList"
|
:show-inboxes-dropdown="showInboxesDropdown"
|
||||||
:has-errors="validationStates.isInboxInvalid"
|
:contactable-inboxes-list="contactableInboxesList"
|
||||||
@update-inbox="removeTargetInbox"
|
:has-errors="validationStates.isInboxInvalid"
|
||||||
@toggle-dropdown="showInboxesDropdown = $event"
|
@update-inbox="removeTargetInbox"
|
||||||
@handle-inbox-action="handleInboxAction"
|
@toggle-dropdown="showInboxesDropdown = $event"
|
||||||
/>
|
@handle-inbox-action="handleInboxAction"
|
||||||
|
/>
|
||||||
|
|
||||||
<EmailOptions
|
<EmailOptions
|
||||||
v-if="inboxTypes.isEmail"
|
v-if="inboxTypes.isEmail"
|
||||||
v-model:cc-emails="state.ccEmails"
|
v-model:cc-emails="state.ccEmails"
|
||||||
v-model:bcc-emails="state.bccEmails"
|
v-model:bcc-emails="state.bccEmails"
|
||||||
v-model:subject="state.subject"
|
v-model:subject="state.subject"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:show-cc-emails-dropdown="showCcEmailsDropdown"
|
:show-cc-emails-dropdown="showCcEmailsDropdown"
|
||||||
:show-bcc-emails-dropdown="showBccEmailsDropdown"
|
:show-bcc-emails-dropdown="showBccEmailsDropdown"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:has-errors="validationStates.isSubjectInvalid"
|
:has-errors="validationStates.isSubjectInvalid"
|
||||||
@search-cc-emails="searchCcEmails"
|
@search-cc-emails="searchCcEmails"
|
||||||
@search-bcc-emails="searchBccEmails"
|
@search-bcc-emails="searchBccEmails"
|
||||||
@update-dropdown="handleDropdownUpdate"
|
@update-dropdown="handleDropdownUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MessageEditor
|
<MessageEditor
|
||||||
v-if="shouldShowMessageEditor"
|
v-if="shouldShowMessageEditor"
|
||||||
v-model="state.message"
|
v-model="state.message"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
:send-with-signature="sendWithSignature"
|
:send-with-signature="sendWithSignature"
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:channel-type="inboxChannelType"
|
||||||
:channel-type="inboxChannelType"
|
:medium="targetInbox?.medium || ''"
|
||||||
:medium="targetInbox?.medium || ''"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
v-if="state.attachedFiles.length > 0"
|
v-if="state.attachedFiles.length > 0"
|
||||||
:attachments="state.attachedFiles"
|
:attachments="state.attachedFiles"
|
||||||
@update:attachments="state.attachedFiles = $event"
|
@update:attachments="state.attachedFiles = $event"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
:attached-files="state.attachedFiles"
|
:attached-files="state.attachedFiles"
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||||
:menu-items="contactableInboxesList"
|
:menu-items="contactableInboxesList"
|
||||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||||
@action="emit('handleInboxAction', $event)"
|
@action="emit('handleInboxAction', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-4
@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
hasErrors: { type: Boolean, default: false },
|
hasErrors: { type: Boolean, default: false },
|
||||||
hasAttachments: { type: Boolean, default: false },
|
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
@@ -24,14 +23,14 @@ const modelValue = defineModel({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
<div class="flex-1 h-full">
|
||||||
<Editor
|
<Editor
|
||||||
:key="editorKey"
|
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
|
:editor-key="editorKey"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||||
:class="
|
:class="
|
||||||
hasErrors
|
hasErrors
|
||||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
|
|||||||
CAMPAIGN_ID: 'campaign_id',
|
CAMPAIGN_ID: 'campaign_id',
|
||||||
LABELS: 'labels',
|
LABELS: 'labels',
|
||||||
BROWSER_LANGUAGE: 'browser_language',
|
BROWSER_LANGUAGE: 'browser_language',
|
||||||
COUNTRY_CODE: 'country_code',
|
|
||||||
REFERER: 'referer',
|
REFERER: 'referer',
|
||||||
CREATED_AT: 'created_at',
|
CREATED_AT: 'created_at',
|
||||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
buildAttributesFilterTypes,
|
buildAttributesFilterTypes,
|
||||||
CONVERSATION_ATTRIBUTES,
|
CONVERSATION_ATTRIBUTES,
|
||||||
} from './helper/filterHelper';
|
} from './helper/filterHelper';
|
||||||
import countries from 'shared/constants/countries.js';
|
|
||||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,17 +217,6 @@ export function useConversationFilterContext() {
|
|||||||
filterOperators: equalityOperators.value,
|
filterOperators: equalityOperators.value,
|
||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
|
||||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
|
||||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
|
||||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
|
||||||
inputType: 'searchSelect',
|
|
||||||
options: countries,
|
|
||||||
dataType: 'text',
|
|
||||||
filterOperators: equalityOperators.value,
|
|
||||||
attributeModel: 'additional',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||||
|
|||||||
@@ -485,6 +485,12 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-briefcase',
|
icon: 'i-lucide-briefcase',
|
||||||
to: accountScopedRoute('general_settings_index'),
|
to: accountScopedRoute('general_settings_index'),
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// name: 'Settings Captain',
|
||||||
|
// label: t('SIDEBAR.CAPTAIN_AI'),
|
||||||
|
// icon: 'i-woot-captain',
|
||||||
|
// to: accountScopedRoute('captain_settings_index'),
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
name: 'Settings Agents',
|
name: 'Settings Agents',
|
||||||
label: t('SIDEBAR.AGENTS'),
|
label: t('SIDEBAR.AGENTS'),
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
|
|||||||
v-if="showDropdownMenu"
|
v-if="showDropdownMenu"
|
||||||
:menu-items="filteredMenuItems"
|
:menu-items="filteredMenuItems"
|
||||||
:is-searching="isLoading"
|
:is-searching="isLoading"
|
||||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||||
@action="handleDropdownAction"
|
@action="handleDropdownAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
removeSignature,
|
removeSignature,
|
||||||
getEffectiveChannelType,
|
getEffectiveChannelType,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||||
|
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
@@ -637,6 +638,25 @@ export default {
|
|||||||
// Filter valid files (non-zero size)
|
// Filter valid files (non-zero size)
|
||||||
Array.from(e.clipboardData.files)
|
Array.from(e.clipboardData.files)
|
||||||
.filter(file => file.size > 0)
|
.filter(file => file.size > 0)
|
||||||
|
.filter(file => {
|
||||||
|
const isAllowed = isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: this.channelType || this.inbox?.channel_type,
|
||||||
|
medium: this.inbox?.medium,
|
||||||
|
conversationType: this.conversationType,
|
||||||
|
isInstagramChannel: this.isAnInstagramChannel,
|
||||||
|
isOnPrivateNote: this.isOnPrivateNote,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
useAlert(
|
||||||
|
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
|
||||||
|
fileName: file.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowed;
|
||||||
|
})
|
||||||
.forEach(file => {
|
.forEach(file => {
|
||||||
const { name, type, size } = file;
|
const { name, type, size } = file;
|
||||||
this.onFileUpload({ name, type, size, file });
|
this.onFileUpload({ name, type, size, file });
|
||||||
|
|||||||
@@ -78,14 +78,6 @@ const filterTypes = [
|
|||||||
filterOperators: OPERATOR_TYPES_1,
|
filterOperators: OPERATOR_TYPES_1,
|
||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
attributeKey: 'country_code',
|
|
||||||
attributeI18nKey: 'COUNTRY_NAME',
|
|
||||||
inputType: 'search_select',
|
|
||||||
dataType: 'text',
|
|
||||||
filterOperators: OPERATOR_TYPES_1,
|
|
||||||
attributeModel: 'additional',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
attributeKey: 'referer',
|
attributeKey: 'referer',
|
||||||
attributeI18nKey: 'REFERER_LINK',
|
attributeI18nKey: 'REFERER_LINK',
|
||||||
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
|
|||||||
key: 'browser_language',
|
key: 'browser_language',
|
||||||
i18nKey: 'BROWSER_LANGUAGE',
|
i18nKey: 'BROWSER_LANGUAGE',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'country_code',
|
|
||||||
i18nKey: 'COUNTRY_NAME',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'referer',
|
key: 'referer',
|
||||||
i18nKey: 'REFERER_LINK',
|
i18nKey: 'REFERER_LINK',
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ export function extractTextFromMarkdown(markdown) {
|
|||||||
*
|
*
|
||||||
* @param {string} markdown - markdown text to process
|
* @param {string} markdown - markdown text to process
|
||||||
* @param {string} channelType - The channel type to check supported formatting
|
* @param {string} channelType - The channel type to check supported formatting
|
||||||
|
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
|
||||||
* @returns {string} - The markdown with unsupported formatting removed
|
* @returns {string} - The markdown with unsupported formatting removed
|
||||||
*/
|
*/
|
||||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
export function stripUnsupportedMarkdown(
|
||||||
|
markdown,
|
||||||
|
channelType,
|
||||||
|
cleanWhitespace = true
|
||||||
|
) {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||||
@@ -55,6 +60,9 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
|||||||
);
|
);
|
||||||
}, markdown);
|
}, markdown);
|
||||||
|
|
||||||
|
if (!cleanWhitespace) return result;
|
||||||
|
|
||||||
|
// Clean whitespace for signatures
|
||||||
return result
|
return result
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(line => line.trim())
|
.map(line => line.trim())
|
||||||
@@ -155,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
|
|||||||
export function appendSignature(body, signature, channelType) {
|
export function appendSignature(body, signature, channelType) {
|
||||||
// Strip only unsupported formatting based on channel capabilities
|
// Strip only unsupported formatting based on channel capabilities
|
||||||
const preparedSignature = channelType
|
const preparedSignature = channelType
|
||||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
? stripUnsupportedMarkdown(signature, channelType)
|
||||||
: signature;
|
: signature;
|
||||||
const cleanedSignature = cleanSignature(preparedSignature);
|
const cleanedSignature = cleanSignature(preparedSignature);
|
||||||
// if signature is already present, return body
|
// if signature is already present, return body
|
||||||
@@ -178,7 +186,7 @@ export function appendSignature(body, signature, channelType) {
|
|||||||
export function removeSignature(body, signature, channelType) {
|
export function removeSignature(body, signature, channelType) {
|
||||||
// Build unique list of signature variants to try
|
// Build unique list of signature variants to try
|
||||||
const channelStripped = channelType
|
const channelStripped = channelType
|
||||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
|
||||||
: null;
|
: null;
|
||||||
const signaturesToTry = [
|
const signaturesToTry = [
|
||||||
cleanSignature(signature),
|
cleanSignature(signature),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
// Quote detection strategies
|
// Quote detection strategies
|
||||||
const QUOTE_INDICATORS = [
|
const QUOTE_INDICATORS = [
|
||||||
'.gmail_quote_container',
|
'.gmail_quote_container',
|
||||||
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
|
|||||||
static extractQuotes(htmlContent) {
|
static extractQuotes(htmlContent) {
|
||||||
// Create a temporary DOM element to parse HTML
|
// Create a temporary DOM element to parse HTML
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = htmlContent;
|
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||||
|
|
||||||
// Remove elements matching class selectors
|
// Remove elements matching class selectors
|
||||||
QUOTE_INDICATORS.forEach(selector => {
|
QUOTE_INDICATORS.forEach(selector => {
|
||||||
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
|
|||||||
*/
|
*/
|
||||||
static hasQuotes(htmlContent) {
|
static hasQuotes(htmlContent) {
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = htmlContent;
|
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||||
|
|
||||||
// Check for class-based quotes
|
// Check for class-based quotes
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
|
|||||||
webhook: 'https://chwt.app/hc/webhooks',
|
webhook: 'https://chwt.app/hc/webhooks',
|
||||||
billing: 'https://chwt.app/pricing',
|
billing: 'https://chwt.app/pricing',
|
||||||
saml: 'https://chwt.app/hc/saml',
|
saml: 'https://chwt.app/hc/saml',
|
||||||
|
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getHelpUrlForFeature(featureName) {
|
export function getHelpUrlForFeature(featureName) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts plain text from HTML content
|
* Extracts plain text from HTML content
|
||||||
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
|
|||||||
return html.replace(/<[^>]*>/g, ' ');
|
return html.replace(/<[^>]*>/g, ' ');
|
||||||
}
|
}
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.innerHTML = html;
|
tempDiv.innerHTML = DOMPurify.sanitize(html);
|
||||||
return tempDiv.textContent || tempDiv.innerText || '';
|
return tempDiv.textContent || tempDiv.innerText || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
cleanSignature,
|
cleanSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
stripUnsupportedSignatureMarkdown,
|
stripUnsupportedMarkdown,
|
||||||
insertAtCursor,
|
insertAtCursor,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
@@ -145,25 +145,19 @@ describe('appendSignature', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
describe('stripUnsupportedMarkdown', () => {
|
||||||
const richSignature =
|
const richSignature =
|
||||||
'**Bold** _italic_ [link](http://example.com) ';
|
'**Bold** _italic_ [link](http://example.com) ';
|
||||||
|
|
||||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||||
const result = stripUnsupportedSignatureMarkdown(
|
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
|
||||||
richSignature,
|
|
||||||
'Channel::Email'
|
|
||||||
);
|
|
||||||
expect(result).toContain('**Bold**');
|
expect(result).toContain('**Bold**');
|
||||||
expect(result).toContain('_italic_');
|
expect(result).toContain('_italic_');
|
||||||
expect(result).toContain('[link](http://example.com)');
|
expect(result).toContain('[link](http://example.com)');
|
||||||
expect(result).toContain('');
|
expect(result).toContain('');
|
||||||
});
|
});
|
||||||
it('strips images but keeps bold/italic for Api channel', () => {
|
it('strips images but keeps bold/italic for Api channel', () => {
|
||||||
const result = stripUnsupportedSignatureMarkdown(
|
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
|
||||||
richSignature,
|
|
||||||
'Channel::Api'
|
|
||||||
);
|
|
||||||
expect(result).toContain('**Bold**');
|
expect(result).toContain('**Bold**');
|
||||||
expect(result).toContain('_italic_');
|
expect(result).toContain('_italic_');
|
||||||
expect(result).toContain('link'); // link text kept
|
expect(result).toContain('link'); // link text kept
|
||||||
@@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
|||||||
expect(result).not.toContain('; // image removed
|
expect(result).not.toContain('; // image removed
|
||||||
});
|
});
|
||||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||||
const result = stripUnsupportedSignatureMarkdown(
|
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
|
||||||
richSignature,
|
|
||||||
'Channel::Telegram'
|
|
||||||
);
|
|
||||||
expect(result).toContain('**Bold**');
|
expect(result).toContain('**Bold**');
|
||||||
expect(result).toContain('_italic_');
|
expect(result).toContain('_italic_');
|
||||||
expect(result).toContain('[link](http://example.com)');
|
expect(result).toContain('[link](http://example.com)');
|
||||||
expect(result).not.toContain(';
|
expect(result).not.toContain(';
|
||||||
});
|
});
|
||||||
it('strips all formatting for SMS channel', () => {
|
it('strips all formatting for SMS channel', () => {
|
||||||
const result = stripUnsupportedSignatureMarkdown(
|
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
|
||||||
richSignature,
|
|
||||||
'Channel::Sms'
|
|
||||||
);
|
|
||||||
expect(result).toContain('Bold');
|
expect(result).toContain('Bold');
|
||||||
expect(result).toContain('italic');
|
expect(result).toContain('italic');
|
||||||
expect(result).toContain('link');
|
expect(result).toContain('link');
|
||||||
@@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
|||||||
expect(result).not.toContain(';
|
expect(result).not.toContain(';
|
||||||
});
|
});
|
||||||
it('returns empty string for empty input', () => {
|
it('returns empty string for empty input', () => {
|
||||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
|
||||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with cleanWhitespace parameter', () => {
|
||||||
|
const textWithWhitespace =
|
||||||
|
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
|
||||||
|
|
||||||
|
it('cleans whitespace when cleanWhitespace=true (default)', () => {
|
||||||
|
const result = stripUnsupportedMarkdown(
|
||||||
|
textWithWhitespace,
|
||||||
|
'Channel::Api',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(result).toBe(
|
||||||
|
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
|
||||||
|
);
|
||||||
|
expect(result).not.toContain('\n\n');
|
||||||
|
expect(result).not.toContain(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves whitespace when cleanWhitespace=false', () => {
|
||||||
|
const result = stripUnsupportedMarkdown(
|
||||||
|
textWithWhitespace,
|
||||||
|
'Channel::Api',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result).toContain('\n\n');
|
||||||
|
expect(result).toContain(' And spaces ');
|
||||||
|
expect(result).toBe(
|
||||||
|
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips formatting but preserves whitespace for messages', () => {
|
||||||
|
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
|
||||||
|
const result = stripUnsupportedMarkdown(
|
||||||
|
messageWithFormatting,
|
||||||
|
'Channel::Sms',
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result).toBe('Bold\n\ncode\n\nNormal text');
|
||||||
|
expect(result).toContain('\n\n');
|
||||||
|
expect(result).not.toContain('**');
|
||||||
|
expect(result).not.toContain('`');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
|
|||||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('HTML sanitization', () => {
|
||||||
|
it('removes onerror handlers from img tags in extractQuotes', () => {
|
||||||
|
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||||
|
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||||
|
|
||||||
|
expect(cleanedHtml).not.toContain('onerror');
|
||||||
|
expect(cleanedHtml).toContain('<p>Hello</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes onerror handlers from img tags in hasQuotes', () => {
|
||||||
|
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||||
|
// Should not throw and should safely check for quotes
|
||||||
|
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes script tags in extractQuotes', () => {
|
||||||
|
const maliciousHtml =
|
||||||
|
'<p>Content</p><script>alert("xss")</script><p>More</p>';
|
||||||
|
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||||
|
|
||||||
|
expect(cleanedHtml).not.toContain('<script');
|
||||||
|
expect(cleanedHtml).not.toContain('alert');
|
||||||
|
expect(cleanedHtml).toContain('<p>Content</p>');
|
||||||
|
expect(cleanedHtml).toContain('<p>More</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes onclick handlers in extractQuotes', () => {
|
||||||
|
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
|
||||||
|
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||||
|
|
||||||
|
expect(cleanedHtml).not.toContain('onclick');
|
||||||
|
expect(cleanedHtml).toContain('Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes javascript: URLs in extractQuotes', () => {
|
||||||
|
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
|
||||||
|
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-script-url
|
||||||
|
expect(cleanedHtml).not.toContain('javascript:');
|
||||||
|
expect(cleanedHtml).toContain('Link');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes encoded payloads with event handlers in extractQuotes', () => {
|
||||||
|
const maliciousHtml =
|
||||||
|
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
|
||||||
|
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||||
|
|
||||||
|
expect(cleanedHtml).not.toContain('onerror');
|
||||||
|
expect(cleanedHtml).not.toContain('eval');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
|
|||||||
expect(result).toContain('Line 1');
|
expect(result).toContain('Line 1');
|
||||||
expect(result).toContain('Line 2');
|
expect(result).toContain('Line 2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sanitizes onerror handlers from img tags', () => {
|
||||||
|
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||||
|
const result = extractPlainTextFromHtml(html);
|
||||||
|
expect(result).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes script tags', () => {
|
||||||
|
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
|
||||||
|
const result = extractPlainTextFromHtml(html);
|
||||||
|
expect(result).toContain('Safe');
|
||||||
|
expect(result).toContain('Content');
|
||||||
|
expect(result).not.toContain('alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes onclick handlers', () => {
|
||||||
|
const html = '<p onclick="alert(1)">Click me</p>';
|
||||||
|
const result = extractPlainTextFromHtml(html);
|
||||||
|
expect(result).toBe('Click me');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getEmailSenderName', () => {
|
describe('getEmailSenderName', () => {
|
||||||
|
|||||||
@@ -247,6 +247,7 @@
|
|||||||
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
||||||
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
||||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
||||||
|
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
|
||||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||||
"SENT_BY": "Sent by:",
|
"SENT_BY": "Sent by:",
|
||||||
"BOT": "Bot",
|
"BOT": "Bot",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"CLOSE": "Close",
|
"CLOSE": "Close",
|
||||||
"BETA": "Beta",
|
"BETA": "Beta",
|
||||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||||
|
"PREFERRED": "Preferred"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,6 +403,7 @@
|
|||||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||||
},
|
},
|
||||||
"ENTERPRISE_PAYWALL": {
|
"ENTERPRISE_PAYWALL": {
|
||||||
|
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
|
||||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"HEADER": "Conversations",
|
"HEADER": "Conversations",
|
||||||
"LOADING_CHART": "Loading chart data...",
|
"LOADING_CHART": "Loading chart data...",
|
||||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
|
||||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||||
"METRICS": {
|
"METRICS": {
|
||||||
|
|||||||
@@ -378,7 +378,56 @@
|
|||||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||||
},
|
},
|
||||||
"DOCS": "Read docs",
|
"DOCS": "Read docs",
|
||||||
"SECURITY": "Security"
|
"SECURITY": "Security",
|
||||||
|
"CAPTAIN_AI": "Captain"
|
||||||
|
},
|
||||||
|
"CAPTAIN_SETTINGS": {
|
||||||
|
"TITLE": "Captain Settings",
|
||||||
|
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
|
||||||
|
"LOADING": "Loading Captain configuration...",
|
||||||
|
"LINK_TEXT": "Learn more about Captain Credits",
|
||||||
|
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
|
||||||
|
"MODEL_CONFIG": {
|
||||||
|
"TITLE": "Model Configuration",
|
||||||
|
"DESCRIPTION": "Select AI models for different features.",
|
||||||
|
"SELECT_MODEL": "Select model",
|
||||||
|
"CREDITS_PER_MESSAGE": "{credits} credit/message",
|
||||||
|
"COMING_SOON": "Coming soon",
|
||||||
|
"EDITOR": {
|
||||||
|
"TITLE": "Editor Features",
|
||||||
|
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
|
||||||
|
},
|
||||||
|
"ASSISTANT": {
|
||||||
|
"TITLE": "Assistant",
|
||||||
|
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
|
||||||
|
},
|
||||||
|
"COPILOT": {
|
||||||
|
"TITLE": "Co-pilot",
|
||||||
|
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"TITLE": "Features",
|
||||||
|
"DESCRIPTION": "Enable or disable AI-powered features.",
|
||||||
|
"AUDIO_TRANSCRIPTION": {
|
||||||
|
"TITLE": "Audio Transcription",
|
||||||
|
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
|
||||||
|
},
|
||||||
|
"HELP_CENTER_SEARCH": {
|
||||||
|
"TITLE": "Help Center Search Indexing",
|
||||||
|
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
|
||||||
|
},
|
||||||
|
"LABEL_SUGGESTION": {
|
||||||
|
"TITLE": "Label Suggestion",
|
||||||
|
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
|
||||||
|
"MODEL_TITLE": "Label Suggestion Model",
|
||||||
|
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"API": {
|
||||||
|
"SUCCESS": "Captain settings updated successfully.",
|
||||||
|
"ERROR": "Failed to update Captain settings. Please try again."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"BILLING_SETTINGS": {
|
"BILLING_SETTINGS": {
|
||||||
"TITLE": "Billing",
|
"TITLE": "Billing",
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
||||||
<div class="w-full max-w-5xl mx-auto z-[60]">
|
<div class="w-full max-w-5xl mx-auto z-30">
|
||||||
<div class="flex flex-col w-full px-4">
|
<div class="flex flex-col w-full px-4">
|
||||||
<SearchHeader
|
<SearchHeader
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
|
||||||
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
|
import SectionLayout from '../account/components/SectionLayout.vue';
|
||||||
|
import ModelSelector from './components/ModelSelector.vue';
|
||||||
|
import FeatureToggle from './components/FeatureToggle.vue';
|
||||||
|
import CaptainPaywall from 'next/captain/pageComponents/Paywall.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { captainEnabled } = useCaptain();
|
||||||
|
const { isEnterprise, enterprisePlanName } = useConfig();
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const { uiFlags } = storeToRefs(captainConfigStore);
|
||||||
|
|
||||||
|
const isLoading = computed(() => uiFlags.value.isFetching);
|
||||||
|
|
||||||
|
const modelFeatures = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'editor',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.DESCRIPTION'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assistant',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.DESCRIPTION'),
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'copilot',
|
||||||
|
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
|
||||||
|
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const featureToggles = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'label_suggestion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'help_center_search',
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'audio_transcription',
|
||||||
|
enterprise: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldShowFeature = feature => {
|
||||||
|
// Cloud will always see these features as long as captain is enabled
|
||||||
|
if (isOnChatwootCloud.value && captainEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.enterprise) {
|
||||||
|
// if the app is in enterprise mode, then we can show the feature
|
||||||
|
// this is not the installation plan, but when the enterprise folder is missing
|
||||||
|
return isEnterprise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFeatureAccessible = feature => {
|
||||||
|
// Cloud will always see these features as long as captain is enabled
|
||||||
|
if (isOnChatwootCloud.value && captainEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.enterprise) {
|
||||||
|
// plan is shown, but is it accessible?
|
||||||
|
// This ensures that the instance has purchased the enterprise license, and only then we allow
|
||||||
|
// access
|
||||||
|
return isEnterprise && enterprisePlanName === 'enterprise';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleFeatureToggle({ feature, enabled }) {
|
||||||
|
try {
|
||||||
|
await captainConfigStore.updatePreferences({
|
||||||
|
captain_features: { [feature]: enabled },
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModelChange({ feature, model }) {
|
||||||
|
try {
|
||||||
|
await captainConfigStore.updatePreferences({
|
||||||
|
captain_models: { [feature]: model },
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
captainConfigStore.fetch();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsLayout
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:no-records-message="t('CAPTAIN_SETTINGS.NOT_ENABLED')"
|
||||||
|
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<BaseSettingsHeader
|
||||||
|
:title="t('CAPTAIN_SETTINGS.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
|
||||||
|
:link-text="t('CAPTAIN_SETTINGS.LINK_TEXT')"
|
||||||
|
icon-name="captain"
|
||||||
|
feature-name="captain_billing"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div v-if="captainEnabled" class="flex flex-col gap-1">
|
||||||
|
<!-- Model Configuration Section -->
|
||||||
|
<SectionLayout
|
||||||
|
:title="t('CAPTAIN_SETTINGS.MODEL_CONFIG.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.MODEL_CONFIG.DESCRIPTION')"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<ModelSelector
|
||||||
|
v-for="feature in modelFeatures"
|
||||||
|
v-show="shouldShowFeature(feature)"
|
||||||
|
:key="feature.key"
|
||||||
|
:is-allowed="isFeatureAccessible(feature)"
|
||||||
|
:feature-key="feature.key"
|
||||||
|
:title="feature.title"
|
||||||
|
:description="feature.description"
|
||||||
|
@change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionLayout>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<SectionLayout
|
||||||
|
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
|
||||||
|
with-border
|
||||||
|
>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<FeatureToggle
|
||||||
|
v-for="feature in featureToggles"
|
||||||
|
v-show="shouldShowFeature(feature)"
|
||||||
|
:key="feature.key"
|
||||||
|
:is-allowed="isFeatureAccessible(feature)"
|
||||||
|
:feature-key="feature.key"
|
||||||
|
@change="handleFeatureToggle"
|
||||||
|
@model-change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionLayout>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { frontendURL } from '../../../../helper/URLHelper';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||||
|
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||||
|
import Index from './Index.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/settings/captain'),
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||||
|
},
|
||||||
|
component: SettingsWrapper,
|
||||||
|
props: {
|
||||||
|
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
|
||||||
|
icon: 'i-lucide-bot',
|
||||||
|
showNewButton: false,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'captain_settings_index',
|
||||||
|
component: Index,
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||||
|
installationTypes: [
|
||||||
|
INSTALLATION_TYPES.ENTERPRISE,
|
||||||
|
INSTALLATION_TYPES.CLOUD,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||||
|
import ModelDropdown from './ModelDropdown.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAllowed: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change', 'modelChange']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const { features } = storeToRefs(captainConfigStore);
|
||||||
|
|
||||||
|
const availableModels = computed(() =>
|
||||||
|
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEnabled = ref(false);
|
||||||
|
|
||||||
|
const featureConfig = computed(() => features.value[props.featureKey]);
|
||||||
|
|
||||||
|
const hasMultipleModels = computed(() => {
|
||||||
|
return availableModels.value && availableModels.value.length > 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModelSelector = computed(() => {
|
||||||
|
return isEnabled.value && hasMultipleModels.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelTitle = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelDescription = computed(() => {
|
||||||
|
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||||
|
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
featureConfig,
|
||||||
|
newConfig => {
|
||||||
|
if (newConfig !== undefined) {
|
||||||
|
isEnabled.value = !!newConfig.enabled;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFeature = () => {
|
||||||
|
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = ({ feature, model }) => {
|
||||||
|
emit('modelChange', { feature, model });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="p-4 rounded-xl border border-n-weak bg-n-solid-1 flex"
|
||||||
|
:class="{
|
||||||
|
'flex-col gap-3': showModelSelector,
|
||||||
|
'items-center justify-between gap-4': !showModelSelector,
|
||||||
|
'opacity-60 pointer-events-none': !isAllowed,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4 flex-1">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAllowed" class="flex-shrink-0">
|
||||||
|
<Switch v-model="isEnabled" @change="toggleFeature" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showModelSelector && isAllowed"
|
||||||
|
class="flex gap-2 ps-8 relative before:content-[''] before:absolute before:w-0.5 before:h-1/2 before:top-0 before:start-3 before:bg-n-weak after:content-[''] after:absolute after:w-2.5 after:h-3 after:top-[calc(50%-6px)] after:start-3 after:border-b-[0.125rem] after:border-s-[0.125rem] after:rounded-es after:border-n-weak"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">{{ modelTitle }}</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ modelDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import DropdownBody from 'dashboard/components-next/dropdown-menu/base/DropdownBody.vue';
|
||||||
|
import DropdownItem from 'dashboard/components-next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
import { provideDropdownContext } from 'dashboard/components-next/dropdown-menu/base/provider.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
|
||||||
|
const PROVIDER_ICONS = {
|
||||||
|
openai: 'i-ri-openai-fill',
|
||||||
|
anthropic: 'i-ri-anthropic-line',
|
||||||
|
mistral: 'i-logos-mistral-icon',
|
||||||
|
gemini: 'i-woot-gemini',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconForModel = model => {
|
||||||
|
return PROVIDER_ICONS[model.provider];
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const captainConfigStore = useCaptainConfigStore();
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
|
const availableModels = computed(() =>
|
||||||
|
captainConfigStore.getModelsForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const recommendedModelId = computed(() =>
|
||||||
|
captainConfigStore.getDefaultModelForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModel = computed(() =>
|
||||||
|
captainConfigStore.getSelectedModelForFeature(props.featureKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModelId = ref(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedModel,
|
||||||
|
newSelected => {
|
||||||
|
if (newSelected) {
|
||||||
|
selectedModelId.value = newSelected;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModelDetails = computed(() => {
|
||||||
|
if (!selectedModelId.value) return null;
|
||||||
|
return (
|
||||||
|
availableModels.value.find(m => m.id === selectedModelId.value) || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCreditLabel = model => {
|
||||||
|
const multiplier = model.credit_multiplier || 1;
|
||||||
|
return t('CAPTAIN_SETTINGS.MODEL_CONFIG.CREDITS_PER_MESSAGE', {
|
||||||
|
credits: multiplier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
provideDropdownContext({
|
||||||
|
isOpen,
|
||||||
|
toggle: () => toggleDropdown(),
|
||||||
|
closeMenu: closeDropdown,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectModel = model => {
|
||||||
|
selectedModelId.value = model.id;
|
||||||
|
emit('change', { feature: props.featureKey, model: model.id });
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-on-clickaway="closeDropdown" class="relative flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 text-sm border rounded-lg border-n-weak dark:bg-n-solid-2 dark:hover:bg-n-solid-3 bg-n-alpha-2 hover:bg-n-alpha-1 min-w-[180px] justify-between"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
>
|
||||||
|
<span v-if="selectedModelDetails" class="text-n-slate-12">
|
||||||
|
{{ selectedModelDetails.display_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-n-slate-10">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.SELECT_MODEL') }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
class="size-4 text-n-slate-11 transition-transform"
|
||||||
|
:class="{ 'rotate-180': isOpen }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<DropdownBody
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 min-w-64 z-50 max-h-96 [&>ul]:max-h-96 [&>ul]:overflow-y-scroll"
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
v-for="model in availableModels"
|
||||||
|
:key="model.id"
|
||||||
|
:click="() => selectModel(model)"
|
||||||
|
class="rounded-lg dark:hover:bg-n-solid-3 hover:bg-n-alpha-1"
|
||||||
|
:class="{
|
||||||
|
'dark:bg-n-solid-3 bg-n-alpha-1': selectedModelId === model.id,
|
||||||
|
'pointer-events-none opacity-60': model.coming_soon,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 w-full">
|
||||||
|
<Icon :icon="iconForModel(model)" class="size-4 flex-shrink-0" />
|
||||||
|
<div class="flex flex-col w-full text-left gap-1">
|
||||||
|
<div
|
||||||
|
class="text-sm w-full font-medium leading-none text-n-slate-12 flex items-baseline justify-between"
|
||||||
|
>
|
||||||
|
{{ model.display_name }}
|
||||||
|
<span
|
||||||
|
v-if="model.id === recommendedModelId"
|
||||||
|
class="text-[10px] uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
|
||||||
|
>
|
||||||
|
{{ t('GENERAL.PREFERRED') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="model.coming_soon" class="text-xs text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.COMING_SOON') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isOnChatwootCloud" class="text-xs text-n-slate-11">
|
||||||
|
{{ getCreditLabel(model) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownBody>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import ModelDropdown from './ModelDropdown.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
featureKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAllowed: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const handleModelChange = ({ feature, model }) => {
|
||||||
|
emit('change', { feature, model });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1"
|
||||||
|
:class="{ 'opacity-60 pointer-events-none relative': !isAllowed }"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ title }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<ModelDropdown
|
||||||
|
v-if="isAllowed"
|
||||||
|
:feature-key="featureKey"
|
||||||
|
@change="handleModelChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+59
-21
@@ -28,10 +28,15 @@ const { t } = useI18n();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const labels = useMapGetter('labels/getLabels');
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
|
||||||
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
|
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||||
props.inbox?.id
|
props.inbox?.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Computed to check if it's any type of WhatsApp channel (Cloud or Twilio)
|
||||||
|
const isAnyWhatsAppChannel = computed(
|
||||||
|
() => isAWhatsAppChannel.value || isATwilioWhatsAppChannel.value
|
||||||
|
);
|
||||||
|
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
const selectedLabelValues = ref([]);
|
const selectedLabelValues = ref([]);
|
||||||
const currentLabel = ref('');
|
const currentLabel = ref('');
|
||||||
@@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => {
|
|||||||
|
|
||||||
// Handle existing template with status
|
// Handle existing template with status
|
||||||
if (templateStatus.value?.template_exists && templateStatus.value.status) {
|
if (templateStatus.value?.template_exists && templateStatus.value.status) {
|
||||||
return statusMap[templateStatus.value.status] || statusMap.PENDING;
|
// Convert status to uppercase for consistency with statusMap keys
|
||||||
|
const normalizedStatus = templateStatus.value.status.toUpperCase();
|
||||||
|
return statusMap[normalizedStatus] || statusMap.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default case - no template exists
|
// Default case - no template exists
|
||||||
@@ -155,7 +162,7 @@ const initializeState = () => {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Store original template values for change detection
|
// Store original template values for change detection
|
||||||
if (isWhatsAppChannel.value) {
|
if (isAnyWhatsAppChannel.value) {
|
||||||
originalTemplateValues.value = {
|
originalTemplateValues.value = {
|
||||||
message: state.message,
|
message: state.message,
|
||||||
templateButtonText: state.templateButtonText,
|
templateButtonText: state.templateButtonText,
|
||||||
@@ -165,7 +172,7 @@ const initializeState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkTemplateStatus = async () => {
|
const checkTemplateStatus = async () => {
|
||||||
if (!isWhatsAppChannel.value) return;
|
if (!isAnyWhatsAppChannel.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
templateLoading.value = true;
|
templateLoading.value = true;
|
||||||
@@ -195,7 +202,7 @@ const checkTemplateStatus = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeState();
|
initializeState();
|
||||||
if (!labels.value?.length) store.dispatch('labels/get');
|
if (!labels.value?.length) store.dispatch('labels/get');
|
||||||
if (isWhatsAppChannel.value) checkTemplateStatus();
|
if (isAnyWhatsAppChannel.value) checkTemplateStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.inbox, initializeState, { immediate: true });
|
watch(() => props.inbox, initializeState, { immediate: true });
|
||||||
@@ -225,7 +232,7 @@ const removeLabel = label => {
|
|||||||
|
|
||||||
// Check if template-related fields have changed
|
// Check if template-related fields have changed
|
||||||
const hasTemplateChanges = () => {
|
const hasTemplateChanges = () => {
|
||||||
if (!isWhatsAppChannel.value) return false;
|
if (!isAnyWhatsAppChannel.value) return false;
|
||||||
|
|
||||||
const original = originalTemplateValues.value;
|
const original = originalTemplateValues.value;
|
||||||
return (
|
return (
|
||||||
@@ -254,10 +261,28 @@ const shouldCreateTemplate = () => {
|
|||||||
|
|
||||||
// Build template config for saving
|
// Build template config for saving
|
||||||
const buildTemplateConfig = () => {
|
const buildTemplateConfig = () => {
|
||||||
if (!hasExistingTemplate()) return null;
|
if (!hasExistingTemplate()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const { template_name, template_id, template, status } =
|
const { template_name, template_id, template, status } =
|
||||||
templateStatus.value || {};
|
templateStatus.value || {};
|
||||||
|
|
||||||
|
if (isATwilioWhatsAppChannel.value) {
|
||||||
|
// Twilio WhatsApp format - get from existing template config
|
||||||
|
const existingTemplate = props.inbox?.csat_config?.template;
|
||||||
|
|
||||||
|
return existingTemplate
|
||||||
|
? {
|
||||||
|
friendly_name: existingTemplate.friendly_name,
|
||||||
|
content_sid: existingTemplate.content_sid,
|
||||||
|
language: existingTemplate.language || state.templateLanguage,
|
||||||
|
status: existingTemplate.status || status,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhatsApp Cloud format
|
||||||
return {
|
return {
|
||||||
name: template_name,
|
name: template_name,
|
||||||
template_id,
|
template_id,
|
||||||
@@ -273,11 +298,11 @@ const updateInbox = async attributes => {
|
|||||||
...attributes,
|
...attributes,
|
||||||
};
|
};
|
||||||
|
|
||||||
return store.dispatch('inboxes/updateInbox', payload);
|
await store.dispatch('inboxes/updateInbox', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTemplate = async () => {
|
const createTemplate = async () => {
|
||||||
if (!isWhatsAppChannel.value) return null;
|
if (!isAnyWhatsAppChannel.value) return null;
|
||||||
|
|
||||||
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
||||||
inboxId: props.inbox.id,
|
inboxId: props.inbox.id,
|
||||||
@@ -298,7 +323,7 @@ const performSave = async () => {
|
|||||||
|
|
||||||
// For WhatsApp channels, create template first if needed
|
// For WhatsApp channels, create template first if needed
|
||||||
if (
|
if (
|
||||||
isWhatsAppChannel.value &&
|
isAnyWhatsAppChannel.value &&
|
||||||
state.csatSurveyEnabled &&
|
state.csatSurveyEnabled &&
|
||||||
shouldCreateTemplate()
|
shouldCreateTemplate()
|
||||||
) {
|
) {
|
||||||
@@ -326,13 +351,25 @@ const performSave = async () => {
|
|||||||
|
|
||||||
// Use new template data if created, otherwise preserve existing template information
|
// Use new template data if created, otherwise preserve existing template information
|
||||||
if (newTemplateData) {
|
if (newTemplateData) {
|
||||||
csatConfig.template = {
|
if (isATwilioWhatsAppChannel.value) {
|
||||||
name: newTemplateData.name,
|
// Twilio WhatsApp template format
|
||||||
template_id: newTemplateData.template_id,
|
csatConfig.template = {
|
||||||
language: newTemplateData.language,
|
friendly_name: newTemplateData.friendly_name,
|
||||||
status: newTemplateData.status,
|
content_sid: newTemplateData.content_sid,
|
||||||
created_at: new Date().toISOString(),
|
language: newTemplateData.language,
|
||||||
};
|
status: newTemplateData.status,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// WhatsApp Cloud template format
|
||||||
|
csatConfig.template = {
|
||||||
|
name: newTemplateData.name,
|
||||||
|
template_id: newTemplateData.template_id,
|
||||||
|
language: newTemplateData.language,
|
||||||
|
status: newTemplateData.status,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const templateConfig = buildTemplateConfig();
|
const templateConfig = buildTemplateConfig();
|
||||||
if (templateConfig) {
|
if (templateConfig) {
|
||||||
@@ -356,8 +393,9 @@ const performSave = async () => {
|
|||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async () => {
|
||||||
// Check if we need to show confirmation dialog for WhatsApp template changes
|
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||||
|
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
|
||||||
if (
|
if (
|
||||||
isWhatsAppChannel.value &&
|
isAnyWhatsAppChannel.value &&
|
||||||
state.csatSurveyEnabled &&
|
state.csatSurveyEnabled &&
|
||||||
hasExistingTemplate() &&
|
hasExistingTemplate() &&
|
||||||
hasTemplateChanges()
|
hasTemplateChanges()
|
||||||
@@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
<!-- Show display type only for non-WhatsApp channels -->
|
<!-- Show display type only for non-WhatsApp channels -->
|
||||||
<WithLabel
|
<WithLabel
|
||||||
v-if="!isWhatsAppChannel"
|
v-if="!isAnyWhatsAppChannel"
|
||||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||||
name="display_type"
|
name="display_type"
|
||||||
>
|
>
|
||||||
@@ -400,7 +438,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
|
||||||
<template v-if="isWhatsAppChannel">
|
<template v-if="isAnyWhatsAppChannel">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||||
>
|
>
|
||||||
@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
|
|||||||
</WithLabel>
|
</WithLabel>
|
||||||
<p class="text-sm italic text-n-slate-11">
|
<p class="text-sm italic text-n-slate-11">
|
||||||
{{
|
{{
|
||||||
isWhatsAppChannel
|
isAnyWhatsAppChannel
|
||||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ export default {
|
|||||||
businessHours,
|
businessHours,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
downloadAgentReports() {
|
downloadConversationReports() {
|
||||||
const { from, to } = this;
|
const { from, to } = this;
|
||||||
const fileName = generateFileName({
|
const fileName = generateFileName({
|
||||||
type: 'agent',
|
type: 'conversation',
|
||||||
to,
|
to,
|
||||||
businessHours: this.businessHours,
|
businessHours: this.businessHours,
|
||||||
});
|
});
|
||||||
this.$store.dispatch('downloadAgentReports', {
|
this.$store.dispatch('downloadConversationsSummaryReports', {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
fileName,
|
fileName,
|
||||||
@@ -109,10 +109,10 @@ export default {
|
|||||||
<template>
|
<template>
|
||||||
<ReportHeader :header-title="$t('REPORT.HEADER')">
|
<ReportHeader :header-title="$t('REPORT.HEADER')">
|
||||||
<V4Button
|
<V4Button
|
||||||
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
:label="$t('REPORT.DOWNLOAD_CONVERSATION_REPORTS')"
|
||||||
icon="i-ph-download-simple"
|
icon="i-ph-download-simple"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="downloadAgentReports"
|
@click="downloadConversationReports"
|
||||||
/>
|
/>
|
||||||
</ReportHeader>
|
</ReportHeader>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
|
|||||||
import customRoles from './customRoles/customRole.routes';
|
import customRoles from './customRoles/customRole.routes';
|
||||||
import profile from './profile/profile.routes';
|
import profile from './profile/profile.routes';
|
||||||
import security from './security/security.routes';
|
import security from './security/security.routes';
|
||||||
|
import captain from './captain/captain.routes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -63,5 +64,6 @@ export default {
|
|||||||
...customRoles.routes,
|
...customRoles.routes,
|
||||||
...profile.routes,
|
...profile.routes,
|
||||||
...security.routes,
|
...security.routes,
|
||||||
|
...captain.routes,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import CaptainPreferencesAPI from 'dashboard/api/captain/preferences';
|
||||||
|
|
||||||
|
export const useCaptainConfigStore = defineStore('captainConfig', {
|
||||||
|
state: () => ({
|
||||||
|
providers: {},
|
||||||
|
models: {},
|
||||||
|
features: {},
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
getProviders: state => state.providers,
|
||||||
|
getModels: state => state.models,
|
||||||
|
getFeatures: state => state.features,
|
||||||
|
getUIFlags: state => state.uiFlags,
|
||||||
|
getModelsForFeature: state => featureKey => {
|
||||||
|
const feature = state.features[featureKey];
|
||||||
|
const models = feature?.models || [];
|
||||||
|
|
||||||
|
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
|
||||||
|
|
||||||
|
return [...models].sort((a, b) => {
|
||||||
|
// Move coming_soon items to the end
|
||||||
|
if (a.coming_soon && !b.coming_soon) return 1;
|
||||||
|
if (!a.coming_soon && b.coming_soon) return -1;
|
||||||
|
|
||||||
|
// Sort by provider
|
||||||
|
const providerA = providerOrder[a.provider] ?? 999;
|
||||||
|
const providerB = providerOrder[b.provider] ?? 999;
|
||||||
|
if (providerA !== providerB) return providerA - providerB;
|
||||||
|
|
||||||
|
// Sort by credit_multiplier (highest first)
|
||||||
|
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getDefaultModelForFeature: state => featureKey => {
|
||||||
|
const feature = state.features[featureKey];
|
||||||
|
return feature?.default || null;
|
||||||
|
},
|
||||||
|
getSelectedModelForFeature: state => featureKey => {
|
||||||
|
const feature = state.features[featureKey];
|
||||||
|
return feature?.selected || feature?.default || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetch() {
|
||||||
|
this.uiFlags.isFetching = true;
|
||||||
|
try {
|
||||||
|
const response = await CaptainPreferencesAPI.get();
|
||||||
|
this.providers = response.data.providers || {};
|
||||||
|
this.models = response.data.models || {};
|
||||||
|
this.features = response.data.features || {};
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
} finally {
|
||||||
|
this.uiFlags.isFetching = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePreferences(data) {
|
||||||
|
const response = await CaptainPreferencesAPI.updatePreferences(data);
|
||||||
|
this.providers = response.data.providers || {};
|
||||||
|
this.models = response.data.models || {};
|
||||||
|
this.features = response.data.features || {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -78,7 +78,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
|
|||||||
case 'team_id':
|
case 'team_id':
|
||||||
return conversation.meta?.team?.id;
|
return conversation.meta?.team?.id;
|
||||||
case 'browser_language':
|
case 'browser_language':
|
||||||
case 'country_code':
|
|
||||||
case 'referer':
|
case 'referer':
|
||||||
return conversation.additional_attributes?.[attributeKey];
|
return conversation.additional_attributes?.[attributeKey];
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -234,6 +234,19 @@ export const actions = {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
downloadConversationsSummaryReports(_, reportObj) {
|
||||||
|
return Report.getConversationsSummaryReports(reportObj)
|
||||||
|
.then(response => {
|
||||||
|
downloadCsvFile(reportObj.fileName, response.data);
|
||||||
|
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
|
||||||
|
reportType: 'conversations_summary',
|
||||||
|
businessHours: reportObj?.businessHours,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
downloadLabelReports(_, reportObj) {
|
downloadLabelReports(_, reportObj) {
|
||||||
return Report.getLabelReports(reportObj)
|
return Report.getLabelReports(reportObj)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
|||||||
@@ -201,4 +201,24 @@ describe('#actions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#downloadConversationsSummaryReports', () => {
|
||||||
|
it('open CSV download prompt if API is success', async () => {
|
||||||
|
const data = `Conversations,Messages received,Messages sent,Avg first response time,Avg resolution time,Resolution count,Avg customer waiting time
|
||||||
|
217,323,623,23 hours 22 minutes,179 days 18 hours,30,48 days 4 hours`;
|
||||||
|
axios.get.mockResolvedValue({ data });
|
||||||
|
const param = {
|
||||||
|
from: 1631039400,
|
||||||
|
to: 1635013800,
|
||||||
|
fileName: 'conversations-summary-report-24-10-2021.csv',
|
||||||
|
};
|
||||||
|
actions.downloadConversationsSummaryReports(1, param);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||||
|
param.fileName,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
|
|
||||||
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
@@ -31,3 +35,55 @@ export const resolveMaximumFileUploadSize = value => {
|
|||||||
|
|
||||||
return parsedValue;
|
return parsedValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a file type is allowed for a specific channel
|
||||||
|
* @param {File} file - The file to validate
|
||||||
|
* @param {Object} options - Validation options
|
||||||
|
* @param {string} options.channelType - The channel type
|
||||||
|
* @param {string} options.medium - The channel medium
|
||||||
|
* @param {string} options.conversationType - The conversation type (for Instagram DM detection)
|
||||||
|
* @param {boolean} options.isInstagramChannel - Whether it's an Instagram channel
|
||||||
|
* @param {boolean} options.isOnPrivateNote - Whether composing a private note (uses broader file type list)
|
||||||
|
* @returns {boolean} - True if file type is allowed, false otherwise
|
||||||
|
*/
|
||||||
|
export const isFileTypeAllowedForChannel = (file, options = {}) => {
|
||||||
|
if (!file || file.size === 0) return false;
|
||||||
|
|
||||||
|
const {
|
||||||
|
channelType: originalChannelType,
|
||||||
|
medium,
|
||||||
|
conversationType,
|
||||||
|
isInstagramChannel,
|
||||||
|
isOnPrivateNote,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Use broader file types for private notes (matches file picker behavior)
|
||||||
|
const allowedFileTypes = isOnPrivateNote
|
||||||
|
? ALLOWED_FILE_TYPES
|
||||||
|
: getAllowedFileTypesByChannel({
|
||||||
|
channelType:
|
||||||
|
isInstagramChannel || conversationType === 'instagram_direct_message'
|
||||||
|
? INBOX_TYPES.INSTAGRAM
|
||||||
|
: originalChannelType,
|
||||||
|
medium,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and validate
|
||||||
|
const allowedTypesArray = allowedFileTypes.split(',').map(t => t.trim());
|
||||||
|
const fileExtension = `.${file.name.split('.').pop()}`;
|
||||||
|
|
||||||
|
return allowedTypesArray.some(allowedType => {
|
||||||
|
// Check for exact file extension match
|
||||||
|
if (allowedType === fileExtension) return true;
|
||||||
|
|
||||||
|
// Check for wildcard MIME type (e.g., image/*)
|
||||||
|
if (allowedType.endsWith('/*')) {
|
||||||
|
const prefix = allowedType.slice(0, -2); // Remove '/*'
|
||||||
|
return file.type.startsWith(prefix + '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for exact MIME type match
|
||||||
|
return allowedType === file.type;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
fileSizeInMegaBytes,
|
fileSizeInMegaBytes,
|
||||||
checkFileSizeLimit,
|
checkFileSizeLimit,
|
||||||
resolveMaximumFileUploadSize,
|
resolveMaximumFileUploadSize,
|
||||||
|
isFileTypeAllowedForChannel,
|
||||||
} from '../FileHelper';
|
} from '../FileHelper';
|
||||||
|
|
||||||
describe('#File Helpers', () => {
|
describe('#File Helpers', () => {
|
||||||
@@ -61,4 +62,187 @@ describe('#File Helpers', () => {
|
|||||||
expect(resolveMaximumFileUploadSize(75)).toBe(75);
|
expect(resolveMaximumFileUploadSize(75)).toBe(75);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isFileTypeAllowedForChannel', () => {
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should return false for null file', () => {
|
||||||
|
expect(isFileTypeAllowedForChannel(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for undefined file', () => {
|
||||||
|
expect(isFileTypeAllowedForChannel(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for file with zero size', () => {
|
||||||
|
const file = { name: 'test.png', type: 'image/png', size: 0 };
|
||||||
|
expect(isFileTypeAllowedForChannel(file)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wildcard MIME types', () => {
|
||||||
|
it('should allow image/png when image/* is allowed', () => {
|
||||||
|
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow image/jpeg when image/* is allowed', () => {
|
||||||
|
const file = { name: 'test.jpg', type: 'image/jpeg', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow audio/mp3 when audio/* is allowed', () => {
|
||||||
|
const file = { name: 'test.mp3', type: 'audio/mp3', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow video/mp4 when video/* is allowed', () => {
|
||||||
|
const file = { name: 'test.mp4', type: 'video/mp4', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exact MIME types', () => {
|
||||||
|
it('should allow application/pdf when explicitly allowed', () => {
|
||||||
|
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow text/plain when explicitly allowed', () => {
|
||||||
|
const file = { name: 'test.txt', type: 'text/plain', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file extensions', () => {
|
||||||
|
it('should allow .3gpp extension when explicitly allowed', () => {
|
||||||
|
const file = { name: 'test.3gpp', type: '', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Instagram special handling', () => {
|
||||||
|
it('should use Instagram rules when isInstagramChannel is true', () => {
|
||||||
|
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
isInstagramChannel: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use Instagram rules when conversationType is instagram_direct_message', () => {
|
||||||
|
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
conversationType: 'instagram_direct_message',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disallowed file types', () => {
|
||||||
|
it('should reject executable files', () => {
|
||||||
|
const file = {
|
||||||
|
name: 'malware.exe',
|
||||||
|
type: 'application/x-msdownload',
|
||||||
|
size: 1000,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject unsupported file types', () => {
|
||||||
|
const file = {
|
||||||
|
name: 'test.xyz',
|
||||||
|
type: 'application/x-unknown',
|
||||||
|
size: 1000,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::WebWidget',
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('channel-specific rules', () => {
|
||||||
|
it('should allow WhatsApp-specific file types', () => {
|
||||||
|
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::Whatsapp',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow Twilio WhatsApp-specific file types', () => {
|
||||||
|
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::TwilioSms',
|
||||||
|
medium: 'whatsapp',
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('private note file types', () => {
|
||||||
|
it('should allow broader file types for private notes', () => {
|
||||||
|
const file = {
|
||||||
|
name: 'test.pdf',
|
||||||
|
type: 'application/pdf',
|
||||||
|
size: 1000,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::Line',
|
||||||
|
isOnPrivateNote: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow CSV files in private notes', () => {
|
||||||
|
const file = { name: 'data.csv', type: 'text/csv', size: 1000 };
|
||||||
|
expect(
|
||||||
|
isFileTypeAllowedForChannel(file, {
|
||||||
|
channelType: 'Channel::Line',
|
||||||
|
isOnPrivateNote: true,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export default {
|
|||||||
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
||||||
this.setLocale(locale);
|
this.setLocale(locale);
|
||||||
this.setWidgetColor(widgetColor);
|
this.setWidgetColor(widgetColor);
|
||||||
|
this.setWidgetColorVariable(widgetColor);
|
||||||
setHeader(window.authToken);
|
setHeader(window.authToken);
|
||||||
if (this.isIFrame) {
|
if (this.isIFrame) {
|
||||||
this.registerListeners();
|
this.registerListeners();
|
||||||
@@ -114,6 +115,14 @@ export default {
|
|||||||
'resetCampaign',
|
'resetCampaign',
|
||||||
]),
|
]),
|
||||||
...mapActions('agent', ['fetchAvailableAgents']),
|
...mapActions('agent', ['fetchAvailableAgents']),
|
||||||
|
setWidgetColorVariable(widgetColor) {
|
||||||
|
if (widgetColor) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--widget-color',
|
||||||
|
widgetColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
scrollConversationToBottom() {
|
scrollConversationToBottom() {
|
||||||
const container = this.$el.querySelector('.conversation-wrap');
|
const container = this.$el.querySelector('.conversation-wrap');
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
|
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
|
||||||
:class="{
|
:class="{
|
||||||
'!shadow-n-brand dark:!shadow-n-brand': isFocused,
|
'!shadow-[var(--widget-color,#2781f6)]': isFocused,
|
||||||
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
|
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
|
||||||
}"
|
}"
|
||||||
@keydown.esc="hideEmojiPicker"
|
@keydown.esc="hideEmojiPicker"
|
||||||
|
|||||||
@@ -159,8 +159,11 @@ class ActionCableListener < BaseListener
|
|||||||
end
|
end
|
||||||
|
|
||||||
def contact_deleted(event)
|
def contact_deleted(event)
|
||||||
contact, account = extract_contact_and_account(event)
|
contact_data = event.data[:contact_data]
|
||||||
broadcast(account, [account_token(account)], CONTACT_DELETED, contact.push_event_data)
|
account = Account.find_by(id: contact_data[:account_id])
|
||||||
|
return if account.blank?
|
||||||
|
|
||||||
|
broadcast(account, [account_token(account)], CONTACT_DELETED, contact_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_mentioned(event)
|
def conversation_mentioned(event)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
|
|||||||
include Reportable
|
include Reportable
|
||||||
include Featurable
|
include Featurable
|
||||||
include CacheKeys
|
include CacheKeys
|
||||||
|
include CaptainFeaturable
|
||||||
|
|
||||||
SETTINGS_PARAMS_SCHEMA = {
|
SETTINGS_PARAMS_SCHEMA = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -41,6 +42,30 @@ class Account < ApplicationRecord
|
|||||||
'conversation_required_attributes': {
|
'conversation_required_attributes': {
|
||||||
'type': %w[array null],
|
'type': %w[array null],
|
||||||
'items': { 'type': 'string' }
|
'items': { 'type': 'string' }
|
||||||
|
},
|
||||||
|
'captain_models': {
|
||||||
|
'type': %w[object null],
|
||||||
|
'properties': {
|
||||||
|
'editor': { 'type': %w[string null] },
|
||||||
|
'assistant': { 'type': %w[string null] },
|
||||||
|
'copilot': { 'type': %w[string null] },
|
||||||
|
'label_suggestion': { 'type': %w[string null] },
|
||||||
|
'audio_transcription': { 'type': %w[string null] },
|
||||||
|
'help_center_search': { 'type': %w[string null] }
|
||||||
|
},
|
||||||
|
'additionalProperties': false
|
||||||
|
},
|
||||||
|
'captain_features': {
|
||||||
|
'type': %w[object null],
|
||||||
|
'properties': {
|
||||||
|
'editor': { 'type': %w[boolean null] },
|
||||||
|
'assistant': { 'type': %w[boolean null] },
|
||||||
|
'copilot': { 'type': %w[boolean null] },
|
||||||
|
'label_suggestion': { 'type': %w[boolean null] },
|
||||||
|
'audio_transcription': { 'type': %w[boolean null] },
|
||||||
|
'help_center_search': { 'type': %w[boolean null] }
|
||||||
|
},
|
||||||
|
'additionalProperties': false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'required': [],
|
'required': [],
|
||||||
@@ -59,7 +84,9 @@ class Account < ApplicationRecord
|
|||||||
attribute_resolver: ->(record) { record.settings }
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||||
|
|
||||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
||||||
|
store_accessor :settings, :captain_models, :captain_features
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module CaptainFeaturable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
validate :validate_captain_models
|
||||||
|
|
||||||
|
# Dynamically define accessor methods for each captain feature
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
# Define enabled? methods (e.g., captain_editor_enabled?)
|
||||||
|
define_method("captain_#{feature_key}_enabled?") do
|
||||||
|
captain_features_with_defaults[feature_key]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Define model accessor methods (e.g., captain_editor_model)
|
||||||
|
define_method("captain_#{feature_key}_model") do
|
||||||
|
captain_models_with_defaults[feature_key]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_preferences
|
||||||
|
{
|
||||||
|
models: captain_models_with_defaults,
|
||||||
|
features: captain_features_with_defaults
|
||||||
|
}.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def captain_models_with_defaults
|
||||||
|
stored_models = captain_models || {}
|
||||||
|
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
|
||||||
|
stored_value = stored_models[feature_key]
|
||||||
|
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
|
||||||
|
stored_value
|
||||||
|
else
|
||||||
|
Llm::Models.default_model_for(feature_key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_features_with_defaults
|
||||||
|
stored_features = captain_features || {}
|
||||||
|
Llm::Models.feature_keys.index_with do |feature_key|
|
||||||
|
stored_features[feature_key] == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_captain_models
|
||||||
|
return if captain_models.blank?
|
||||||
|
|
||||||
|
captain_models.each do |feature_key, model_name|
|
||||||
|
next if model_name.blank?
|
||||||
|
next if Llm::Models.valid_model_for?(feature_key, model_name)
|
||||||
|
|
||||||
|
allowed_models = Llm::Models.models_for(feature_key)
|
||||||
|
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+10
-6
@@ -179,11 +179,9 @@ class Contact < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.resolved_contacts(use_crm_v2: false)
|
def self.resolved_contacts(use_crm_v2: false)
|
||||||
if use_crm_v2
|
return where(contact_type: 'lead') if use_crm_v2
|
||||||
where(contact_type: 'lead')
|
|
||||||
else
|
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
|
||||||
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def discard_invalid_attrs
|
def discard_invalid_attrs
|
||||||
@@ -243,7 +241,13 @@ class Contact < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def dispatch_destroy_event
|
def dispatch_destroy_event
|
||||||
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
|
# Pass serialized data instead of ActiveRecord object to avoid DeserializationError
|
||||||
|
# when the async EventDispatcherJob runs after the contact has been deleted
|
||||||
|
Rails.configuration.dispatcher.dispatch(
|
||||||
|
CONTACT_DELETED,
|
||||||
|
Time.zone.now,
|
||||||
|
contact_data: push_event_data.merge(account_id: account_id)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Contact.include_mod_with('Concerns::Contact')
|
Contact.include_mod_with('Concerns::Contact')
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ class Inbox < ApplicationRecord
|
|||||||
channel_type == 'Channel::Whatsapp'
|
channel_type == 'Channel::Whatsapp'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def twilio_whatsapp?
|
||||||
|
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
|
||||||
|
end
|
||||||
|
|
||||||
def assignable_agents
|
def assignable_agents
|
||||||
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class CsatSurveyService
|
|||||||
|
|
||||||
if whatsapp_channel? && template_available_and_approved?
|
if whatsapp_channel? && template_available_and_approved?
|
||||||
send_whatsapp_template_survey
|
send_whatsapp_template_survey
|
||||||
|
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
|
||||||
|
send_twilio_whatsapp_template_survey
|
||||||
elsif within_messaging_window?
|
elsif within_messaging_window?
|
||||||
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
|
||||||
else
|
else
|
||||||
@@ -45,7 +47,7 @@ class CsatSurveyService
|
|||||||
template_config = inbox.csat_config&.dig('template')
|
template_config = inbox.csat_config&.dig('template')
|
||||||
return false unless template_config
|
return false unless template_config
|
||||||
|
|
||||||
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
|
||||||
|
|
||||||
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
status_result = inbox.channel.provider_service.get_template_status(template_name)
|
||||||
|
|
||||||
@@ -55,9 +57,25 @@ class CsatSurveyService
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def twilio_template_available_and_approved?
|
||||||
|
template_config = inbox.csat_config&.dig('template')
|
||||||
|
return false unless template_config
|
||||||
|
|
||||||
|
content_sid = template_config['content_sid']
|
||||||
|
return false unless content_sid
|
||||||
|
|
||||||
|
template_service = Twilio::CsatTemplateService.new(inbox.channel)
|
||||||
|
status_result = template_service.get_template_status(content_sid)
|
||||||
|
|
||||||
|
status_result[:success] && status_result[:template][:status] == 'approved'
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error checking Twilio CSAT template status: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def send_whatsapp_template_survey
|
def send_whatsapp_template_survey
|
||||||
template_config = inbox.csat_config&.dig('template')
|
template_config = inbox.csat_config&.dig('template')
|
||||||
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
|
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
|
||||||
|
|
||||||
phone_number = conversation.contact_inbox.source_id
|
phone_number = conversation.contact_inbox.source_id
|
||||||
template_info = build_template_info(template_name, template_config)
|
template_info = build_template_info(template_name, template_config)
|
||||||
@@ -95,6 +113,26 @@ class CsatSurveyService
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_twilio_whatsapp_template_survey
|
||||||
|
template_config = inbox.csat_config&.dig('template')
|
||||||
|
content_sid = template_config['content_sid']
|
||||||
|
|
||||||
|
phone_number = conversation.contact_inbox.source_id
|
||||||
|
content_variables = { '1' => conversation.uuid }
|
||||||
|
message = build_csat_message
|
||||||
|
|
||||||
|
send_service = Twilio::SendOnTwilioService.new(message: message)
|
||||||
|
result = send_service.send_csat_template_message(
|
||||||
|
phone_number: phone_number,
|
||||||
|
content_sid: content_sid,
|
||||||
|
content_variables: content_variables
|
||||||
|
)
|
||||||
|
|
||||||
|
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
def create_csat_not_sent_activity_message
|
def create_csat_not_sent_activity_message
|
||||||
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
|
||||||
activity_message_params = {
|
activity_message_params = {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
class CsatTemplateManagementService
|
||||||
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
|
||||||
|
def initialize(inbox)
|
||||||
|
@inbox = inbox
|
||||||
|
end
|
||||||
|
|
||||||
|
def template_status
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return { template_exists: false } unless template
|
||||||
|
|
||||||
|
if @inbox.twilio_whatsapp?
|
||||||
|
get_twilio_template_status(template)
|
||||||
|
else
|
||||||
|
get_whatsapp_template_status(template)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||||
|
{ service_error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template(template_params)
|
||||||
|
validate_template_params!(template_params)
|
||||||
|
|
||||||
|
delete_existing_template_if_needed
|
||||||
|
|
||||||
|
result = create_template_via_provider(template_params)
|
||||||
|
update_inbox_csat_config(result) if result[:success]
|
||||||
|
|
||||||
|
result
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||||
|
{ success: false, service_error: 'Template creation failed' }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_template_params!(template_params)
|
||||||
|
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template_via_provider(template_params)
|
||||||
|
if @inbox.twilio_whatsapp?
|
||||||
|
create_twilio_template(template_params)
|
||||||
|
else
|
||||||
|
create_whatsapp_template(template_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_twilio_template(template_params)
|
||||||
|
template_config = build_template_config(template_params)
|
||||||
|
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||||
|
template_service.create_template(template_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_whatsapp_template(template_params)
|
||||||
|
template_config = build_template_config(template_params)
|
||||||
|
Whatsapp::CsatTemplateService.new(@inbox.channel).create_template(template_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_config(template_params)
|
||||||
|
{
|
||||||
|
message: template_params[:message],
|
||||||
|
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||||
|
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||||
|
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||||
|
template_name: CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_inbox_csat_config(result)
|
||||||
|
current_config = @inbox.csat_config || {}
|
||||||
|
template_data = build_template_data_from_result(result)
|
||||||
|
updated_config = current_config.merge('template' => template_data)
|
||||||
|
@inbox.update!(csat_config: updated_config)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_data_from_result(result)
|
||||||
|
if @inbox.twilio_whatsapp?
|
||||||
|
build_twilio_template_data(result)
|
||||||
|
else
|
||||||
|
build_whatsapp_cloud_template_data(result)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_twilio_template_data(result)
|
||||||
|
{
|
||||||
|
'friendly_name' => result[:friendly_name],
|
||||||
|
'content_sid' => result[:content_sid],
|
||||||
|
'approval_sid' => result[:approval_sid],
|
||||||
|
'language' => result[:language],
|
||||||
|
'status' => result[:whatsapp_status] || result[:status],
|
||||||
|
'created_at' => Time.current.iso8601
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_whatsapp_cloud_template_data(result)
|
||||||
|
{
|
||||||
|
'name' => result[:template_name],
|
||||||
|
'template_id' => result[:template_id],
|
||||||
|
'language' => result[:language],
|
||||||
|
'created_at' => Time.current.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_twilio_template_status(template)
|
||||||
|
content_sid = template['content_sid']
|
||||||
|
return { template_exists: false } unless content_sid
|
||||||
|
|
||||||
|
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||||
|
status_result = template_service.get_template_status(content_sid)
|
||||||
|
|
||||||
|
if status_result[:success]
|
||||||
|
{
|
||||||
|
template_exists: true,
|
||||||
|
friendly_name: template['friendly_name'],
|
||||||
|
content_sid: template['content_sid'],
|
||||||
|
status: status_result[:template][:status],
|
||||||
|
language: template['language']
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
template_exists: false,
|
||||||
|
error: 'Template not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_whatsapp_template_status(template)
|
||||||
|
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||||
|
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
|
||||||
|
|
||||||
|
if status_result[:success]
|
||||||
|
{
|
||||||
|
template_exists: true,
|
||||||
|
template_name: template_name,
|
||||||
|
status: status_result[:template][:status],
|
||||||
|
template_id: status_result[:template][:id]
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
template_exists: false,
|
||||||
|
error: 'Template not found'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_template_if_needed
|
||||||
|
template = @inbox.csat_config&.dig('template')
|
||||||
|
return true if template.blank?
|
||||||
|
|
||||||
|
if @inbox.twilio_whatsapp?
|
||||||
|
delete_existing_twilio_template(template)
|
||||||
|
else
|
||||||
|
delete_existing_whatsapp_template(template)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_twilio_template(template)
|
||||||
|
content_sid = template['content_sid']
|
||||||
|
return true if content_sid.blank?
|
||||||
|
|
||||||
|
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||||
|
deletion_result = template_service.delete_template(nil, content_sid)
|
||||||
|
|
||||||
|
if deletion_result[:success]
|
||||||
|
Rails.logger.info "Deleted existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to delete existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_existing_whatsapp_template(template)
|
||||||
|
template_name = template['name']
|
||||||
|
return true if template_name.blank?
|
||||||
|
|
||||||
|
csat_template_service = Whatsapp::CsatTemplateService.new(@inbox.channel)
|
||||||
|
template_status = csat_template_service.get_template_status(template_name)
|
||||||
|
return true unless template_status[:success]
|
||||||
|
|
||||||
|
deletion_result = csat_template_service.delete_template(template_name)
|
||||||
|
if deletion_result[:success]
|
||||||
|
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||||
|
true
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
class Whatsapp::CsatTemplateNameService
|
class CsatTemplateNameService
|
||||||
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
||||||
|
|
||||||
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class Twilio::CsatTemplateApiClient
|
||||||
|
def initialize(twilio_channel)
|
||||||
|
@twilio_channel = twilio_channel
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template(request_body)
|
||||||
|
HTTParty.post(
|
||||||
|
"#{api_base_path}/v1/Content",
|
||||||
|
headers: api_headers,
|
||||||
|
body: request_body.to_json
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit_for_approval(approval_url, template_name, category)
|
||||||
|
request_body = {
|
||||||
|
name: template_name,
|
||||||
|
category: category
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTParty.post(
|
||||||
|
approval_url,
|
||||||
|
headers: api_headers,
|
||||||
|
body: request_body.to_json
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_template(content_sid)
|
||||||
|
HTTParty.delete(
|
||||||
|
"#{api_base_path}/v1/Content/#{content_sid}",
|
||||||
|
headers: api_headers
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_template(content_sid)
|
||||||
|
HTTParty.get(
|
||||||
|
"#{api_base_path}/v1/Content/#{content_sid}",
|
||||||
|
headers: api_headers
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_approval_status(content_sid)
|
||||||
|
HTTParty.get(
|
||||||
|
"#{api_base_path}/v1/Content/#{content_sid}/ApprovalRequests",
|
||||||
|
headers: api_headers
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def api_headers
|
||||||
|
{
|
||||||
|
'Authorization' => "Basic #{encoded_credentials}",
|
||||||
|
'Content-Type' => 'application/json'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_credentials
|
||||||
|
if @twilio_channel.api_key_sid.present?
|
||||||
|
Base64.strict_encode64("#{@twilio_channel.api_key_sid}:#{@twilio_channel.auth_token}")
|
||||||
|
else
|
||||||
|
Base64.strict_encode64("#{@twilio_channel.account_sid}:#{@twilio_channel.auth_token}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_base_path
|
||||||
|
'https://content.twilio.com'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
class Twilio::CsatTemplateService
|
||||||
|
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||||
|
DEFAULT_LANGUAGE = 'en'.freeze
|
||||||
|
TEMPLATE_CATEGORY = 'UTILITY'.freeze
|
||||||
|
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||||
|
TEMPLATE_CONTENT_TYPE = 'twilio/call-to-action'.freeze
|
||||||
|
|
||||||
|
def initialize(twilio_channel)
|
||||||
|
@twilio_channel = twilio_channel
|
||||||
|
@api_client = Twilio::CsatTemplateApiClient.new(twilio_channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_template(template_config)
|
||||||
|
base_name = template_config[:template_name]
|
||||||
|
template_name = generate_template_name(base_name)
|
||||||
|
template_config_with_name = template_config.merge(template_name: template_name)
|
||||||
|
|
||||||
|
request_body = build_template_request_body(template_config_with_name)
|
||||||
|
|
||||||
|
# Step 1: Create template
|
||||||
|
response = @api_client.create_template(request_body)
|
||||||
|
|
||||||
|
return process_template_creation_response(response, template_config_with_name) unless response.success? && response['sid']
|
||||||
|
|
||||||
|
# Step 2: Submit for WhatsApp approval using the approval_create URL
|
||||||
|
approval_url = response.dig('links', 'approval_create')
|
||||||
|
|
||||||
|
if approval_url.present?
|
||||||
|
approval_response = submit_for_whatsapp_approval(approval_url, template_config_with_name[:template_name])
|
||||||
|
process_approval_response(approval_response, response, template_config_with_name)
|
||||||
|
else
|
||||||
|
Rails.logger.warn 'No approval_create URL provided in template creation response'
|
||||||
|
# Fallback if no approval URL provided
|
||||||
|
process_template_creation_response(response, template_config_with_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_template(_template_name = nil, content_sid = nil)
|
||||||
|
content_sid ||= current_template_sid_from_config
|
||||||
|
return { success: false, error: 'No template to delete' } unless content_sid
|
||||||
|
|
||||||
|
response = @api_client.delete_template(content_sid)
|
||||||
|
{ success: response.success?, response_body: response.body }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_template_status(content_sid)
|
||||||
|
return { success: false, error: 'No content SID provided' } unless content_sid
|
||||||
|
|
||||||
|
template_response = fetch_template_details(content_sid)
|
||||||
|
return template_response unless template_response[:success]
|
||||||
|
|
||||||
|
approval_response = fetch_approval_status(content_sid)
|
||||||
|
build_template_status_response(content_sid, template_response[:data], approval_response)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Error fetching Twilio template status: #{e.message}"
|
||||||
|
{ success: false, error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_template_details(content_sid)
|
||||||
|
response = @api_client.fetch_template(content_sid)
|
||||||
|
|
||||||
|
if response.success?
|
||||||
|
{ success: true, data: response }
|
||||||
|
else
|
||||||
|
Rails.logger.error "Failed to get template details: #{response.code} - #{response.body}"
|
||||||
|
{ success: false, error: 'Template not found' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_approval_status(content_sid)
|
||||||
|
@api_client.fetch_approval_status(content_sid)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_status_response(content_sid, template_response, approval_response)
|
||||||
|
if approval_response.success? && approval_response['whatsapp']
|
||||||
|
build_approved_template_response(content_sid, template_response, approval_response['whatsapp'])
|
||||||
|
else
|
||||||
|
build_pending_template_response(content_sid, template_response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_approved_template_response(content_sid, template_response, whatsapp_data)
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
template: {
|
||||||
|
content_sid: content_sid,
|
||||||
|
friendly_name: whatsapp_data['name'] || template_response['friendly_name'],
|
||||||
|
status: whatsapp_data['status'] || 'pending',
|
||||||
|
language: template_response['language'] || 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_pending_template_response(content_sid, template_response)
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
template: {
|
||||||
|
content_sid: content_sid,
|
||||||
|
friendly_name: template_response['friendly_name'],
|
||||||
|
status: 'pending',
|
||||||
|
language: template_response['language'] || 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_template_name(base_name)
|
||||||
|
current_template_name = current_template_name_from_config
|
||||||
|
CsatTemplateNameService.generate_next_template_name(base_name, @twilio_channel.inbox.id, current_template_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_template_name_from_config
|
||||||
|
@twilio_channel.inbox.csat_config&.dig('template', 'friendly_name')
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_template_sid_from_config
|
||||||
|
@twilio_channel.inbox.csat_config&.dig('template', 'content_sid')
|
||||||
|
end
|
||||||
|
|
||||||
|
def template_exists_in_config?
|
||||||
|
content_sid = current_template_sid_from_config
|
||||||
|
friendly_name = current_template_name_from_config
|
||||||
|
|
||||||
|
content_sid.present? && friendly_name.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_request_body(template_config)
|
||||||
|
{
|
||||||
|
friendly_name: template_config[:template_name],
|
||||||
|
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||||
|
variables: {
|
||||||
|
'1' => '12345' # Example conversation UUID
|
||||||
|
},
|
||||||
|
types: {
|
||||||
|
TEMPLATE_CONTENT_TYPE => {
|
||||||
|
body: template_config[:message],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: 'URL',
|
||||||
|
title: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||||
|
url: "#{template_config[:base_url]}/survey/responses/{{1}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def submit_for_whatsapp_approval(approval_url, template_name)
|
||||||
|
@api_client.submit_for_approval(approval_url, template_name, TEMPLATE_CATEGORY)
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_template_creation_response(response, template_config = {})
|
||||||
|
if response.success? && response['sid']
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
content_sid: response['sid'],
|
||||||
|
friendly_name: template_config[:template_name],
|
||||||
|
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||||
|
status: TEMPLATE_STATUS_PENDING
|
||||||
|
}
|
||||||
|
else
|
||||||
|
Rails.logger.error "Twilio template creation failed: #{response.code} - #{response.body}"
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Template creation failed',
|
||||||
|
response_body: response.body
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_approval_response(approval_response, creation_response, template_config)
|
||||||
|
if approval_response.success?
|
||||||
|
build_successful_approval_response(approval_response, creation_response, template_config)
|
||||||
|
else
|
||||||
|
build_failed_approval_response(approval_response, creation_response, template_config)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_successful_approval_response(approval_response, creation_response, template_config)
|
||||||
|
approval_data = approval_response.parsed_response
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
content_sid: creation_response['sid'],
|
||||||
|
friendly_name: template_config[:template_name],
|
||||||
|
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||||
|
status: TEMPLATE_STATUS_PENDING,
|
||||||
|
approval_sid: approval_data['sid'],
|
||||||
|
whatsapp_status: approval_data.dig('whatsapp', 'status') || TEMPLATE_STATUS_PENDING
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_failed_approval_response(approval_response, creation_response, template_config)
|
||||||
|
Rails.logger.error "Twilio template approval submission failed: #{approval_response.code} - #{approval_response.body}"
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
content_sid: creation_response['sid'],
|
||||||
|
friendly_name: template_config[:template_name],
|
||||||
|
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||||
|
status: 'created'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,24 @@
|
|||||||
class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
||||||
|
def send_csat_template_message(phone_number:, content_sid:, content_variables: {})
|
||||||
|
send_params = {
|
||||||
|
to: phone_number,
|
||||||
|
content_sid: content_sid
|
||||||
|
}
|
||||||
|
|
||||||
|
send_params[:content_variables] = content_variables.to_json if content_variables.present?
|
||||||
|
send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true)
|
||||||
|
|
||||||
|
# Add messaging service or from number
|
||||||
|
send_params = send_params.merge(channel.send(:send_message_from))
|
||||||
|
|
||||||
|
twilio_message = channel.send(:client).messages.create(**send_params)
|
||||||
|
|
||||||
|
{ success: true, message_id: twilio_message.sid }
|
||||||
|
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||||
|
Rails.logger.error "Failed to send Twilio template message: #{e.message}"
|
||||||
|
{ success: false, error: e.message }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def channel_class
|
def channel_class
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Whatsapp::CsatTemplateService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete_template(template_name = nil)
|
def delete_template(template_name = nil)
|
||||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
|
template_name ||= CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
|
||||||
response = HTTParty.delete(
|
response = HTTParty.delete(
|
||||||
"#{business_account_path}/message_templates?name=#{template_name}",
|
"#{business_account_path}/message_templates?name=#{template_name}",
|
||||||
headers: api_headers
|
headers: api_headers
|
||||||
@@ -51,7 +51,7 @@ class Whatsapp::CsatTemplateService
|
|||||||
|
|
||||||
def generate_template_name(base_name)
|
def generate_template_name(base_name)
|
||||||
current_template_name = current_template_name_from_config
|
current_template_name = current_template_name_from_config
|
||||||
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_template_name_from_config
|
def current_template_name_from_config
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def delete_csat_template(template_name = nil)
|
def delete_csat_template(template_name = nil)
|
||||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
|
template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
|
||||||
csat_template_service.delete_template(template_name)
|
csat_template_service.delete_template(template_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ class Whatsapp::TemplateProcessorService
|
|||||||
|
|
||||||
def find_template
|
def find_template
|
||||||
channel.message_templates.find do |t|
|
channel.message_templates.find do |t|
|
||||||
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
|
t['name'] == template_params['name'] &&
|
||||||
|
t['language']&.downcase == template_params['language']&.downcase &&
|
||||||
|
t['status']&.downcase == 'approved'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
|
||||||
|
|
||||||
|
<% headers = [
|
||||||
|
I18n.t('reports.conversation_csv.conversations_count'),
|
||||||
|
I18n.t('reports.conversation_csv.incoming_messages_count'),
|
||||||
|
I18n.t('reports.conversation_csv.outgoing_messages_count'),
|
||||||
|
I18n.t('reports.conversation_csv.avg_first_response_time'),
|
||||||
|
I18n.t('reports.conversation_csv.avg_resolution_time'),
|
||||||
|
I18n.t('reports.conversation_csv.resolution_count'),
|
||||||
|
I18n.t('reports.conversation_csv.avg_customer_waiting_time')
|
||||||
|
]
|
||||||
|
%>
|
||||||
|
<%= CSVSafe.generate_line headers -%>
|
||||||
|
<% @report_data.each do |row| %>
|
||||||
|
<%= CSVSafe.generate_line row -%>
|
||||||
|
<% end %>
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '4.9.1'
|
version: '4.9.2'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
type: secret
|
type: secret
|
||||||
- name: CAPTAIN_OPEN_AI_MODEL
|
- name: CAPTAIN_OPEN_AI_MODEL
|
||||||
display_title: 'OpenAI Model'
|
display_title: 'OpenAI Model'
|
||||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
|
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini'
|
||||||
locked: false
|
locked: false
|
||||||
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
- name: CAPTAIN_OPEN_AI_ENDPOINT
|
||||||
display_title: 'OpenAI API Endpoint (optional)'
|
display_title: 'OpenAI API Endpoint (optional)'
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
aproviders:
|
||||||
|
openai:
|
||||||
|
display_name: 'OpenAI'
|
||||||
|
anthropic:
|
||||||
|
display_name: 'Anthropic'
|
||||||
|
gemini:
|
||||||
|
display_name: 'Gemini'
|
||||||
|
|
||||||
|
models:
|
||||||
|
gpt-4.1:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-4.1'
|
||||||
|
credit_multiplier: 3
|
||||||
|
gpt-4.1-mini:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-4.1 Mini'
|
||||||
|
credit_multiplier: 1
|
||||||
|
gpt-4.1-nano:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-4.1 Nano'
|
||||||
|
credit_multiplier: 1
|
||||||
|
gpt-5.1:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-5.1'
|
||||||
|
credit_multiplier: 2
|
||||||
|
gpt-5-mini:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-5 Mini'
|
||||||
|
credit_multiplier: 1
|
||||||
|
gpt-5-nano:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-5 Nano'
|
||||||
|
credit_multiplier: 1
|
||||||
|
gpt-5.2:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'GPT-5.2'
|
||||||
|
credit_multiplier: 3
|
||||||
|
claude-haiku-4.5:
|
||||||
|
provider: anthropic
|
||||||
|
display_name: 'Claude Haiku 4.5'
|
||||||
|
coming_soon: true
|
||||||
|
credit_multiplier: 2
|
||||||
|
claude-sonnet-4.5:
|
||||||
|
provider: anthropic
|
||||||
|
display_name: 'Claude Sonnet 4.5'
|
||||||
|
coming_soon: true
|
||||||
|
credit_multiplier: 3
|
||||||
|
gemini-3-flash:
|
||||||
|
provider: gemini
|
||||||
|
display_name: 'Gemini 3 Flash'
|
||||||
|
coming_soon: true
|
||||||
|
credit_multiplier: 1
|
||||||
|
gemini-3-pro:
|
||||||
|
provider: gemini
|
||||||
|
display_name: 'Gemini 3 Pro'
|
||||||
|
coming_soon: true
|
||||||
|
credit_multiplier: 3
|
||||||
|
whisper-1:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'Whisper'
|
||||||
|
credit_multiplier: 1
|
||||||
|
text-embedding-3-small:
|
||||||
|
provider: openai
|
||||||
|
display_name: 'Text Embedding 3 Small'
|
||||||
|
credit_multiplier: 1
|
||||||
|
|
||||||
|
features:
|
||||||
|
editor:
|
||||||
|
models:
|
||||||
|
[
|
||||||
|
gpt-4.1-mini,
|
||||||
|
gpt-4.1-nano,
|
||||||
|
gpt-5-mini,
|
||||||
|
gpt-4.1,
|
||||||
|
gpt-5.1,
|
||||||
|
gpt-5.2,
|
||||||
|
claude-haiku-4.5,
|
||||||
|
gemini-3-flash,
|
||||||
|
gemini-3-pro,
|
||||||
|
]
|
||||||
|
default: gpt-4.1-mini
|
||||||
|
assistant:
|
||||||
|
models:
|
||||||
|
[
|
||||||
|
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.1
|
||||||
|
copilot:
|
||||||
|
models:
|
||||||
|
[
|
||||||
|
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.1
|
||||||
|
label_suggestion:
|
||||||
|
models:
|
||||||
|
[gpt-4.1-nano, gpt-4.1-mini, gpt-5-mini, gemini-3-flash, claude-haiku-4.5]
|
||||||
|
default: gpt-4.1-nano
|
||||||
|
audio_transcription:
|
||||||
|
models: [whisper-1]
|
||||||
|
default: whisper-1
|
||||||
|
help_center_search:
|
||||||
|
models: [text-embedding-3-small]
|
||||||
|
default: text-embedding-3-small
|
||||||
@@ -134,6 +134,8 @@ en:
|
|||||||
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||||
stripe_customer_not_configured: Stripe customer not configured
|
stripe_customer_not_configured: Stripe customer not configured
|
||||||
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
||||||
|
reports:
|
||||||
|
date_range_too_long: Date range cannot exceed 6 months
|
||||||
profile:
|
profile:
|
||||||
mfa:
|
mfa:
|
||||||
enabled: MFA enabled successfully
|
enabled: MFA enabled successfully
|
||||||
@@ -170,6 +172,14 @@ en:
|
|||||||
avg_resolution_time: Avg resolution time
|
avg_resolution_time: Avg resolution time
|
||||||
resolution_count: Resolution Count
|
resolution_count: Resolution Count
|
||||||
avg_customer_waiting_time: Avg customer waiting time
|
avg_customer_waiting_time: Avg customer waiting time
|
||||||
|
conversation_csv:
|
||||||
|
conversations_count: Conversations
|
||||||
|
incoming_messages_count: Messages received
|
||||||
|
outgoing_messages_count: Messages sent
|
||||||
|
avg_first_response_time: Avg first response time
|
||||||
|
avg_resolution_time: Avg resolution time
|
||||||
|
resolution_count: Resolution count
|
||||||
|
avg_customer_waiting_time: Avg customer waiting time
|
||||||
conversation_traffic_csv:
|
conversation_traffic_csv:
|
||||||
timezone: Timezone
|
timezone: Timezone
|
||||||
sla_csv:
|
sla_csv:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
|
|||||||
post :bulk_create, on: :collection
|
post :bulk_create, on: :collection
|
||||||
end
|
end
|
||||||
namespace :captain do
|
namespace :captain do
|
||||||
|
resource :preferences, only: [:show, :update]
|
||||||
resources :assistants do
|
resources :assistants do
|
||||||
member do
|
member do
|
||||||
post :playground
|
post :playground
|
||||||
@@ -417,6 +418,7 @@ Rails.application.routes.draw do
|
|||||||
get :team
|
get :team
|
||||||
get :inbox
|
get :inbox
|
||||||
get :label
|
get :label
|
||||||
|
get :channel
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :reports, only: [:index] do
|
resources :reports, only: [:index] do
|
||||||
@@ -428,6 +430,7 @@ Rails.application.routes.draw do
|
|||||||
get :labels
|
get :labels
|
||||||
get :teams
|
get :teams
|
||||||
get :conversations
|
get :conversations
|
||||||
|
get :conversations_summary
|
||||||
get :conversation_traffic
|
get :conversation_traffic
|
||||||
get :bot_metrics
|
get :bot_metrics
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
class RemoveCountryCodeFromConversationFilters < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
CustomFilter.conversation
|
||||||
|
.where('query::text LIKE ?', '%country_code%')
|
||||||
|
.find_each do |custom_filter|
|
||||||
|
query = custom_filter.query || {}
|
||||||
|
payload = query['payload']
|
||||||
|
|
||||||
|
next unless payload.is_a?(Array)
|
||||||
|
|
||||||
|
updated_payload = payload.reject do |filter|
|
||||||
|
filter.is_a?(Hash) && filter['attribute_key'] == 'country_code'
|
||||||
|
end
|
||||||
|
|
||||||
|
next if updated_payload == payload
|
||||||
|
|
||||||
|
if updated_payload.empty?
|
||||||
|
custom_filter.delete
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
# we will skip model validation, since we don't want any callbacks running
|
||||||
|
custom_filter.update_columns(query: query.merge('payload' => updated_payload))
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# no-op: removed filters cannot be restored reliably
|
||||||
|
end
|
||||||
|
end
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
|
ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
|
|||||||
+11
-11
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Description: Install and manage a Chatwoot installation.
|
# Description: Install and manage a Chatwoot installation.
|
||||||
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
|
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
|
||||||
# Script Version: 3.4.3
|
# Script Version: 3.5.0
|
||||||
# Run this script as root
|
# Run this script as root
|
||||||
|
|
||||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||||
@@ -19,7 +19,7 @@ fi
|
|||||||
# option --output/-o requires 1 argument
|
# option --output/-o requires 1 argument
|
||||||
LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,Upgrade:,webserver,version,web-only,worker-only,convert:
|
LONGOPTS=console,debug,help,install,Install:,logs:,restart,ssl,upgrade,Upgrade:,webserver,version,web-only,worker-only,convert:
|
||||||
OPTIONS=cdhiI:l:rsuU:wvWK
|
OPTIONS=cdhiI:l:rsuU:wvWK
|
||||||
CWCTL_VERSION="3.4.3"
|
CWCTL_VERSION="3.5.0"
|
||||||
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
|
||||||
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
|
CHATWOOT_HUB_URL="https://hub.2.chatwoot.com/events"
|
||||||
|
|
||||||
@@ -209,11 +209,11 @@ EOF
|
|||||||
function install_dependencies() {
|
function install_dependencies() {
|
||||||
apt-get update && apt-get upgrade -y
|
apt-get update && apt-get upgrade -y
|
||||||
apt-get install -y curl
|
apt-get install -y curl
|
||||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor --yes -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
|
||||||
NODE_MAJOR=23
|
NODE_MAJOR=24
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||||
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg 16" > /etc/apt/sources.list.d/pgdg.list
|
echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg 16" > /etc/apt/sources.list.d/pgdg.list
|
||||||
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
|
||||||
@@ -882,7 +882,7 @@ function upgrade_redis() {
|
|||||||
|
|
||||||
echo "Upgrading Redis to v7+ for Rails 7 support(Chatwoot v2.17+)"
|
echo "Upgrading Redis to v7+ for Rails 7 support(Chatwoot v2.17+)"
|
||||||
|
|
||||||
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
|
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor --yes -o /usr/share/keyrings/redis-archive-keyring.gpg
|
||||||
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
|
||||||
apt-get update -y
|
apt-get update -y
|
||||||
apt-get upgrade redis-server -y
|
apt-get upgrade redis-server -y
|
||||||
@@ -908,15 +908,15 @@ function upgrade_node() {
|
|||||||
# Parse major version number
|
# Parse major version number
|
||||||
major_version=$(echo "$current_version" | cut -d. -f1)
|
major_version=$(echo "$current_version" | cut -d. -f1)
|
||||||
|
|
||||||
if [ "$major_version" -ge 23 ]; then
|
if [ "$major_version" -ge 24 ]; then
|
||||||
echo "Node.js is already version $current_version (>= 23.x). Skipping Node.js upgrade."
|
echo "Node.js is already version $current_version (>= 24.x). Skipping Node.js upgrade."
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Upgrading Node.js version to v23.x"
|
echo "Upgrading Node.js version to v24.x"
|
||||||
mkdir -p /etc/apt/keyrings
|
mkdir -p /etc/apt/keyrings
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
|
||||||
NODE_MAJOR=23
|
NODE_MAJOR=24
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
apt-get update
|
apt-get update
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
# pre-build stage
|
# pre-build stage
|
||||||
FROM node:23-alpine as node
|
FROM node:24-alpine as node
|
||||||
FROM ruby:3.4.4-alpine3.21 AS pre-builder
|
FROM ruby:3.4.4-alpine3.21 AS pre-builder
|
||||||
|
|
||||||
ARG NODE_VERSION="23.7.0"
|
ARG NODE_VERSION="24.12.0"
|
||||||
ARG PNPM_VERSION="10.2.0"
|
ARG PNPM_VERSION="10.2.0"
|
||||||
ENV NODE_VERSION=${NODE_VERSION}
|
ENV NODE_VERSION=${NODE_VERSION}
|
||||||
ENV PNPM_VERSION=${PNPM_VERSION}
|
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||||
@@ -98,7 +98,7 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
|
|||||||
# final build stage
|
# final build stage
|
||||||
FROM ruby:3.4.4-alpine3.21
|
FROM ruby:3.4.4-alpine3.21
|
||||||
|
|
||||||
ARG NODE_VERSION="23.7.0"
|
ARG NODE_VERSION="24.12.0"
|
||||||
ARG PNPM_VERSION="10.2.0"
|
ARG PNPM_VERSION="10.2.0"
|
||||||
ENV NODE_VERSION=${NODE_VERSION}
|
ENV NODE_VERSION=${NODE_VERSION}
|
||||||
ENV PNPM_VERSION=${PNPM_VERSION}
|
ENV PNPM_VERSION=${PNPM_VERSION}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Captain::ChatGenerationRecorder
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
include Integrations::LlmInstrumentationConstants
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def record_llm_generation(chat, message)
|
||||||
|
return unless valid_llm_message?(message)
|
||||||
|
|
||||||
|
# Create a generation span with model and token info for Langfuse cost calculation.
|
||||||
|
# Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation.
|
||||||
|
tracer.in_span("llm.captain.#{feature_name}.generation") do |span|
|
||||||
|
set_generation_span_attributes(span, chat, message)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "Failed to record LLM generation: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip non-LLM messages (e.g., tool results that RubyLLM processes internally).
|
||||||
|
# Check for assistant role rather than token presence - some providers/streaming modes
|
||||||
|
# may not return token counts, but we still want to capture the generation for evals.
|
||||||
|
def valid_llm_message?(message)
|
||||||
|
message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_generation_span_attributes(span, chat, message)
|
||||||
|
generation_attributes(chat, message).each do |key, value|
|
||||||
|
span.set_attribute(key, value) if value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generation_attributes(chat, message)
|
||||||
|
{
|
||||||
|
ATTR_GEN_AI_PROVIDER => determine_provider(model),
|
||||||
|
ATTR_GEN_AI_REQUEST_MODEL => model,
|
||||||
|
ATTR_GEN_AI_REQUEST_TEMPERATURE => temperature,
|
||||||
|
ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens,
|
||||||
|
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil,
|
||||||
|
ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat),
|
||||||
|
ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_input_messages(chat)
|
||||||
|
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
module Captain::ChatHelper
|
module Captain::ChatHelper
|
||||||
include Integrations::LlmInstrumentation
|
include Integrations::LlmInstrumentation
|
||||||
include Captain::ChatResponseHelper
|
include Captain::ChatResponseHelper
|
||||||
|
include Captain::ChatGenerationRecorder
|
||||||
|
|
||||||
def request_chat_completion
|
def request_chat_completion
|
||||||
log_chat_completion_request
|
log_chat_completion_request
|
||||||
@@ -42,8 +43,11 @@ module Captain::ChatHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def setup_event_handlers(chat)
|
def setup_event_handlers(chat)
|
||||||
chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) }
|
# NOTE: We only use on_end_message to record the generation with token counts.
|
||||||
chat.on_end_message { |message| end_llm_turn_span(message) }
|
# RubyLLM callbacks fire after chunks arrive, not around the API call, so
|
||||||
|
# span timing won't reflect actual API latency. But Langfuse calculates costs
|
||||||
|
# from model + token counts, so this is sufficient for cost tracking.
|
||||||
|
chat.on_end_message { |message| record_llm_generation(chat, message) }
|
||||||
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
|
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
|
||||||
chat.on_tool_result { |result| handle_tool_result(result) }
|
chat.on_tool_result { |result| handle_tool_result(result) }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#
|
#
|
||||||
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
|
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
|
||||||
class Llm::LegacyBaseOpenAiService
|
class Llm::LegacyBaseOpenAiService
|
||||||
DEFAULT_MODEL = 'gpt-4o-mini'
|
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||||
|
|
||||||
attr_reader :client, :model
|
attr_reader :client, :model
|
||||||
|
|
||||||
|
|||||||
@@ -86,12 +86,6 @@ conversations:
|
|||||||
filter_operators:
|
filter_operators:
|
||||||
- "equal_to"
|
- "equal_to"
|
||||||
- "not_equal_to"
|
- "not_equal_to"
|
||||||
country_code:
|
|
||||||
attribute_type: "additional_attributes"
|
|
||||||
data_type: "text"
|
|
||||||
filter_operators:
|
|
||||||
- "equal_to"
|
|
||||||
- "not_equal_to"
|
|
||||||
referer:
|
referer:
|
||||||
attribute_type: "additional_attributes"
|
attribute_type: "additional_attributes"
|
||||||
data_type: "link"
|
data_type: "link"
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ module Integrations::LlmInstrumentation
|
|||||||
result = nil
|
result = nil
|
||||||
executed = false
|
executed = false
|
||||||
tracer.in_span(params[:span_name]) do |span|
|
tracer.in_span(params[:span_name]) do |span|
|
||||||
set_request_attributes(span, params)
|
|
||||||
set_metadata_attributes(span, params)
|
set_metadata_attributes(span, params)
|
||||||
|
|
||||||
# By default, the input and output of a trace are set from the root observation
|
# By default, the input and output of a trace are set from the root observation
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module Llm::Models
|
||||||
|
CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def providers = CONFIG['providers']
|
||||||
|
def models = CONFIG['models']
|
||||||
|
def features = CONFIG['features']
|
||||||
|
def feature_keys = CONFIG['features'].keys
|
||||||
|
|
||||||
|
def default_model_for(feature)
|
||||||
|
CONFIG.dig('features', feature.to_s, 'default')
|
||||||
|
end
|
||||||
|
|
||||||
|
def models_for(feature)
|
||||||
|
CONFIG.dig('features', feature.to_s, 'models') || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_model_for?(feature, model_name)
|
||||||
|
models_for(feature).include?(model_name.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def feature_config(feature_key)
|
||||||
|
feature = features[feature_key.to_s]
|
||||||
|
return nil unless feature
|
||||||
|
|
||||||
|
{
|
||||||
|
models: feature['models'].map do |model_name|
|
||||||
|
model = models[model_name]
|
||||||
|
{
|
||||||
|
id: model_name,
|
||||||
|
display_name: model['display_name'],
|
||||||
|
provider: model['provider'],
|
||||||
|
coming_soon: model['coming_soon'],
|
||||||
|
credit_multiplier: model['credit_multiplier']
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
default: feature['default']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+8
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chatwoot/chatwoot",
|
"name": "@chatwoot/chatwoot",
|
||||||
"version": "4.9.1",
|
"version": "4.9.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"eslint": "eslint app/**/*.{js,vue}",
|
"eslint": "eslint app/**/*.{js,vue}",
|
||||||
@@ -111,13 +111,13 @@
|
|||||||
"wavesurfer.js": "7.8.6"
|
"wavesurfer.js": "7.8.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@egoist/tailwindcss-icons": "^1.8.1",
|
"@egoist/tailwindcss-icons": "^1.9.0",
|
||||||
"@histoire/plugin-vue": "0.17.15",
|
"@histoire/plugin-vue": "0.17.15",
|
||||||
"@iconify-json/logos": "^1.2.3",
|
"@iconify-json/logos": "^1.2.10",
|
||||||
"@iconify-json/lucide": "^1.2.68",
|
"@iconify-json/lucide": "^1.2.82",
|
||||||
"@iconify-json/ph": "^1.2.1",
|
"@iconify-json/ph": "^1.2.2",
|
||||||
"@iconify-json/ri": "^1.2.3",
|
"@iconify-json/ri": "^1.2.6",
|
||||||
"@iconify-json/teenyicons": "^1.2.1",
|
"@iconify-json/teenyicons": "^1.2.2",
|
||||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||||
"@size-limit/file": "^8.2.4",
|
"@size-limit/file": "^8.2.4",
|
||||||
"@vitest/coverage-v8": "3.0.5",
|
"@vitest/coverage-v8": "3.0.5",
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
"vitest": "3.0.5"
|
"vitest": "3.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "23.x",
|
"node": "24.x",
|
||||||
"pnpm": "10.x"
|
"pnpm": "10.x"
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
|
|||||||
Generated
+118
-76
@@ -249,26 +249,26 @@ importers:
|
|||||||
version: 7.8.6
|
version: 7.8.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@egoist/tailwindcss-icons':
|
'@egoist/tailwindcss-icons':
|
||||||
specifier: ^1.8.1
|
specifier: ^1.9.0
|
||||||
version: 1.8.1(tailwindcss@3.4.13)
|
version: 1.9.0(tailwindcss@3.4.13)
|
||||||
'@histoire/plugin-vue':
|
'@histoire/plugin-vue':
|
||||||
specifier: 0.17.15
|
specifier: 0.17.15
|
||||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||||
'@iconify-json/logos':
|
'@iconify-json/logos':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.10
|
||||||
version: 1.2.3
|
version: 1.2.10
|
||||||
'@iconify-json/lucide':
|
'@iconify-json/lucide':
|
||||||
specifier: ^1.2.68
|
specifier: ^1.2.82
|
||||||
version: 1.2.68
|
version: 1.2.82
|
||||||
'@iconify-json/ph':
|
'@iconify-json/ph':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.2
|
||||||
version: 1.2.1
|
version: 1.2.2
|
||||||
'@iconify-json/ri':
|
'@iconify-json/ri':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.6
|
||||||
version: 1.2.3
|
version: 1.2.6
|
||||||
'@iconify-json/teenyicons':
|
'@iconify-json/teenyicons':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.2
|
||||||
version: 1.2.1
|
version: 1.2.2
|
||||||
'@intlify/eslint-plugin-vue-i18n':
|
'@intlify/eslint-plugin-vue-i18n':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0(eslint@8.57.0)
|
version: 3.2.0(eslint@8.57.0)
|
||||||
@@ -399,11 +399,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@antfu/install-pkg@0.4.1':
|
'@antfu/install-pkg@1.1.0':
|
||||||
resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
|
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||||
|
|
||||||
'@antfu/utils@0.7.10':
|
'@antfu/utils@8.1.1':
|
||||||
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
|
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.0':
|
'@asamuzakjp/css-color@4.1.0':
|
||||||
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==}
|
||||||
@@ -700,8 +700,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss-selector-parser: ^6.0.10
|
postcss-selector-parser: ^6.0.10
|
||||||
|
|
||||||
'@egoist/tailwindcss-icons@1.8.1':
|
'@egoist/tailwindcss-icons@1.9.0':
|
||||||
resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==}
|
resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
tailwindcss: '*'
|
tailwindcss: '*'
|
||||||
|
|
||||||
@@ -961,29 +961,29 @@ packages:
|
|||||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||||
deprecated: Use @eslint/object-schema instead
|
deprecated: Use @eslint/object-schema instead
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.3':
|
'@iconify-json/logos@1.2.10':
|
||||||
resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==}
|
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.68':
|
'@iconify-json/lucide@1.2.82':
|
||||||
resolution: {integrity: sha512-lR5xNJdn2CT0iR7lM25G4SewBO4G2hbr3fTWOc3AE9BspflEcneh02E3l9TBaCU/JOHozTJevWLrxBGypD7Tng==}
|
resolution: {integrity: sha512-fHZWegspOZonl5GNTvOkHsjnTMdSslFh3EzpzUtRyLxO8bOonqk2OTU3hCl0k4VXzViMjqpRK3X1sotnuBXkFA==}
|
||||||
|
|
||||||
'@iconify-json/material-symbols@1.2.10':
|
'@iconify-json/material-symbols@1.2.10':
|
||||||
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
|
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
|
||||||
|
|
||||||
'@iconify-json/ph@1.2.1':
|
'@iconify-json/ph@1.2.2':
|
||||||
resolution: {integrity: sha512-x0DNfwWrS18dbsBYOq3XGiZnGz4CgRyC+YSl/TZvMQiKhIUl1woWqUbMYqqfMNUBzjyk7ulvaRovpRsIlqIf8g==}
|
resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==}
|
||||||
|
|
||||||
'@iconify-json/ri@1.2.3':
|
'@iconify-json/ri@1.2.6':
|
||||||
resolution: {integrity: sha512-UVKofd5xkSevGd5K01pvO4NWsu+2C9spu+GxnMZUYymUiaWmpCAxtd22MFSpm6MGf0MP4GCwhDCo1Q8L8oZ9wg==}
|
resolution: {integrity: sha512-tGXRmXtb8oFu8DNg9MsS1pywKFgs9QZ4U6LBzUamBHaw3ePSiPd7ouE64gzHzfEcR16hgVaXoUa+XxD3BB0XOg==}
|
||||||
|
|
||||||
'@iconify-json/teenyicons@1.2.1':
|
'@iconify-json/teenyicons@1.2.2':
|
||||||
resolution: {integrity: sha512-PaVv+zrQEO6I/9YfEwxkJfYSrCIWyOoSv/ZOVgETsr0MOqN9k7ecnHF/lPrgpyCLkwLzPX7MyFm3/gmziwjSiw==}
|
resolution: {integrity: sha512-Do08DrvNpT+pKVeyFqn7nZiIviAAY8KbduSfpNKzE1bgVekAIJ/AAJtOBSUFpV4vTk+hXw195+jmCv8/0cJSKA==}
|
||||||
|
|
||||||
'@iconify/types@2.0.0':
|
'@iconify/types@2.0.0':
|
||||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||||
|
|
||||||
'@iconify/utils@2.1.32':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-LeifFZPPKu28O3AEDpYJNdEbvS4/ojAPyIW+pF/vUpJTYnbTiXUHkCh0bwgFRzKvdpb8H4Fbfd/742++MF4fPQ==}
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
'@intlify/core-base@9.14.2':
|
'@intlify/core-base@9.14.2':
|
||||||
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
||||||
@@ -1916,8 +1916,11 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
confbox@0.1.7:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
|
confbox@0.2.2:
|
||||||
|
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
|
||||||
|
|
||||||
config-chain@1.1.13:
|
config-chain@1.1.13:
|
||||||
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
|
||||||
@@ -2444,6 +2447,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
exsolve@1.0.8:
|
||||||
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2636,6 +2642,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
globals@15.15.0:
|
||||||
|
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
globalthis@1.0.3:
|
globalthis@1.0.3:
|
||||||
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3118,8 +3128,8 @@ packages:
|
|||||||
lit@2.2.6:
|
lit@2.2.6:
|
||||||
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
resolution: {integrity: sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==}
|
||||||
|
|
||||||
local-pkg@0.5.0:
|
local-pkg@1.1.2:
|
||||||
resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
|
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
locate-path@5.0.0:
|
locate-path@5.0.0:
|
||||||
@@ -3298,8 +3308,8 @@ packages:
|
|||||||
mitt@3.0.1:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
mlly@1.7.1:
|
mlly@1.8.0:
|
||||||
resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==}
|
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||||
|
|
||||||
mpd-parser@0.21.0:
|
mpd-parser@0.21.0:
|
||||||
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
resolution: {integrity: sha512-NbpMJ57qQzFmfCiP1pbL7cGMbVTD0X1hqNgL0VYP1wLlZXLf/HtmvQpNkOA1AHkPVeGQng+7/jEtSvNUzV7Gdg==}
|
||||||
@@ -3481,8 +3491,8 @@ packages:
|
|||||||
package-json-from-dist@1.0.0:
|
package-json-from-dist@1.0.0:
|
||||||
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
|
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
|
||||||
|
|
||||||
package-manager-detector@0.2.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
||||||
@@ -3540,6 +3550,9 @@ packages:
|
|||||||
pathe@2.0.2:
|
pathe@2.0.2:
|
||||||
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
|
resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
pathval@2.0.0:
|
pathval@2.0.0:
|
||||||
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
||||||
engines: {node: '>= 14.16'}
|
engines: {node: '>= 14.16'}
|
||||||
@@ -3590,8 +3603,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
|
resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
pkg-types@1.2.0:
|
pkg-types@1.3.1:
|
||||||
resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
|
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
pngjs@5.0.0:
|
pngjs@5.0.0:
|
||||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
@@ -3899,6 +3915,9 @@ packages:
|
|||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
quansync@0.2.11:
|
||||||
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
|
|
||||||
querystringify@2.2.0:
|
querystringify@2.2.0:
|
||||||
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
|
||||||
|
|
||||||
@@ -4290,6 +4309,10 @@ packages:
|
|||||||
tinyexec@0.3.2:
|
tinyexec@0.3.2:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
|
tinyexec@1.0.2:
|
||||||
|
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinykeys@3.0.0:
|
tinykeys@3.0.0:
|
||||||
resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
|
resolution: {integrity: sha512-nazawuGv5zx6MuDfDY0rmfXjuOGhD5XU2z0GLURQ1nzl0RUe9OuCJq+0u8xxJZINHe+mr7nw8PWYYZ9WhMFujw==}
|
||||||
|
|
||||||
@@ -4409,8 +4432,8 @@ packages:
|
|||||||
uc.micro@2.1.0:
|
uc.micro@2.1.0:
|
||||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
ufo@1.5.4:
|
ufo@1.6.1:
|
||||||
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
|
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||||
|
|
||||||
unbox-primitive@1.0.2:
|
unbox-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||||
@@ -4910,12 +4933,12 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.8
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
||||||
'@antfu/install-pkg@0.4.1':
|
'@antfu/install-pkg@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
package-manager-detector: 0.2.0
|
package-manager-detector: 1.6.0
|
||||||
tinyexec: 0.3.2
|
tinyexec: 1.0.2
|
||||||
|
|
||||||
'@antfu/utils@0.7.10': {}
|
'@antfu/utils@8.1.1': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.0':
|
'@asamuzakjp/css-color@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5233,9 +5256,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss-selector-parser: 6.1.1
|
postcss-selector-parser: 6.1.1
|
||||||
|
|
||||||
'@egoist/tailwindcss-icons@1.8.1(tailwindcss@3.4.13)':
|
'@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.13)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/utils': 2.1.32
|
'@iconify/utils': 2.3.0
|
||||||
tailwindcss: 3.4.13
|
tailwindcss: 3.4.13
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -5490,11 +5513,11 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/object-schema@2.0.3': {}
|
'@humanwhocodes/object-schema@2.0.3': {}
|
||||||
|
|
||||||
'@iconify-json/logos@1.2.3':
|
'@iconify-json/logos@1.2.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.68':
|
'@iconify-json/lucide@1.2.82':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
@@ -5502,29 +5525,30 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/ph@1.2.1':
|
'@iconify-json/ph@1.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/ri@1.2.3':
|
'@iconify-json/ri@1.2.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/teenyicons@1.2.1':
|
'@iconify-json/teenyicons@1.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify/types@2.0.0': {}
|
'@iconify/types@2.0.0': {}
|
||||||
|
|
||||||
'@iconify/utils@2.1.32':
|
'@iconify/utils@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/install-pkg': 0.4.1
|
'@antfu/install-pkg': 1.1.0
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 8.1.1
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
debug: 4.4.0
|
debug: 4.4.3
|
||||||
|
globals: 15.15.0
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
local-pkg: 0.5.0
|
local-pkg: 1.1.2
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -6196,8 +6220,7 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.14.0: {}
|
acorn@8.14.0: {}
|
||||||
|
|
||||||
acorn@8.15.0:
|
acorn@8.15.0: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
activestorage@5.2.8:
|
activestorage@5.2.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6598,7 +6621,9 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
confbox@0.1.7: {}
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
|
confbox@0.2.2: {}
|
||||||
|
|
||||||
config-chain@1.1.13:
|
config-chain@1.1.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7235,6 +7260,8 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.1.0: {}
|
expect-type@1.1.0: {}
|
||||||
|
|
||||||
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-extendable: 0.1.1
|
is-extendable: 0.1.1
|
||||||
@@ -7450,6 +7477,8 @@ snapshots:
|
|||||||
|
|
||||||
globals@15.14.0: {}
|
globals@15.14.0: {}
|
||||||
|
|
||||||
|
globals@15.15.0: {}
|
||||||
|
|
||||||
globalthis@1.0.3:
|
globalthis@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-properties: 1.2.0
|
define-properties: 1.2.0
|
||||||
@@ -8020,10 +8049,11 @@ snapshots:
|
|||||||
lit-element: 3.3.3
|
lit-element: 3.3.3
|
||||||
lit-html: 2.8.0
|
lit-html: 2.8.0
|
||||||
|
|
||||||
local-pkg@0.5.0:
|
local-pkg@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
pkg-types: 1.2.0
|
pkg-types: 2.3.0
|
||||||
|
quansync: 0.2.11
|
||||||
|
|
||||||
locate-path@5.0.0:
|
locate-path@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8197,12 +8227,12 @@ snapshots:
|
|||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
mlly@1.7.1:
|
mlly@1.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.12.1
|
acorn: 8.15.0
|
||||||
pathe: 1.1.2
|
pathe: 2.0.3
|
||||||
pkg-types: 1.2.0
|
pkg-types: 1.3.1
|
||||||
ufo: 1.5.4
|
ufo: 1.6.1
|
||||||
|
|
||||||
mpd-parser@0.21.0:
|
mpd-parser@0.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8383,7 +8413,7 @@ snapshots:
|
|||||||
|
|
||||||
package-json-from-dist@1.0.0: {}
|
package-json-from-dist@1.0.0: {}
|
||||||
|
|
||||||
package-manager-detector@0.2.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8435,6 +8465,8 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.2: {}
|
pathe@2.0.2: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pathval@2.0.0: {}
|
pathval@2.0.0: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
@@ -8468,11 +8500,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
find-up: 6.3.0
|
find-up: 6.3.0
|
||||||
|
|
||||||
pkg-types@1.2.0:
|
pkg-types@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.1.7
|
confbox: 0.1.8
|
||||||
mlly: 1.7.1
|
mlly: 1.8.0
|
||||||
pathe: 1.1.2
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
pkg-types@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
confbox: 0.2.2
|
||||||
|
exsolve: 1.0.8
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
pngjs@5.0.0: {}
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
@@ -8843,6 +8881,8 @@ snapshots:
|
|||||||
pngjs: 5.0.0
|
pngjs: 5.0.0
|
||||||
yargs: 15.4.1
|
yargs: 15.4.1
|
||||||
|
|
||||||
|
quansync@0.2.11: {}
|
||||||
|
|
||||||
querystringify@2.2.0: {}
|
querystringify@2.2.0: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@@ -9307,6 +9347,8 @@ snapshots:
|
|||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
|
tinyexec@1.0.2: {}
|
||||||
|
|
||||||
tinykeys@3.0.0: {}
|
tinykeys@3.0.0: {}
|
||||||
|
|
||||||
tinypool@1.0.2: {}
|
tinypool@1.0.2: {}
|
||||||
@@ -9437,7 +9479,7 @@ snapshots:
|
|||||||
|
|
||||||
uc.micro@2.1.0: {}
|
uc.micro@2.1.0: {}
|
||||||
|
|
||||||
ufo@1.5.4: {}
|
ufo@1.6.1: {}
|
||||||
|
|
||||||
unbox-primitive@1.0.2:
|
unbox-primitive@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe V2::Reports::ChannelSummaryBuilder do
|
||||||
|
let!(:account) { create(:account) }
|
||||||
|
let!(:web_widget_inbox) { create(:inbox, account: account) }
|
||||||
|
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
since: 1.week.ago.beginning_of_day,
|
||||||
|
until: Time.current.end_of_day
|
||||||
|
}
|
||||||
|
end
|
||||||
|
let(:builder) { described_class.new(account: account, params: params) }
|
||||||
|
|
||||||
|
describe '#build' do
|
||||||
|
subject(:report) { builder.build }
|
||||||
|
|
||||||
|
context 'when there are conversations with different statuses across channels' do
|
||||||
|
before do
|
||||||
|
# Web widget conversations
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 3.days.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :pending, created_at: 1.day.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :snoozed, created_at: 1.day.ago)
|
||||||
|
|
||||||
|
# Email conversations
|
||||||
|
create(:conversation, account: account, inbox: email_inbox, status: :open, created_at: 2.days.ago)
|
||||||
|
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 1.day.ago)
|
||||||
|
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 3.days.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns correct counts grouped by channel type' do
|
||||||
|
expect(report['Channel::WebWidget']).to eq(
|
||||||
|
open: 2,
|
||||||
|
resolved: 1,
|
||||||
|
pending: 1,
|
||||||
|
snoozed: 1,
|
||||||
|
total: 5
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(report['Channel::Email']).to eq(
|
||||||
|
open: 1,
|
||||||
|
resolved: 2,
|
||||||
|
pending: 0,
|
||||||
|
snoozed: 0,
|
||||||
|
total: 3
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when conversations are outside the date range' do
|
||||||
|
before do
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.weeks.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'only includes conversations within the date range' do
|
||||||
|
expect(report['Channel::WebWidget']).to eq(
|
||||||
|
open: 1,
|
||||||
|
resolved: 0,
|
||||||
|
pending: 0,
|
||||||
|
snoozed: 0,
|
||||||
|
total: 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when there are no conversations' do
|
||||||
|
it 'returns an empty hash' do
|
||||||
|
expect(report).to eq({})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when a channel has only one status type' do
|
||||||
|
before do
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 1.day.ago)
|
||||||
|
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns zeros for other statuses' do
|
||||||
|
expect(report['Channel::WebWidget']).to eq(
|
||||||
|
open: 0,
|
||||||
|
resolved: 2,
|
||||||
|
pending: 0,
|
||||||
|
snoozed: 0,
|
||||||
|
total: 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,19 +10,14 @@ RSpec.describe 'API Base', type: :request do
|
|||||||
let!(:conversation) { create(:conversation, account: account) }
|
let!(:conversation) { create(:conversation, account: account) }
|
||||||
|
|
||||||
it 'sets Current attributes for the request and then returns the response' do
|
it 'sets Current attributes for the request and then returns the response' do
|
||||||
# expect Current.account_user is set to the admin's account_user
|
# This test verifies that Current.user, Current.account, and Current.account_user
|
||||||
allow(Current).to receive(:user=).and_call_original
|
# are properly set during request processing. We verify this indirectly:
|
||||||
allow(Current).to receive(:account=).and_call_original
|
# - A successful response proves Current.account_user was set (required for authorization)
|
||||||
allow(Current).to receive(:account_user=).and_call_original
|
# - The correct conversation data proves Current.account was set (scopes the query)
|
||||||
|
|
||||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||||
headers: { api_access_token: admin.access_token.token },
|
headers: { api_access_token: admin.access_token.token },
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(Current).to have_received(:user=).with(admin).at_least(:once)
|
|
||||||
expect(Current).to have_received(:account=).with(account).at_least(:once)
|
|
||||||
expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
|
def json_response
|
||||||
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/{account.id}/captain/preferences' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns captain config' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'returns captain config' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /api/v1/accounts/{account.id}/captain/preferences' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
params: { captain_models: { editor: 'gpt-4.1-mini' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns forbidden' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { captain_models: { editor: 'gpt-4.1-mini' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'updates captain_models' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { captain_models: { editor: 'gpt-4.1-mini' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
expect(account.reload.captain_models['editor']).to eq('gpt-4.1-mini')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates captain_features' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { captain_features: { editor: true } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
expect(account.reload.captain_features['editor']).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'merges with existing captain_models' do
|
||||||
|
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.1' })
|
||||||
|
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { captain_models: { editor: 'gpt-4.1' } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
models = account.reload.captain_models
|
||||||
|
expect(models['editor']).to eq('gpt-4.1')
|
||||||
|
expect(models['assistant']).to eq('gpt-5.1') # Preserved
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'merges with existing captain_features' do
|
||||||
|
account.update!(captain_features: { 'editor' => true, 'assistant' => false })
|
||||||
|
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { captain_features: { editor: false } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
features = account.reload.captain_features
|
||||||
|
expect(features['editor']).to be false
|
||||||
|
expect(features['assistant']).to be false # Preserved
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates both models and features in single request' do
|
||||||
|
put "/api/v1/accounts/#{account.id}/captain/preferences",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: {
|
||||||
|
captain_models: { editor: 'gpt-4.1-mini' },
|
||||||
|
captain_features: { editor: true }
|
||||||
|
},
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response).to have_key(:providers)
|
||||||
|
expect(json_response).to have_key(:models)
|
||||||
|
expect(json_response).to have_key(:features)
|
||||||
|
account.reload
|
||||||
|
expect(account.captain_models['editor']).to eq('gpt-4.1-mini')
|
||||||
|
expect(account.captain_features['editor']).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,11 +9,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
end
|
end
|
||||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||||
let(:web_widget_inbox) { create(:inbox, account: account) }
|
let(:web_widget_inbox) { create(:inbox, account: account) }
|
||||||
let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
|
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
||||||
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service)
|
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
||||||
@@ -32,7 +32,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
|
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
|
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp and Twilio WhatsApp channels')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -195,11 +195,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'creates template successfully' do
|
it 'creates template successfully' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
template_id: '987654321'
|
template_id: '987654321'
|
||||||
})
|
})
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -222,7 +222,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
}
|
}
|
||||||
|
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
expect(mock_service).to receive(:create_csat_template) do |config|
|
expect(mock_service).to receive(:create_template) do |config|
|
||||||
expect(config[:button_text]).to eq('Please rate us')
|
expect(config[:button_text]).to eq('Please rate us')
|
||||||
expect(config[:language]).to eq('en')
|
expect(config[:language]).to eq('en')
|
||||||
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
|
||||||
@@ -249,11 +249,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
}
|
}
|
||||||
|
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Template creation failed',
|
error: 'Template creation failed',
|
||||||
response_body: whatsapp_error_response.to_json
|
response_body: whatsapp_error_response.to_json
|
||||||
})
|
})
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -272,11 +272,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'handles generic API errors' do
|
it 'handles generic API errors' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Network timeout',
|
error: 'Network timeout',
|
||||||
response_body: nil
|
response_body: nil
|
||||||
})
|
})
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -289,7 +289,7 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'handles unexpected service errors' do
|
it 'handles unexpected service errors' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template)
|
allow(mock_service).to receive(:create_template)
|
||||||
.and_raise(StandardError, 'Unexpected error')
|
.and_raise(StandardError, 'Unexpected error')
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
@@ -312,10 +312,10 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
allow(mock_service).to receive(:get_template_status)
|
allow(mock_service).to receive(:get_template_status)
|
||||||
.with('existing_template')
|
.with('existing_template')
|
||||||
.and_return({ success: true, template: { id: '111111111' } })
|
.and_return({ success: true, template: { id: '111111111' } })
|
||||||
expect(mock_service).to receive(:delete_csat_template)
|
expect(mock_service).to receive(:delete_template)
|
||||||
.with('existing_template')
|
.with('existing_template')
|
||||||
.and_return({ success: true })
|
.and_return({ success: true })
|
||||||
expect(mock_service).to receive(:create_csat_template)
|
expect(mock_service).to receive(:create_template)
|
||||||
.and_return({
|
.and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
@@ -336,13 +336,13 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
})
|
})
|
||||||
|
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
|
||||||
allow(mock_service).to receive(:delete_csat_template)
|
allow(mock_service).to receive(:delete_template)
|
||||||
.and_return({ success: false, response_body: 'Delete failed' })
|
.and_return({ success: false, response_body: 'Delete failed' })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
|
||||||
template_id: '333333333'
|
template_id: '333333333'
|
||||||
})
|
})
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -365,11 +365,11 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
|||||||
|
|
||||||
it 'allows access when agent is assigned to inbox' do
|
it 'allows access when agent is assigned to inbox' do
|
||||||
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
|
||||||
allow(mock_service).to receive(:create_csat_template).and_return({
|
allow(mock_service).to receive(:create_template).and_return({
|
||||||
success: true,
|
success: true,
|
||||||
template_name: 'customer_satisfaction_survey',
|
template_name: 'customer_satisfaction_survey',
|
||||||
template_id: '444444444'
|
template_id: '444444444'
|
||||||
})
|
})
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
|
|||||||
@@ -160,4 +160,68 @@ RSpec.describe 'Summary Reports API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v2/accounts/:account_id/summary_reports/channel' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v2/accounts/#{account.id}/summary_reports/channel"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
since: start_of_today.to_s,
|
||||||
|
until: end_of_today.to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unauthorized for agents' do
|
||||||
|
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||||
|
params: params,
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calls V2::Reports::ChannelSummaryBuilder with the right params if the user is an admin' do
|
||||||
|
channel_summary_builder = double
|
||||||
|
allow(V2::Reports::ChannelSummaryBuilder).to receive(:new).and_return(channel_summary_builder)
|
||||||
|
allow(channel_summary_builder).to receive(:build)
|
||||||
|
.and_return({
|
||||||
|
'Channel::WebWidget' => { open: 5, resolved: 10, pending: 2, snoozed: 1, total: 18 }
|
||||||
|
})
|
||||||
|
|
||||||
|
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||||
|
params: params,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(V2::Reports::ChannelSummaryBuilder).to have_received(:new).with(
|
||||||
|
account: account,
|
||||||
|
params: hash_including(since: start_of_today.to_s, until: end_of_today.to_s)
|
||||||
|
)
|
||||||
|
expect(channel_summary_builder).to have_received(:build)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
json_response = response.parsed_body
|
||||||
|
|
||||||
|
expect(json_response['Channel::WebWidget']['open']).to eq(5)
|
||||||
|
expect(json_response['Channel::WebWidget']['total']).to eq(18)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unprocessable_entity when date range exceeds 6 months' do
|
||||||
|
get "/api/v2/accounts/#{account.id}/summary_reports/channel",
|
||||||
|
params: { since: 1.year.ago.to_i.to_s, until: Time.current.to_i.to_s },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq(I18n.t('errors.reports.date_range_too_long'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -252,6 +252,17 @@ RSpec.describe Integrations::LlmInstrumentation do
|
|||||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', params[:messages].to_json)
|
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', params[:messages].to_json)
|
||||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Regression test for Langfuse double-counting bug.
|
||||||
|
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
|
||||||
|
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
|
||||||
|
# (once for the parent, once for each child GENERATION).
|
||||||
|
# See: https://github.com/langfuse/langfuse/issues/7549
|
||||||
|
it 'does NOT set gen_ai.request.model to avoid being classified as a GENERATION' do
|
||||||
|
instance.instrument_agent_session(params) { 'result' }
|
||||||
|
|
||||||
|
expect(mock_span).not_to have_received(:set_attribute).with('gen_ai.request.model', anything)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -117,13 +117,14 @@ describe ActionCableListener do
|
|||||||
describe '#contact_deleted' do
|
describe '#contact_deleted' do
|
||||||
let(:event_name) { :'contact.deleted' }
|
let(:event_name) { :'contact.deleted' }
|
||||||
let!(:contact) { create(:contact, account: account) }
|
let!(:contact) { create(:contact, account: account) }
|
||||||
let!(:event) { Events::Base.new(event_name, Time.zone.now, contact: contact) }
|
let(:contact_data) { contact.push_event_data.merge(account_id: contact.account_id) }
|
||||||
|
let!(:event) { Events::Base.new(event_name, Time.zone.now, contact_data: contact_data) }
|
||||||
|
|
||||||
it 'sends message to account admins, inbox agents' do
|
it 'sends message to account admins, inbox agents' do
|
||||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||||
["account_#{account.id}"],
|
["account_#{account.id}"],
|
||||||
'contact.deleted',
|
'contact.deleted',
|
||||||
contact.push_event_data.merge(account_id: account.id)
|
contact_data
|
||||||
)
|
)
|
||||||
listener.contact_deleted(event)
|
listener.contact_deleted(event)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -218,4 +218,59 @@ RSpec.describe Account do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'captain_preferences' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
|
describe 'with no saved preferences' do
|
||||||
|
it 'returns defaults from llm.yml' do
|
||||||
|
prefs = account.captain_preferences
|
||||||
|
|
||||||
|
expect(prefs[:features].values).to all(be false)
|
||||||
|
|
||||||
|
Llm::Models.feature_keys.each do |feature|
|
||||||
|
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'with saved model preferences' do
|
||||||
|
it 'returns saved preferences merged with defaults' do
|
||||||
|
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
|
||||||
|
|
||||||
|
prefs = account.captain_preferences
|
||||||
|
|
||||||
|
expect(prefs[:models]['editor']).to eq('gpt-4.1-mini')
|
||||||
|
expect(prefs[:models]['assistant']).to eq('gpt-5.2')
|
||||||
|
expect(prefs[:models]['copilot']).to eq(Llm::Models.default_model_for('copilot'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'with saved feature preferences' do
|
||||||
|
it 'returns saved feature states' do
|
||||||
|
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
|
||||||
|
|
||||||
|
prefs = account.captain_preferences
|
||||||
|
|
||||||
|
expect(prefs[:features]['editor']).to be true
|
||||||
|
expect(prefs[:features]['assistant']).to be true
|
||||||
|
expect(prefs[:features]['copilot']).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validation' do
|
||||||
|
it 'rejects invalid model for a feature' do
|
||||||
|
account.captain_models = { 'label_suggestion' => 'gpt-5.1' }
|
||||||
|
|
||||||
|
expect(account).not_to be_valid
|
||||||
|
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts valid model for a feature' do
|
||||||
|
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' }
|
||||||
|
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe CaptainFeaturable do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
|
describe 'dynamic method generation' do
|
||||||
|
it 'generates enabled? methods for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account).to respond_to("captain_#{feature_key}_enabled?")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates model accessor methods for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account).to respond_to("captain_#{feature_key}_model")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'feature enabled methods' do
|
||||||
|
context 'when no features are explicitly enabled' do
|
||||||
|
it 'returns false for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when features are explicitly enabled' do
|
||||||
|
before do
|
||||||
|
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true for enabled features' do
|
||||||
|
expect(account.captain_editor_enabled?).to be true
|
||||||
|
expect(account.captain_assistant_enabled?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for disabled features' do
|
||||||
|
expect(account.captain_copilot_enabled?).to be false
|
||||||
|
expect(account.captain_label_suggestion_enabled?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when captain_features is nil' do
|
||||||
|
before do
|
||||||
|
account.update!(captain_features: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'model accessor methods' do
|
||||||
|
context 'when no models are explicitly configured' do
|
||||||
|
it 'returns default models for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expected_default = Llm::Models.default_model_for(feature_key)
|
||||||
|
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when models are explicitly configured' do
|
||||||
|
before do
|
||||||
|
account.update!(captain_models: {
|
||||||
|
'editor' => 'gpt-4.1-mini',
|
||||||
|
'assistant' => 'gpt-5.1',
|
||||||
|
'label_suggestion' => 'gpt-4.1-nano'
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns configured models for configured features' do
|
||||||
|
expect(account.captain_editor_model).to eq('gpt-4.1-mini')
|
||||||
|
expect(account.captain_assistant_model).to eq('gpt-5.1')
|
||||||
|
expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns default models for unconfigured features' do
|
||||||
|
expect(account.captain_copilot_model).to eq(Llm::Models.default_model_for('copilot'))
|
||||||
|
expect(account.captain_audio_transcription_model).to eq(Llm::Models.default_model_for('audio_transcription'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when configured with invalid model' do
|
||||||
|
before do
|
||||||
|
account.captain_models = { 'editor' => 'invalid-model' }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'falls back to default model' do
|
||||||
|
expect(account.captain_editor_model).to eq(Llm::Models.default_model_for('editor'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when captain_models is nil' do
|
||||||
|
before do
|
||||||
|
account.update!(captain_models: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns default models for all features' do
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expected_default = Llm::Models.default_model_for(feature_key)
|
||||||
|
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'integration with existing captain_preferences' do
|
||||||
|
it 'enabled? methods use the same logic as captain_preferences[:features]' do
|
||||||
|
account.update!(captain_features: { 'editor' => true, 'copilot' => true })
|
||||||
|
prefs = account.captain_preferences
|
||||||
|
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account.send("captain_#{feature_key}_enabled?")).to eq(prefs[:features][feature_key])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'model methods use the same logic as captain_preferences[:models]' do
|
||||||
|
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
|
||||||
|
prefs = account.captain_preferences
|
||||||
|
|
||||||
|
Llm::Models.feature_keys.each do |feature_key|
|
||||||
|
expect(account.send("captain_#{feature_key}_model")).to eq(prefs[:models][feature_key])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -215,5 +215,25 @@ RSpec.describe AutomationRules::ConditionsFilterService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when conditions based on contact country_code' do
|
||||||
|
before do
|
||||||
|
conversation.update(additional_attributes: { country_code: 'US' })
|
||||||
|
conversation.contact.update(additional_attributes: { country_code: 'IN' })
|
||||||
|
rule.conditions = [
|
||||||
|
{ 'values': ['IN'], 'attribute_key': 'country_code', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||||
|
]
|
||||||
|
rule.save
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches against the contact additional_attributes' do
|
||||||
|
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when the contact country_code does not match' do
|
||||||
|
conversation.contact.update(additional_attributes: { country_code: 'GB' })
|
||||||
|
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user