Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805ee8403c | ||
|
|
a0abd167dc |
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
:position: before
|
|
||||||
:position_in_additional_file_patterns: before
|
|
||||||
:position_in_class: before
|
|
||||||
:position_in_factory: before
|
|
||||||
:position_in_fixture: before
|
|
||||||
:position_in_routes: before
|
|
||||||
:position_in_serializer: before
|
|
||||||
:position_in_test: before
|
|
||||||
:classified_sort: true
|
|
||||||
:exclude_controllers: true
|
|
||||||
:exclude_factories: true
|
|
||||||
:exclude_fixtures: true
|
|
||||||
:exclude_helpers: true
|
|
||||||
:exclude_scaffolds: true
|
|
||||||
:exclude_serializers: true
|
|
||||||
:exclude_sti_subclasses: false
|
|
||||||
:exclude_tests: true
|
|
||||||
:force: false
|
|
||||||
:format_markdown: false
|
|
||||||
:format_rdoc: false
|
|
||||||
:format_yard: false
|
|
||||||
:frozen: false
|
|
||||||
:grouped_polymorphic: false
|
|
||||||
:ignore_model_sub_dir: false
|
|
||||||
:ignore_unknown_models: false
|
|
||||||
:include_version: false
|
|
||||||
:show_check_constraints: false
|
|
||||||
:show_complete_foreign_keys: false
|
|
||||||
:show_foreign_keys: true
|
|
||||||
:show_indexes: true
|
|
||||||
:show_indexes_include: false
|
|
||||||
:simple_indexes: false
|
|
||||||
:sort: false
|
|
||||||
:timestamp: false
|
|
||||||
:trace: false
|
|
||||||
:with_comment: true
|
|
||||||
:with_column_comments: true
|
|
||||||
:with_table_comments: true
|
|
||||||
:position_of_column_comment: :with_name
|
|
||||||
:active_admin: false
|
|
||||||
:command:
|
|
||||||
:debug: false
|
|
||||||
:hide_default_column_types: json,jsonb,hstore
|
|
||||||
:hide_limit_column_types: integer,bigint,boolean
|
|
||||||
:timestamp_columns:
|
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
:ignore_columns:
|
|
||||||
:ignore_routes:
|
|
||||||
:models: true
|
|
||||||
:routes: false
|
|
||||||
:skip_on_db_migrate: false
|
|
||||||
:target_action: :do_annotations
|
|
||||||
:wrapper:
|
|
||||||
:wrapper_close:
|
|
||||||
:wrapper_open:
|
|
||||||
:classes_default_to_s: []
|
|
||||||
:additional_file_patterns: []
|
|
||||||
:model_dir:
|
|
||||||
- app/models
|
|
||||||
- enterprise/app/models
|
|
||||||
:require: []
|
|
||||||
:root_dir:
|
|
||||||
- ''
|
|
||||||
@@ -2,8 +2,3 @@
|
|||||||
ignore:
|
ignore:
|
||||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
|
||||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
|
||||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
|
||||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
|
||||||
- CVE-2026-33658
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.12.1
|
4.12.0
|
||||||
|
|||||||
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
||||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
||||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
||||||
# Visiting a conversation should clear any unread inbox notifications for this conversation.
|
|
||||||
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
|
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||||
|
|
||||||
|
|||||||
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_conversation
|
def set_conversation
|
||||||
return unless conversation.nil?
|
@conversation = create_conversation if conversation.nil?
|
||||||
|
|
||||||
@conversation = create_conversation
|
|
||||||
apply_labels if permitted_params[:labels].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply_labels
|
|
||||||
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
|
|
||||||
@conversation.update_labels(valid_labels) if valid_labels.present?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_finder_params
|
def message_finder_params
|
||||||
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
# timestamp parameter is used in create conversation method
|
# timestamp parameter is used in create conversation method
|
||||||
# custom_attributes and labels are applied when a new conversation is created alongside the first message
|
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||||
params.permit(
|
|
||||||
:id, :before, :after, :website_token,
|
|
||||||
contact: [:name, :email],
|
|
||||||
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
|
|
||||||
custom_attributes: {},
|
|
||||||
labels: []
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_message
|
def set_message
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
private
|
private
|
||||||
|
|
||||||
def sign_in_user
|
def sign_in_user
|
||||||
# Capture before skip_confirmation! sets confirmed_at, which would
|
|
||||||
# make oauth_user_needs_password_reset? return false and skip the
|
|
||||||
# password reset for persisted unconfirmed users.
|
|
||||||
needs_password_reset = oauth_user_needs_password_reset?
|
|
||||||
@resource.skip_confirmation! if confirmable_enabled?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
set_random_password_if_oauth_user if needs_password_reset
|
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sign_in_user_on_mobile
|
def sign_in_user_on_mobile
|
||||||
# See comment in sign_in_user for why this is captured before skip_confirmation!
|
|
||||||
needs_password_reset = oauth_user_needs_password_reset?
|
|
||||||
@resource.skip_confirmation! if confirmable_enabled?
|
@resource.skip_confirmation! if confirmable_enabled?
|
||||||
set_random_password_if_oauth_user if needs_password_reset
|
|
||||||
|
|
||||||
# once the resource is found and verified
|
# once the resource is found and verified
|
||||||
# we can just send them to the login page again with the SSO params
|
# we can just send them to the login page again with the SSO params
|
||||||
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||||
|
|
||||||
create_account_for_user
|
create_account_for_user
|
||||||
set_random_password_if_oauth_user
|
|
||||||
token = @resource.send(:set_reset_password_token)
|
token = @resource.send(:set_reset_password_token)
|
||||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||||
@@ -90,15 +81,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
|||||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def oauth_user_needs_password_reset?
|
|
||||||
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_random_password_if_oauth_user
|
|
||||||
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
|
|
||||||
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_devise_mapping
|
def default_devise_mapping
|
||||||
'user'
|
'user'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
|
|
||||||
before_action :set_account
|
|
||||||
before_action :validate_account_permissible
|
|
||||||
before_action :validate_feature_flag
|
|
||||||
before_action :validate_params
|
|
||||||
|
|
||||||
def create
|
|
||||||
results = migrate_email_channels
|
|
||||||
render json: { results: results }, status: :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_account
|
|
||||||
@account = Account.find(params[:account_id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_account_permissible
|
|
||||||
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
|
|
||||||
|
|
||||||
render json: { error: 'Non permissible resource' }, status: :unauthorized
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_feature_flag
|
|
||||||
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
|
|
||||||
|
|
||||||
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_params
|
|
||||||
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
|
|
||||||
|
|
||||||
return unless migration_params.size > MAX_MIGRATIONS
|
|
||||||
|
|
||||||
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
|
|
||||||
status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
|
|
||||||
def migrate_email_channels
|
|
||||||
migration_params.map { |entry| migrate_single(entry) }
|
|
||||||
end
|
|
||||||
|
|
||||||
MAX_MIGRATIONS = 25
|
|
||||||
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
|
|
||||||
|
|
||||||
def migrate_single(entry)
|
|
||||||
validate_provider!(entry[:provider])
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
channel = create_channel(entry)
|
|
||||||
inbox = create_inbox(channel, entry)
|
|
||||||
|
|
||||||
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
{ email: entry[:email], status: 'error', message: e.message }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_channel(entry)
|
|
||||||
Channel::Email.create!(
|
|
||||||
account_id: @account.id,
|
|
||||||
email: entry[:email],
|
|
||||||
provider: entry[:provider],
|
|
||||||
provider_config: entry[:provider_config]&.to_h,
|
|
||||||
imap_enabled: entry.fetch(:imap_enabled, true),
|
|
||||||
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
|
|
||||||
imap_port: entry[:imap_port] || 993,
|
|
||||||
imap_login: entry[:imap_login] || entry[:email],
|
|
||||||
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_inbox(channel, entry)
|
|
||||||
@account.inboxes.create!(
|
|
||||||
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
|
|
||||||
channel: channel
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_provider!(provider)
|
|
||||||
return if SUPPORTED_PROVIDERS.include?(provider)
|
|
||||||
|
|
||||||
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_imap_address(provider)
|
|
||||||
case provider
|
|
||||||
when 'google' then 'imap.gmail.com'
|
|
||||||
when 'microsoft' then 'outlook.office365.com'
|
|
||||||
else ''
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def migration_params
|
|
||||||
params.permit(migrations: [
|
|
||||||
:email, :provider, :inbox_name,
|
|
||||||
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
|
|
||||||
{ provider_config: {} }
|
|
||||||
])[:migrations]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
|
||||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||||
before_action :set_article, only: [:show]
|
before_action :set_article, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
|
||||||
before_action :set_category, only: [:show]
|
before_action :set_category, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||||
before_action :ensure_custom_domain_request, only: [:show]
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
|
||||||
before_action :portal
|
before_action :portal
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -25,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
def redirect_to_portal_with_locale
|
def redirect_to_portal_with_locale
|
||||||
return if params[:locale].present?
|
return if params[:locale].present?
|
||||||
|
|
||||||
portal
|
|
||||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
|
|||||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||||
}, status: :unauthorized and return
|
}, status: :unauthorized and return
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_portal_feature_enabled
|
|
||||||
return unless ChatwootApp.chatwoot_cloud?
|
|
||||||
return if @portal.account.feature_enabled?('help_center')
|
|
||||||
|
|
||||||
render 'public/api/v1/portals/not_active', status: :payment_required
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -98,9 +98,7 @@ export default {
|
|||||||
mql.onchange = e => setColorTheme(e.matches);
|
mql.onchange = e => setColorTheme(e.matches);
|
||||||
},
|
},
|
||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
if (locale) {
|
this.$root.$i18n.locale = locale;
|
||||||
this.$root.$i18n.locale = locale;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async initializeAccount() {
|
async initializeAccount() {
|
||||||
await this.$store.dispatch('accounts/get');
|
await this.$store.dispatch('accounts/get');
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return axios.delete(`${this.url}/${id}`);
|
return axios.delete(`${this.url}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
test(data = {}) {
|
|
||||||
return axios.post(`${this.url}/test`, {
|
|
||||||
custom_tool: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainCustomTools();
|
export default new CaptainCustomTools();
|
||||||
|
|||||||
@@ -106,10 +106,6 @@ select {
|
|||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply field-disabled;
|
@apply field-disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
option:not(:disabled) {
|
|
||||||
@apply bg-n-solid-2 text-n-slate-12;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textarea
|
// Textarea
|
||||||
|
|||||||
+195
-51
@@ -1,63 +1,207 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
defineProps({
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
priority: {
|
priority: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
showEmpty: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
|
||||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
|
||||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
|
||||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityLabels = {
|
|
||||||
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
|
|
||||||
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
|
|
||||||
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
|
|
||||||
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconName = computed(() => {
|
|
||||||
if (props.priority && icons[props.priority]) {
|
|
||||||
return icons[props.priority];
|
|
||||||
}
|
|
||||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipContent = computed(() => {
|
|
||||||
if (props.priority && priorityLabels[props.priority]) {
|
|
||||||
return t(priorityLabels[props.priority]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.showEmpty) {
|
|
||||||
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||||
<template>
|
<template>
|
||||||
<Icon
|
<div class="inline-flex items-center justify-center rounded-md">
|
||||||
v-tooltip.top="{
|
<!-- Low Priority -->
|
||||||
content: tooltipContent,
|
<svg
|
||||||
delay: { show: 500, hide: 0 },
|
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||||
}"
|
width="20"
|
||||||
:icon="iconName"
|
height="20"
|
||||||
class="size-4 text-n-slate-5"
|
viewBox="0 0 20 20"
|
||||||
/>
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Medium Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-slate-6"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- High Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-amber-9"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Urgent Priority -->
|
||||||
|
<svg
|
||||||
|
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0_2030_12879"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<rect width="20" height="20" fill="#D9D9D9" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_2030_12879)">
|
||||||
|
<rect
|
||||||
|
x="3.33301"
|
||||||
|
y="10"
|
||||||
|
width="3.33333"
|
||||||
|
height="6.66667"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="8.33301"
|
||||||
|
y="6.6665"
|
||||||
|
width="3.33333"
|
||||||
|
height="10"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="13.333"
|
||||||
|
y="3.3335"
|
||||||
|
width="3.33333"
|
||||||
|
height="13.3333"
|
||||||
|
rx="1.66667"
|
||||||
|
class="fill-n-ruby-9"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -35,34 +34,14 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
isSelected: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
selectable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showSelectionControl: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
showMenu: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action', 'select', 'hover']);
|
const emit = defineEmits(['action']);
|
||||||
const { checkPermissions } = usePolicy();
|
const { checkPermissions } = usePolicy();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
const modelValue = computed({
|
|
||||||
get: () => props.isSelected,
|
|
||||||
set: () => emit('select', props.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout
|
<CardLayout>
|
||||||
:selectable="selectable"
|
|
||||||
class="relative"
|
|
||||||
@mouseenter="emit('hover', true)"
|
|
||||||
@mouseleave="emit('hover', false)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-show="showSelectionControl"
|
|
||||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
|
||||||
>
|
|
||||||
<Checkbox v-model="modelValue" />
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-1 justify-between w-full">
|
<div class="flex gap-1 justify-between w-full">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
class="flex relative items-center group"
|
class="flex relative items-center group"
|
||||||
|
|||||||
+5
-11
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const bulkDeleteDialogRef = ref(null);
|
const bulkDeleteDialogRef = ref(null);
|
||||||
const i18nKey = computed(() => {
|
const i18nKey = computed(() => props.type.toUpperCase());
|
||||||
const i18nTypeMap = {
|
|
||||||
AssistantResponse: 'RESPONSES',
|
|
||||||
AssistantDocument: 'DOCUMENTS',
|
|
||||||
};
|
|
||||||
return i18nTypeMap[props.type];
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleBulkDelete = async ids => {
|
const handleBulkDelete = async ids => {
|
||||||
if (!ids) return;
|
if (!ids) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
await store.dispatch(
|
||||||
ids: Array.from(props.bulkIds),
|
'captainBulkActions/handleBulkDelete',
|
||||||
type: props.type,
|
Array.from(props.bulkIds)
|
||||||
});
|
);
|
||||||
|
|
||||||
emit('deleteSuccess');
|
emit('deleteSuccess');
|
||||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||||
|
|||||||
+6
-3
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
|
|||||||
</Policy>
|
</Policy>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
<div class="flex items-center justify-between w-full gap-4">
|
||||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
<div class="flex items-center gap-3 flex-1">
|
||||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
<span
|
||||||
|
v-if="description"
|
||||||
|
class="text-sm truncate text-n-slate-11 flex-1"
|
||||||
|
>
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
+6
-80
@@ -1,10 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, maxLength } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
@@ -73,12 +72,8 @@ const DEFAULT_PARAM = {
|
|||||||
required: false,
|
required: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
|
||||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
|
||||||
const MAX_TOOL_NAME_LENGTH = 55;
|
|
||||||
|
|
||||||
const validationRules = {
|
const validationRules = {
|
||||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
title: { required },
|
||||||
endpoint_url: { required },
|
endpoint_url: { required },
|
||||||
http_method: { required },
|
http_method: { required },
|
||||||
auth_type: { required },
|
auth_type: { required },
|
||||||
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
const getErrorMessage = (field, errorKey) => {
|
||||||
if (!v$.value[field].$error) return '';
|
return v$.value[field].$error
|
||||||
|
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
: '';
|
||||||
if (failedRule === 'maxLength') {
|
|
||||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
|
||||||
max: MAX_TOOL_NAME_LENGTH,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formErrors = computed(() => ({
|
const formErrors = computed(() => ({
|
||||||
@@ -151,30 +140,6 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
emit('submit', state);
|
emit('submit', state);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTesting = ref(false);
|
|
||||||
const testResult = ref(null);
|
|
||||||
const isTestDisabled = computed(
|
|
||||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTest = async () => {
|
|
||||||
if (!state.endpoint_url) return;
|
|
||||||
|
|
||||||
isTesting.value = true;
|
|
||||||
testResult.value = null;
|
|
||||||
try {
|
|
||||||
const { data } = await CustomToolsAPI.test(state);
|
|
||||||
const isOk = data.status >= 200 && data.status < 300;
|
|
||||||
testResult.value = { success: isOk, status: data.status };
|
|
||||||
} catch (e) {
|
|
||||||
const message =
|
|
||||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
|
||||||
testResult.value = { success: false, message };
|
|
||||||
} finally {
|
|
||||||
isTesting.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -283,45 +248,6 @@ const handleTest = async () => {
|
|||||||
class="[&_textarea]:font-mono"
|
class="[&_textarea]:font-mono"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="faded"
|
|
||||||
color="slate"
|
|
||||||
icon="i-lucide-play"
|
|
||||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
|
||||||
:is-loading="isTesting"
|
|
||||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
|
||||||
@click="handleTest"
|
|
||||||
/>
|
|
||||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
|
||||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-if="testResult"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
|
||||||
:class="
|
|
||||||
testResult.success
|
|
||||||
? 'bg-n-teal-2 text-n-teal-11'
|
|
||||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
|
||||||
"
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
testResult.status
|
|
||||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
|
||||||
status: testResult.status,
|
|
||||||
})
|
|
||||||
: testResult.message
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-between items-center w-full">
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
-12
@@ -1,11 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
const { isOnChatwootCloud } = useAccount();
|
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@@ -13,15 +10,6 @@ const onClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FeatureSpotlight
|
|
||||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
|
||||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
|
||||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
|
||||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
|
||||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
|
||||||
class="mb-8"
|
|
||||||
:hide-actions="!isOnChatwootCloud"
|
|
||||||
/>
|
|
||||||
<EmptyStateLayout
|
<EmptyStateLayout
|
||||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||||
|
|||||||
@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasCustomTools = computed(() => {
|
|
||||||
return (
|
|
||||||
isFeatureEnabledonAccount.value(
|
|
||||||
accountId.value,
|
|
||||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
|
||||||
) ||
|
|
||||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleShortcutModalFn = show => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
emit('openKeyShortcutModal');
|
emit('openKeyShortcutModal');
|
||||||
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
|
|||||||
navigationPath: 'captain_assistants_inboxes_index',
|
navigationPath: 'captain_assistants_inboxes_index',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
...(hasCustomTools.value
|
{
|
||||||
? [
|
name: 'Tools',
|
||||||
{
|
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||||
name: 'Tools',
|
activeOn: ['captain_tools_index'],
|
||||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
to: accountScopedRoute('captain_assistants_index', {
|
||||||
activeOn: ['captain_tools_index'],
|
navigationPath: 'captain_tools_index',
|
||||||
to: accountScopedRoute('captain_assistants_index', {
|
}),
|
||||||
navigationPath: 'captain_tools_index',
|
},
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue';
|
|||||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
import PriorityMark from './PriorityMark.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||||
@@ -305,7 +305,7 @@ const deleteConversation = () => {
|
|||||||
>
|
>
|
||||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||||
<div
|
<div
|
||||||
class="flex items-baseline gap-2 flex-shrink-0"
|
class="flex items-center gap-2 flex-shrink-0"
|
||||||
:class="{
|
:class="{
|
||||||
'flex-1 justify-between': !showInboxName,
|
'flex-1 justify-between': !showInboxName,
|
||||||
}"
|
}"
|
||||||
@@ -317,10 +317,7 @@ const deleteConversation = () => {
|
|||||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||||
{{ assignee.name }}
|
{{ assignee.name }}
|
||||||
</span>
|
</span>
|
||||||
<CardPriorityIcon
|
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||||
:priority="chat.priority"
|
|
||||||
class="flex-shrink-0 !size-3.5"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4
|
<h4
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PriorityMark',
|
||||||
|
props: {
|
||||||
|
priority: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
validate: value =>
|
||||||
|
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
CONVERSATION_PRIORITY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
tooltipText() {
|
||||||
|
return this.$t(
|
||||||
|
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isUrgent() {
|
||||||
|
return this.priority === CONVERSATION_PRIORITY.URGENT;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="priority"
|
||||||
|
v-tooltip="{
|
||||||
|
content: tooltipText,
|
||||||
|
delay: { show: 1500, hide: 0 },
|
||||||
|
}"
|
||||||
|
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||||
|
:class="{
|
||||||
|
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
|
||||||
|
'bg-n-slate-4 text-n-slate-11': !isUrgent,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<fluent-icon
|
||||||
|
:icon="`priority-${priority.toLowerCase()}`"
|
||||||
|
:size="isUrgent ? 12 : 14"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
view-box="0 0 14 14"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -253,9 +253,6 @@ export default {
|
|||||||
if (this.isAnInstagramChannel) {
|
if (this.isAnInstagramChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||||
}
|
}
|
||||||
if (this.isATelegramChannel) {
|
|
||||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
|
||||||
}
|
|
||||||
if (this.isATiktokChannel) {
|
if (this.isATiktokChannel) {
|
||||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||||
}
|
}
|
||||||
@@ -548,10 +545,7 @@ export default {
|
|||||||
},
|
},
|
||||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
this.copilotAcceptedMessages[key] = trimContent(
|
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||||
message || '',
|
|
||||||
this.maxLength
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
@@ -609,7 +603,7 @@ export default {
|
|||||||
saveDraft(conversationId, replyType) {
|
saveDraft(conversationId, replyType) {
|
||||||
if (this.message || this.message === '') {
|
if (this.message || this.message === '') {
|
||||||
const key = this.getDraftKey(conversationId, replyType);
|
const key = this.getDraftKey(conversationId, replyType);
|
||||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
const draftToSave = trimContent(this.message || '');
|
||||||
|
|
||||||
this.$store.dispatch('draftMessages/set', {
|
this.$store.dispatch('draftMessages/set', {
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const FEATURE_FLAGS = {
|
|||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
|
||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
CAPTAIN_TASKS: 'captain_tasks',
|
CAPTAIN_TASKS: 'captain_tasks',
|
||||||
SAML: 'saml',
|
SAML: 'saml',
|
||||||
|
|||||||
@@ -166,8 +166,6 @@ const TOD_TO_MERIDIEM = {
|
|||||||
evening: 'pm',
|
evening: 'pm',
|
||||||
night: 'pm',
|
night: 'pm',
|
||||||
};
|
};
|
||||||
const CJK_CHAR_RE =
|
|
||||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
||||||
|
|
||||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -280,13 +278,8 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|||||||
const substituteLocalTokens = (text, pairs) => {
|
const substituteLocalTokens = (text, pairs) => {
|
||||||
let r = text;
|
let r = text;
|
||||||
pairs.forEach(([local, en]) => {
|
pairs.forEach(([local, en]) => {
|
||||||
if (CJK_CHAR_RE.test(local)) {
|
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||||
const re = new RegExp(escapeRegex(local), 'g');
|
r = r.replace(re, en);
|
||||||
r = r.replace(re, ` ${en} `);
|
|
||||||
} else {
|
|
||||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
|
||||||
r = r.replace(re, en);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,9 +82,6 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
|||||||
|
|
||||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||||
const RELATIVE_DURATION_AFTER_RE = new RegExp(
|
|
||||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
|
|
||||||
);
|
|
||||||
const DURATION_FROM_NOW_RE = new RegExp(
|
const DURATION_FROM_NOW_RE = new RegExp(
|
||||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||||
);
|
);
|
||||||
@@ -92,9 +89,6 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
|||||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||||
);
|
);
|
||||||
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
|
|
||||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
|
|
||||||
);
|
|
||||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||||
);
|
);
|
||||||
@@ -251,7 +245,6 @@ const matchDuration = (text, now) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
|
|
||||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -310,13 +303,6 @@ const matchRelativeDay = (text, now) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
|
|
||||||
if (dayMeridiemMatch) {
|
|
||||||
const [, dayKey, meridiem] = dayMeridiemMatch;
|
|
||||||
const hours = meridiem === 'am' ? 9 : 14;
|
|
||||||
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||||
if (dayAtTimeMatch) {
|
if (dayAtTimeMatch) {
|
||||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||||
|
|||||||
@@ -1626,24 +1626,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const zhTWSnoozeTranslations = {
|
|
||||||
UNITS: {
|
|
||||||
HOUR: '小時',
|
|
||||||
HOURS: '小時',
|
|
||||||
DAY: '天',
|
|
||||||
DAYS: '天',
|
|
||||||
},
|
|
||||||
HALF: '半',
|
|
||||||
RELATIVE: {
|
|
||||||
TOMORROW: '明天',
|
|
||||||
},
|
|
||||||
MERIDIEM: {
|
|
||||||
AM: '上午',
|
|
||||||
PM: '下午',
|
|
||||||
},
|
|
||||||
AFTER: '後',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
|
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
|
||||||
it('Arabic "غد" does not produce half-duration suggestions', () => {
|
it('Arabic "غد" does not produce half-duration suggestions', () => {
|
||||||
const results = generateDateSuggestions('غد', now, {
|
const results = generateDateSuggestions('غد', now, {
|
||||||
@@ -1739,37 +1721,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
|||||||
expect(results[0].date.getHours()).toBe(6);
|
expect(results[0].date.getHours()).toBe(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('zh_TW compact CJK inputs', () => {
|
|
||||||
const options = {
|
|
||||||
translations: zhTWSnoozeTranslations,
|
|
||||||
locale: 'zh-TW',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('parses "2小時後" (2 hours from now) without spaces', () => {
|
|
||||||
const results = generateDateSuggestions('2小時後', now, options);
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0].date.getDate()).toBe(16);
|
|
||||||
expect(results[0].date.getHours()).toBe(12);
|
|
||||||
expect(results[0].date.getMinutes()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "半天" (half day) without spaces', () => {
|
|
||||||
const results = generateDateSuggestions('半天', now, options);
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0].date.getDate()).toBe(16);
|
|
||||||
expect(results[0].date.getHours()).toBe(22);
|
|
||||||
expect(results[0].date.getMinutes()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
|
|
||||||
const results = generateDateSuggestions('明天 上午', now, options);
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results[0].date.getDate()).toBe(17);
|
|
||||||
expect(results[0].date.getHours()).toBe(9);
|
|
||||||
expect(results[0].date.getMinutes()).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no-space duration suggestions', () => {
|
describe('no-space duration suggestions', () => {
|
||||||
|
|||||||
@@ -738,17 +738,6 @@
|
|||||||
"DOCUMENTS": {
|
"DOCUMENTS": {
|
||||||
"HEADER": "Documents",
|
"HEADER": "Documents",
|
||||||
"ADD_NEW": "Create a new document",
|
"ADD_NEW": "Create a new document",
|
||||||
"SELECTED": "{count} selected",
|
|
||||||
"SELECT_ALL": "Select all ({count})",
|
|
||||||
"UNSELECT_ALL": "Unselect all ({count})",
|
|
||||||
"BULK_DELETE_BUTTON": "Delete",
|
|
||||||
"BULK_DELETE": {
|
|
||||||
"TITLE": "Delete documents?",
|
|
||||||
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
|
|
||||||
"CONFIRM": "Yes, delete all",
|
|
||||||
"SUCCESS_MESSAGE": "Documents deleted successfully",
|
|
||||||
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
|
|
||||||
},
|
|
||||||
"RELATED_RESPONSES": {
|
"RELATED_RESPONSES": {
|
||||||
"TITLE": "Related FAQs",
|
"TITLE": "Related FAQs",
|
||||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||||
@@ -807,7 +796,6 @@
|
|||||||
"CUSTOM_TOOLS": {
|
"CUSTOM_TOOLS": {
|
||||||
"HEADER": "Tools",
|
"HEADER": "Tools",
|
||||||
"ADD_NEW": "Create a new tool",
|
"ADD_NEW": "Create a new tool",
|
||||||
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
|
|
||||||
"EMPTY_STATE": {
|
"EMPTY_STATE": {
|
||||||
"TITLE": "No custom tools available",
|
"TITLE": "No custom tools available",
|
||||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||||
@@ -838,18 +826,11 @@
|
|||||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||||
},
|
},
|
||||||
"TEST": {
|
|
||||||
"BUTTON": "Test connection",
|
|
||||||
"SUCCESS": "Endpoint returned HTTP {status}",
|
|
||||||
"ERROR": "Connection failed",
|
|
||||||
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
|
|
||||||
},
|
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"TITLE": {
|
"TITLE": {
|
||||||
"LABEL": "Tool Name",
|
"LABEL": "Tool Name",
|
||||||
"PLACEHOLDER": "Order Lookup",
|
"PLACEHOLDER": "Order Lookup",
|
||||||
"ERROR": "Tool name is required",
|
"ERROR": "Tool name is required"
|
||||||
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
|
|
||||||
},
|
},
|
||||||
"DESCRIPTION": {
|
"DESCRIPTION": {
|
||||||
"LABEL": "Description",
|
"LABEL": "Description",
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import setNewPassword from './setNewPassword.json';
|
|||||||
import settings from './settings.json';
|
import settings from './settings.json';
|
||||||
import signup from './signup.json';
|
import signup from './signup.json';
|
||||||
import sla from './sla.json';
|
import sla from './sla.json';
|
||||||
import snooze from './snooze.json';
|
|
||||||
import teamsSettings from './teamsSettings.json';
|
import teamsSettings from './teamsSettings.json';
|
||||||
import whatsappTemplates from './whatsappTemplates.json';
|
import whatsappTemplates from './whatsappTemplates.json';
|
||||||
|
|
||||||
@@ -75,7 +74,6 @@ export default {
|
|||||||
...settings,
|
...settings,
|
||||||
...signup,
|
...signup,
|
||||||
...sla,
|
...sla,
|
||||||
...snooze,
|
|
||||||
...teamsSettings,
|
...teamsSettings,
|
||||||
...whatsappTemplates,
|
...whatsappTemplates,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
{
|
{
|
||||||
"SNOOZE_PARSER": {
|
"SNOOZE_PARSER": {
|
||||||
"UNITS": {
|
"UNITS": {
|
||||||
"MINUTE": "分鐘",
|
"MINUTE": "minute",
|
||||||
"MINUTES": "分鐘",
|
"MINUTES": "minutes",
|
||||||
"HOUR": "小時",
|
"HOUR": "hour",
|
||||||
"HOURS": "小時",
|
"HOURS": "小時",
|
||||||
"DAY": "天",
|
"DAY": "day",
|
||||||
"DAYS": "天",
|
"DAYS": "days",
|
||||||
"WEEK": "週",
|
"WEEK": "week",
|
||||||
"WEEKS": "週",
|
"WEEKS": "weeks",
|
||||||
"MONTH": "月",
|
"MONTH": "month",
|
||||||
"MONTHS": "月",
|
"MONTHS": "months",
|
||||||
"YEAR": "年",
|
"YEAR": "month",
|
||||||
"YEARS": "年"
|
"YEARS": "years"
|
||||||
},
|
},
|
||||||
"HALF": "半",
|
"HALF": "half",
|
||||||
"NEXT": "下一個",
|
"NEXT": "next",
|
||||||
"THIS": "這個",
|
"THIS": "this",
|
||||||
"AT": "在",
|
"AT": "at",
|
||||||
"IN": "在",
|
"IN": "in",
|
||||||
"FROM_NOW": "之後",
|
"FROM_NOW": "from now",
|
||||||
"NEXT_YEAR": "明年",
|
"NEXT_YEAR": "next year",
|
||||||
"MERIDIEM": {
|
"MERIDIEM": {
|
||||||
"AM": "上午",
|
"AM": "am",
|
||||||
"PM": "下午"
|
"PM": "pm"
|
||||||
},
|
},
|
||||||
"RELATIVE": {
|
"RELATIVE": {
|
||||||
"TOMORROW": "明天",
|
"TOMORROW": "明天",
|
||||||
"DAY_AFTER_TOMORROW": "後天",
|
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||||
"NEXT_WEEK": "下週",
|
"NEXT_WEEK": "下週",
|
||||||
"NEXT_MONTH": "下個月",
|
"NEXT_MONTH": "next month",
|
||||||
"THIS_WEEKEND": "這個週末",
|
"THIS_WEEKEND": "this weekend",
|
||||||
"NEXT_WEEKEND": "下個週末"
|
"NEXT_WEEKEND": "next weekend"
|
||||||
},
|
},
|
||||||
"TIME_OF_DAY": {
|
"TIME_OF_DAY": {
|
||||||
"MORNING": "早上",
|
"MORNING": "morning",
|
||||||
"AFTERNOON": "下午",
|
"AFTERNOON": "afternoon",
|
||||||
"EVENING": "晚上",
|
"EVENING": "evening",
|
||||||
"NIGHT": "夜晚",
|
"NIGHT": "night",
|
||||||
"NOON": "中午",
|
"NOON": "noon",
|
||||||
"MIDNIGHT": "午夜"
|
"MIDNIGHT": "midnight"
|
||||||
},
|
},
|
||||||
"WORD_NUMBERS": {
|
"WORD_NUMBERS": {
|
||||||
"ONE": "一",
|
"ONE": "one",
|
||||||
"TWO": "二",
|
"TWO": "two",
|
||||||
"THREE": "三",
|
"THREE": "three",
|
||||||
"FOUR": "四",
|
"FOUR": "four",
|
||||||
"FIVE": "五",
|
"FIVE": "five",
|
||||||
"SIX": "六",
|
"SIX": "six",
|
||||||
"SEVEN": "七",
|
"SEVEN": "seven",
|
||||||
"EIGHT": "八",
|
"EIGHT": "eight",
|
||||||
"NINE": "九",
|
"NINE": "nine",
|
||||||
"TEN": "十",
|
"TEN": "ten",
|
||||||
"TWELVE": "十二",
|
"TWELVE": "twelve",
|
||||||
"FIFTEEN": "十五",
|
"FIFTEEN": "fifteen",
|
||||||
"TWENTY": "二十",
|
"TWENTY": "twenty",
|
||||||
"THIRTY": "三十"
|
"THIRTY": "thirty"
|
||||||
},
|
},
|
||||||
"ORDINALS": {
|
"ORDINALS": {
|
||||||
"FIRST": "第一",
|
"FIRST": "first",
|
||||||
"SECOND": "第二",
|
"SECOND": "second",
|
||||||
"THIRD": "第三",
|
"THIRD": "third",
|
||||||
"FOURTH": "第四",
|
"FOURTH": "fourth",
|
||||||
"FIFTH": "第五"
|
"FIFTH": "fifth"
|
||||||
},
|
},
|
||||||
"OF": "的",
|
"OF": "of",
|
||||||
"AFTER": "後",
|
"AFTER": "after",
|
||||||
"WEEK": "週",
|
"WEEK": "week",
|
||||||
"DAY": "天"
|
"DAY": "day"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const assistantRoutes = [
|
|||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||||
component: CustomToolsIndex,
|
component: CustomToolsIndex,
|
||||||
name: 'captain_tools_index',
|
name: 'captain_tools_index',
|
||||||
meta,
|
meta: metaV2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
|
||||||
|
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
|
||||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
|
||||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||||
@@ -18,12 +14,9 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
|
|||||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
|
||||||
const { checkPermissions } = usePolicy();
|
|
||||||
|
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
||||||
@@ -32,13 +25,9 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
|
|||||||
const documentsMeta = useMapGetter('captainDocuments/getMeta');
|
const documentsMeta = useMapGetter('captainDocuments/getMeta');
|
||||||
|
|
||||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||||
const canManageDocuments = computed(() => checkPermissions(['administrator']));
|
|
||||||
|
|
||||||
const selectedDocument = ref(null);
|
const selectedDocument = ref(null);
|
||||||
const deleteDocumentDialog = ref(null);
|
const deleteDocumentDialog = ref(null);
|
||||||
const bulkDeleteDialog = ref(null);
|
|
||||||
const bulkSelectedIds = ref(new Set());
|
|
||||||
const hoveredCard = ref(null);
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
deleteDocumentDialog.value.dialogRef.open();
|
deleteDocumentDialog.value.dialogRef.open();
|
||||||
@@ -89,14 +78,7 @@ const fetchDocuments = (page = 1) => {
|
|||||||
store.dispatch('captainDocuments/get', filterParams);
|
store.dispatch('captainDocuments/get', filterParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPageChange = page => {
|
const onPageChange = page => fetchDocuments(page);
|
||||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
|
||||||
fetchDocuments(page);
|
|
||||||
|
|
||||||
if (hadSelection) {
|
|
||||||
bulkSelectedIds.value = new Set();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteSuccess = () => {
|
const onDeleteSuccess = () => {
|
||||||
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
||||||
@@ -104,58 +86,6 @@ const onDeleteSuccess = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSelectedCountLabel = computed(() => {
|
|
||||||
const count = documents.value?.length || 0;
|
|
||||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
|
||||||
return isAllSelected
|
|
||||||
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
|
|
||||||
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedCountLabel = computed(() => {
|
|
||||||
return t('CAPTAIN.DOCUMENTS.SELECTED', {
|
|
||||||
count: bulkSelectedIds.value.size,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
|
|
||||||
|
|
||||||
const shouldShowSelectionControl = docId => {
|
|
||||||
return (
|
|
||||||
canManageDocuments.value &&
|
|
||||||
(hoveredCard.value === docId || hasBulkSelection.value)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardHover = (isHovered, id) => {
|
|
||||||
hoveredCard.value = isHovered ? id : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardSelect = id => {
|
|
||||||
if (!canManageDocuments.value) return;
|
|
||||||
const selected = new Set(bulkSelectedIds.value);
|
|
||||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
|
||||||
bulkSelectedIds.value = selected;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDocumentsAfterBulkAction = () => {
|
|
||||||
const hasNoDocumentsLeft = documents.value?.length === 0;
|
|
||||||
const currentPage = documentsMeta.value?.page;
|
|
||||||
|
|
||||||
if (hasNoDocumentsLeft) {
|
|
||||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
|
||||||
fetchDocuments(pageToFetch);
|
|
||||||
} else {
|
|
||||||
fetchDocuments(currentPage);
|
|
||||||
}
|
|
||||||
|
|
||||||
bulkSelectedIds.value = new Set();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBulkDeleteSuccess = () => {
|
|
||||||
fetchDocumentsAfterBulkAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchDocuments();
|
fetchDocuments();
|
||||||
});
|
});
|
||||||
@@ -176,21 +106,6 @@ onMounted(() => {
|
|||||||
@update:current-page="onPageChange"
|
@update:current-page="onPageChange"
|
||||||
@click="handleCreateDocument"
|
@click="handleCreateDocument"
|
||||||
>
|
>
|
||||||
<template #subHeader>
|
|
||||||
<Policy :permissions="['administrator']">
|
|
||||||
<BulkSelectBar
|
|
||||||
v-model="bulkSelectedIds"
|
|
||||||
:all-items="documents"
|
|
||||||
:select-all-label="buildSelectedCountLabel"
|
|
||||||
:selected-count-label="selectedCountLabel"
|
|
||||||
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
|
|
||||||
class="w-fit"
|
|
||||||
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
|
|
||||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
|
||||||
/>
|
|
||||||
</Policy>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #knowMore>
|
<template #knowMore>
|
||||||
<FeatureSpotlightPopover
|
<FeatureSpotlightPopover
|
||||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||||
@@ -223,13 +138,7 @@ onMounted(() => {
|
|||||||
:external-link="doc.external_link"
|
:external-link="doc.external_link"
|
||||||
:assistant="doc.assistant"
|
:assistant="doc.assistant"
|
||||||
:created-at="doc.created_at"
|
:created-at="doc.created_at"
|
||||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
|
||||||
:selectable="canManageDocuments"
|
|
||||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
|
||||||
:show-menu="!bulkSelectedIds.has(doc.id)"
|
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@select="handleCardSelect"
|
|
||||||
@hover="isHovered => handleCardHover(isHovered, doc.id)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,12 +162,5 @@ onMounted(() => {
|
|||||||
type="Documents"
|
type="Documents"
|
||||||
@delete-success="onDeleteSuccess"
|
@delete-success="onDeleteSuccess"
|
||||||
/>
|
/>
|
||||||
<BulkDeleteDialog
|
|
||||||
v-if="bulkSelectedIds"
|
|
||||||
ref="bulkDeleteDialog"
|
|
||||||
:bulk-ids="bulkSelectedIds"
|
|
||||||
type="AssistantDocument"
|
|
||||||
@delete-success="onBulkDeleteSuccess"
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ onMounted(() => {
|
|||||||
v-if="bulkSelectedIds"
|
v-if="bulkSelectedIds"
|
||||||
ref="bulkDeleteDialog"
|
ref="bulkDeleteDialog"
|
||||||
:bulk-ids="bulkSelectedIds"
|
:bulk-ids="bulkSelectedIds"
|
||||||
type="AssistantResponse"
|
type="Responses"
|
||||||
@delete-success="onBulkDeleteSuccess"
|
@delete-success="onBulkDeleteSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ onMounted(() => {
|
|||||||
v-if="bulkSelectedIds"
|
v-if="bulkSelectedIds"
|
||||||
ref="bulkDeleteDialog"
|
ref="bulkDeleteDialog"
|
||||||
:bulk-ids="bulkSelectedIds"
|
:bulk-ids="bulkSelectedIds"
|
||||||
type="AssistantResponse"
|
type="Responses"
|
||||||
@delete-success="onBulkDeleteSuccess"
|
@delete-success="onBulkDeleteSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,21 @@
|
|||||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
|
||||||
|
|
||||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
|
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isFeatureFlagEnabled } = usePolicy();
|
|
||||||
|
|
||||||
const SOFT_LIMIT = 10;
|
|
||||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
|
||||||
|
|
||||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||||
|
|
||||||
const showSoftLimitWarning = computed(
|
|
||||||
() => !isV2.value && customToolsMeta.value.totalCount > SOFT_LIMIT
|
|
||||||
);
|
|
||||||
|
|
||||||
const createDialogRef = ref(null);
|
const createDialogRef = ref(null);
|
||||||
const deleteDialogRef = ref(null);
|
const deleteDialogRef = ref(null);
|
||||||
const selectedTool = ref(null);
|
const selectedTool = ref(null);
|
||||||
@@ -94,23 +86,21 @@ onMounted(() => {
|
|||||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
:is-empty="!customTools.length"
|
:is-empty="!customTools.length"
|
||||||
|
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||||
:show-know-more="false"
|
:show-know-more="false"
|
||||||
@update:current-page="onPageChange"
|
@update:current-page="onPageChange"
|
||||||
@click="openCreateDialog"
|
@click="openCreateDialog"
|
||||||
>
|
>
|
||||||
|
<template #paywall>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #emptyState>
|
<template #emptyState>
|
||||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div
|
|
||||||
v-if="showSoftLimitWarning"
|
|
||||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-lg bg-n-amber-2 text-n-amber-11"
|
|
||||||
>
|
|
||||||
<span class="i-lucide-triangle-alert size-4 shrink-0" />
|
|
||||||
{{ $t('CAPTAIN.CUSTOM_TOOLS.SOFT_LIMIT_WARNING') }}
|
|
||||||
</div>
|
|
||||||
<CustomToolCard
|
<CustomToolCard
|
||||||
v-for="tool in customTools"
|
v-for="tool in customTools"
|
||||||
:id="tool.id"
|
:id="tool.id"
|
||||||
|
|||||||
@@ -36,27 +36,27 @@ export default {
|
|||||||
{
|
{
|
||||||
id: null,
|
id: null,
|
||||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
||||||
icon: 'i-woot-priority-empty',
|
thumbnail: `/assets/images/dashboard/priority/none.svg`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: CONVERSATION_PRIORITY.URGENT,
|
id: CONVERSATION_PRIORITY.URGENT,
|
||||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
||||||
icon: 'i-woot-priority-urgent',
|
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: CONVERSATION_PRIORITY.HIGH,
|
id: CONVERSATION_PRIORITY.HIGH,
|
||||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
||||||
icon: 'i-woot-priority-high',
|
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: CONVERSATION_PRIORITY.MEDIUM,
|
id: CONVERSATION_PRIORITY.MEDIUM,
|
||||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
||||||
icon: 'i-woot-priority-medium',
|
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: CONVERSATION_PRIORITY.LOW,
|
id: CONVERSATION_PRIORITY.LOW,
|
||||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
||||||
icon: 'i-woot-priority-low',
|
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ export default {
|
|||||||
const { name, locale, id, domain, support_email, features } =
|
const { name, locale, id, domain, support_email, features } =
|
||||||
this.getAccount(this.accountId);
|
this.getAccount(this.accountId);
|
||||||
|
|
||||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
|
||||||
if (effectiveLocale) {
|
|
||||||
this.$root.$i18n.locale = effectiveLocale;
|
|
||||||
}
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.locale = locale;
|
this.locale = locale;
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -132,9 +129,11 @@ export default {
|
|||||||
support_email: this.supportEmail,
|
support_email: this.supportEmail,
|
||||||
});
|
});
|
||||||
// If user locale is set, update the locale with user locale
|
// If user locale is set, update the locale with user locale
|
||||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
if (this.uiSettings?.locale) {
|
||||||
if (updatedLocale) {
|
this.$root.$i18n.locale = this.uiSettings?.locale;
|
||||||
this.$root.$i18n.locale = updatedLocale;
|
} else {
|
||||||
|
// If user locale is not set, update the locale with account locale
|
||||||
|
this.$root.$i18n.locale = this.locale;
|
||||||
}
|
}
|
||||||
this.getAccount(this.id).locale = this.locale;
|
this.getAccount(this.id).locale = this.locale;
|
||||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(
|
useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
|
||||||
error.message ||
|
|
||||||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const saveMacro = async macroData => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto h-full w-full !px-6">
|
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||||
<woot-loading-state
|
<woot-loading-state
|
||||||
v-if="uiFlags.isFetchingItem"
|
v-if="uiFlags.isFetchingItem"
|
||||||
:message="t('MACROS.EDITOR.LOADING')"
|
:message="t('MACROS.EDITOR.LOADING')"
|
||||||
|
|||||||
@@ -25,26 +25,17 @@ export default createStore({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleBulkDelete: async function handleBulkDelete(
|
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
|
||||||
{ dispatch },
|
|
||||||
{ type = 'AssistantResponse', ids }
|
|
||||||
) {
|
|
||||||
const response = await dispatch('processBulkAction', {
|
const response = await dispatch('processBulkAction', {
|
||||||
type,
|
type: 'AssistantResponse',
|
||||||
actionType: 'delete',
|
actionType: 'delete',
|
||||||
ids,
|
ids,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (type === 'AssistantResponse') {
|
// Update the response store after successful API call
|
||||||
// Update the response store after successful API call
|
await dispatch('captainResponses/removeBulkResponses', ids, {
|
||||||
await dispatch('captainResponses/removeBulkResponses', ids, {
|
root: true,
|
||||||
root: true,
|
});
|
||||||
});
|
|
||||||
} else if (type === 'AssistantDocument') {
|
|
||||||
await dispatch('captainDocuments/removeBulkRecords', ids, {
|
|
||||||
root: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,4 @@ import { createStore } from '../storeFactory';
|
|||||||
export default createStore({
|
export default createStore({
|
||||||
name: 'CaptainDocument',
|
name: 'CaptainDocument',
|
||||||
API: CaptainDocumentAPI,
|
API: CaptainDocumentAPI,
|
||||||
actions: mutations => ({
|
|
||||||
removeBulkRecords({ commit, getters }, ids) {
|
|
||||||
const records = getters.getRecords.filter(
|
|
||||||
record => !ids.includes(record.id)
|
|
||||||
);
|
|
||||||
commit(mutations.SET, records);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,8 +220,9 @@ export const actions = {
|
|||||||
sendAnalyticsEvent(channel.type);
|
sendAnalyticsEvent(channel.type);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error?.response?.data?.message;
|
||||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||||
return throwErrorMessage(error);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createWebsiteChannel: async ({ commit }, params) => {
|
createWebsiteChannel: async ({ commit }, params) => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useToggle } from '@vueuse/core';
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
|
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -54,10 +53,6 @@ const hasValue = computed(() => {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasIcon = computed(() => {
|
|
||||||
return props.selectedItem?.icon || false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +83,7 @@ const hasIcon = computed(() => {
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="hasValue && hasThumbnail && !hasIcon"
|
v-if="hasValue && hasThumbnail"
|
||||||
:src="selectedItem.thumbnail"
|
:src="selectedItem.thumbnail"
|
||||||
:status="selectedItem.availability_status"
|
:status="selectedItem.availability_status"
|
||||||
:name="selectedItem.name"
|
:name="selectedItem.name"
|
||||||
@@ -96,11 +91,6 @@ const hasIcon = computed(() => {
|
|||||||
hide-offline-status
|
hide-offline-status
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<Icon
|
|
||||||
v-if="hasValue && hasIcon"
|
|
||||||
:icon="selectedItem.icon"
|
|
||||||
class="size-5 text-n-slate-11"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -10,7 +9,6 @@ export default {
|
|||||||
WootDropdownItem,
|
WootDropdownItem,
|
||||||
WootDropdownMenu,
|
WootDropdownMenu,
|
||||||
Avatar,
|
Avatar,
|
||||||
Icon,
|
|
||||||
NextButton,
|
NextButton,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -108,7 +106,7 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="hasThumbnail && !option.icon"
|
v-if="hasThumbnail"
|
||||||
:src="option.thumbnail"
|
:src="option.thumbnail"
|
||||||
:name="option.name"
|
:name="option.name"
|
||||||
:status="option.availability_status"
|
:status="option.availability_status"
|
||||||
@@ -116,11 +114,6 @@ export default {
|
|||||||
hide-offline-status
|
hide-offline-status
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<Icon
|
|
||||||
v-if="option.icon"
|
|
||||||
:icon="option.icon"
|
|
||||||
class="size-5 text-n-slate-11"
|
|
||||||
/>
|
|
||||||
</NextButton>
|
</NextButton>
|
||||||
</WootDropdownItem>
|
</WootDropdownItem>
|
||||||
</WootDropdownMenu>
|
</WootDropdownMenu>
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
if (locale) {
|
this.$root.$i18n.locale = locale;
|
||||||
this.$root.$i18n.locale = locale;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,26 +6,13 @@ const createConversationAPI = async content => {
|
|||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessageAPI = async (
|
const sendMessageAPI = async (content, replyTo = null) => {
|
||||||
content,
|
const urlData = endPoints.sendMessage(content, replyTo);
|
||||||
replyTo = null,
|
|
||||||
{ customAttributes, labels } = {}
|
|
||||||
) => {
|
|
||||||
const urlData = endPoints.sendMessage(content, replyTo, {
|
|
||||||
customAttributes,
|
|
||||||
labels,
|
|
||||||
});
|
|
||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAttachmentAPI = async (
|
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||||
attachment,
|
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||||
{ customAttributes, labels } = {}
|
|
||||||
) => {
|
|
||||||
const urlData = endPoints.sendAttachment(attachment, {
|
|
||||||
customAttributes,
|
|
||||||
labels,
|
|
||||||
});
|
|
||||||
return API.post(urlData.url, urlData.params);
|
return API.post(urlData.url, urlData.params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,30 +22,23 @@ const createConversation = params => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
|
const sendMessage = (content, replyTo) => {
|
||||||
const referrerURL = window.referrerURL || '';
|
const referrerURL = window.referrerURL || '';
|
||||||
const search = buildSearchParamsWithLocale(window.location.search);
|
const search = buildSearchParamsWithLocale(window.location.search);
|
||||||
const params = {
|
return {
|
||||||
message: {
|
url: `/api/v1/widget/messages${search}`,
|
||||||
content,
|
params: {
|
||||||
reply_to: replyTo,
|
message: {
|
||||||
timestamp: new Date().toString(),
|
content,
|
||||||
referer_url: referrerURL,
|
reply_to: replyTo,
|
||||||
|
timestamp: new Date().toString(),
|
||||||
|
referer_url: referrerURL,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
|
||||||
params.custom_attributes = customAttributes;
|
|
||||||
}
|
|
||||||
if (labels && labels.length > 0) {
|
|
||||||
params.labels = labels;
|
|
||||||
}
|
|
||||||
return { url: `/api/v1/widget/messages${search}`, params };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendAttachment = (
|
const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||||
{ attachment, replyTo = null },
|
|
||||||
{ customAttributes, labels } = {}
|
|
||||||
) => {
|
|
||||||
const { referrerURL = '' } = window;
|
const { referrerURL = '' } = window;
|
||||||
const timestamp = new Date().toString();
|
const timestamp = new Date().toString();
|
||||||
const { file } = attachment;
|
const { file } = attachment;
|
||||||
@@ -62,16 +55,6 @@ const sendAttachment = (
|
|||||||
if (replyTo !== null) {
|
if (replyTo !== null) {
|
||||||
formData.append('message[reply_to]', replyTo);
|
formData.append('message[reply_to]', replyTo);
|
||||||
}
|
}
|
||||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
|
||||||
Object.entries(customAttributes).forEach(([key, value]) => {
|
|
||||||
formData.append(`custom_attributes[${key}]`, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (labels && labels.length > 0) {
|
|
||||||
labels.forEach(label => {
|
|
||||||
formData.append('labels[]', label);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
url: `/api/v1/widget/messages${window.location.search}`,
|
url: `/api/v1/widget/messages${window.location.search}`,
|
||||||
params: formData,
|
params: formData,
|
||||||
|
|||||||
@@ -32,50 +32,6 @@ describe('#sendMessage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#sendMessage with pending metadata', () => {
|
|
||||||
it('includes custom_attributes and labels in payload', () => {
|
|
||||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
|
||||||
toString: () => 'mock date',
|
|
||||||
}));
|
|
||||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
|
||||||
...window.location,
|
|
||||||
search: '?param=1',
|
|
||||||
});
|
|
||||||
|
|
||||||
window.WOOT_WIDGET = {
|
|
||||||
$root: { $i18n: { locale: 'ar' } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = endPoints.sendMessage('hello', null, {
|
|
||||||
customAttributes: { plan: 'enterprise' },
|
|
||||||
labels: ['vip'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
|
|
||||||
expect(result.params.labels).toEqual(['vip']);
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not include metadata keys when not provided', () => {
|
|
||||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
|
||||||
toString: () => 'mock date',
|
|
||||||
}));
|
|
||||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
|
||||||
...window.location,
|
|
||||||
search: '?param=1',
|
|
||||||
});
|
|
||||||
|
|
||||||
window.WOOT_WIDGET = {
|
|
||||||
$root: { $i18n: { locale: 'ar' } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = endPoints.sendMessage('hello');
|
|
||||||
expect(result.params.custom_attributes).toBeUndefined();
|
|
||||||
expect(result.params.labels).toBeUndefined();
|
|
||||||
spy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getConversation', () => {
|
describe('#getConversation', () => {
|
||||||
it('returns correct payload', () => {
|
it('returns correct payload', () => {
|
||||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
@apply antialiased h-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-mobile {
|
.is-mobile {
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async retrySendMessage() {
|
async retrySendMessage() {
|
||||||
await this.$store.dispatch('conversation/sendMessageWithData', {
|
await this.$store.dispatch(
|
||||||
message: this.message,
|
'conversation/sendMessageWithData',
|
||||||
});
|
this.message
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onImageLoadError() {
|
onImageLoadError() {
|
||||||
this.hasImageError = true;
|
this.hasImageError = true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, watchEffect } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
const isDarkModeAuto = mode => mode === 'auto';
|
const isDarkModeAuto = mode => mode === 'auto';
|
||||||
@@ -23,10 +23,6 @@ export function useDarkMode() {
|
|||||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
darkMode,
|
darkMode,
|
||||||
prefersDarkMode,
|
prefersDarkMode,
|
||||||
|
|||||||
@@ -30,37 +30,18 @@ export const actions = {
|
|||||||
commit('setConversationUIFlag', { isCreating: false });
|
commit('setConversationUIFlag', { isCreating: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessage: async ({ dispatch, state: conversationState }, params) => {
|
sendMessage: async ({ dispatch }, params) => {
|
||||||
const { content, replyTo } = params;
|
const { content, replyTo } = params;
|
||||||
const message = createTemporaryMessage({ content, replyTo });
|
const message = createTemporaryMessage({ content, replyTo });
|
||||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
dispatch('sendMessageWithData', message);
|
||||||
dispatch('sendMessageWithData', {
|
|
||||||
message,
|
|
||||||
pendingCustomAttributes,
|
|
||||||
pendingLabels,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
sendMessageWithData: async (
|
sendMessageWithData: async ({ commit }, message) => {
|
||||||
{ commit },
|
|
||||||
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
|
|
||||||
) => {
|
|
||||||
const { id, content, replyTo, meta = {} } = message;
|
const { id, content, replyTo, meta = {} } = message;
|
||||||
const hasPendingMetadata =
|
|
||||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
|
||||||
pendingLabels.length > 0;
|
|
||||||
|
|
||||||
commit('pushMessageToConversation', message);
|
commit('pushMessageToConversation', message);
|
||||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||||
try {
|
try {
|
||||||
const { data } = await sendMessageAPI(content, replyTo, {
|
const { data } = await sendMessageAPI(content, replyTo);
|
||||||
customAttributes: hasPendingMetadata
|
|
||||||
? pendingCustomAttributes
|
|
||||||
: undefined,
|
|
||||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
|
||||||
});
|
|
||||||
if (hasPendingMetadata) {
|
|
||||||
commit('clearPendingConversationMetadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
|
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
|
||||||
// commit('deleteMessage', message.id);
|
// commit('deleteMessage', message.id);
|
||||||
@@ -78,7 +59,7 @@ export const actions = {
|
|||||||
commit('setLastMessageId');
|
commit('setLastMessageId');
|
||||||
},
|
},
|
||||||
|
|
||||||
sendAttachment: async ({ commit, state: conversationState }, params) => {
|
sendAttachment: async ({ commit }, params) => {
|
||||||
const {
|
const {
|
||||||
attachment: { thumbUrl, fileType },
|
attachment: { thumbUrl, fileType },
|
||||||
meta = {},
|
meta = {},
|
||||||
@@ -93,22 +74,9 @@ export const actions = {
|
|||||||
attachments: [attachment],
|
attachments: [attachment],
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
});
|
});
|
||||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
|
||||||
const hasPendingMetadata =
|
|
||||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
|
||||||
pendingLabels.length > 0;
|
|
||||||
|
|
||||||
commit('pushMessageToConversation', tempMessage);
|
commit('pushMessageToConversation', tempMessage);
|
||||||
try {
|
try {
|
||||||
const { data } = await sendAttachmentAPI(params, {
|
const { data } = await sendAttachmentAPI(params);
|
||||||
customAttributes: hasPendingMetadata
|
|
||||||
? pendingCustomAttributes
|
|
||||||
: undefined,
|
|
||||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
|
||||||
});
|
|
||||||
if (hasPendingMetadata) {
|
|
||||||
commit('clearPendingConversationMetadata');
|
|
||||||
}
|
|
||||||
commit('updateAttachmentMessageStatus', {
|
commit('updateAttachmentMessageStatus', {
|
||||||
message: data,
|
message: data,
|
||||||
tempId: tempMessage.id,
|
tempId: tempMessage.id,
|
||||||
@@ -212,14 +180,7 @@ export const actions = {
|
|||||||
await toggleStatus();
|
await toggleStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
setCustomAttributes: async (
|
setCustomAttributes: async (_, customAttributes = {}) => {
|
||||||
{ commit, rootGetters },
|
|
||||||
customAttributes = {}
|
|
||||||
) => {
|
|
||||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
|
||||||
commit('setPendingCustomAttributes', customAttributes);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await setCustomAttributes(customAttributes);
|
await setCustomAttributes(customAttributes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -227,11 +188,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
|
deleteCustomAttribute: async (_, customAttribute) => {
|
||||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
|
||||||
commit('removePendingCustomAttribute', customAttribute);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteCustomAttribute(customAttribute);
|
await deleteCustomAttribute(customAttribute);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export const getters = {
|
|||||||
messages: groupConversationBySender(conversationGroupedByDate[date]),
|
messages: groupConversationBySender(conversationGroupedByDate[date]),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
|
|
||||||
getPendingLabels: _state => _state.pendingLabels,
|
|
||||||
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
|
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
|
||||||
getMessageCount: _state => {
|
getMessageCount: _state => {
|
||||||
return Object.values(_state.conversations).length;
|
return Object.values(_state.conversations).length;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ const state = {
|
|||||||
isCreating: false,
|
isCreating: false,
|
||||||
},
|
},
|
||||||
lastMessageId: null,
|
lastMessageId: null,
|
||||||
pendingCustomAttributes: {},
|
|
||||||
pendingLabels: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { findUndeliveredMessage } from './helpers';
|
|||||||
export const mutations = {
|
export const mutations = {
|
||||||
clearConversations($state) {
|
clearConversations($state) {
|
||||||
$state.conversations = {};
|
$state.conversations = {};
|
||||||
$state.pendingCustomAttributes = {};
|
|
||||||
$state.pendingLabels = [];
|
|
||||||
},
|
},
|
||||||
pushMessageToConversation($state, message) {
|
pushMessageToConversation($state, message) {
|
||||||
const { id, status, message_type: type } = message;
|
const { id, status, message_type: type } = message;
|
||||||
@@ -115,31 +113,4 @@ export const mutations = {
|
|||||||
const { id } = lastMessage;
|
const { id } = lastMessage;
|
||||||
$state.lastMessageId = id;
|
$state.lastMessageId = id;
|
||||||
},
|
},
|
||||||
|
|
||||||
setPendingCustomAttributes($state, data) {
|
|
||||||
$state.pendingCustomAttributes = {
|
|
||||||
...$state.pendingCustomAttributes,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
setPendingLabels($state, label) {
|
|
||||||
if (!$state.pendingLabels.includes(label)) {
|
|
||||||
$state.pendingLabels.push(label);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removePendingCustomAttribute($state, key) {
|
|
||||||
const { [key]: _, ...rest } = $state.pendingCustomAttributes;
|
|
||||||
$state.pendingCustomAttributes = rest;
|
|
||||||
},
|
|
||||||
|
|
||||||
removePendingLabel($state, label) {
|
|
||||||
$state.pendingLabels = $state.pendingLabels.filter(l => l !== label);
|
|
||||||
},
|
|
||||||
|
|
||||||
clearPendingConversationMetadata($state) {
|
|
||||||
$state.pendingCustomAttributes = {};
|
|
||||||
$state.pendingLabels = [];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,22 +5,14 @@ const state = {};
|
|||||||
export const getters = {};
|
export const getters = {};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
create: async ({ commit, rootGetters }, label) => {
|
create: async (_, label) => {
|
||||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
|
||||||
commit('conversation/setPendingLabels', label, { root: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await conversationLabels.create(label);
|
await conversationLabels.create(label);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
destroy: async ({ commit, rootGetters }, label) => {
|
destroy: async (_, label) => {
|
||||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
|
||||||
commit('conversation/removePendingLabel', label, { root: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await conversationLabels.destroy(label);
|
await conversationLabels.destroy(label);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -111,45 +111,20 @@ describe('#actions', () => {
|
|||||||
search: '?param=1',
|
search: '?param=1',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
|
||||||
await actions.sendMessage(
|
await actions.sendMessage(
|
||||||
{ commit, dispatch, state },
|
{ commit, dispatch },
|
||||||
{ content: 'hello', replyTo: 124 }
|
{ content: 'hello', replyTo: 124 }
|
||||||
);
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
windowSpy.mockRestore();
|
windowSpy.mockRestore();
|
||||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||||
message: {
|
attachments: undefined,
|
||||||
attachments: undefined,
|
content: 'hello',
|
||||||
content: 'hello',
|
created_at: 1466424490,
|
||||||
created_at: 1466424490,
|
id: '1111',
|
||||||
id: '1111',
|
message_type: 0,
|
||||||
message_type: 0,
|
replyTo: 124,
|
||||||
replyTo: 124,
|
status: 'in_progress',
|
||||||
status: 'in_progress',
|
|
||||||
},
|
|
||||||
pendingCustomAttributes: {},
|
|
||||||
pendingLabels: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes pending metadata when available', async () => {
|
|
||||||
const mockDate = new Date(1466424490000);
|
|
||||||
getUuid.mockImplementationOnce(() => '2222');
|
|
||||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
|
||||||
const state = {
|
|
||||||
pendingCustomAttributes: { plan: 'enterprise' },
|
|
||||||
pendingLabels: ['vip'],
|
|
||||||
};
|
|
||||||
await actions.sendMessage(
|
|
||||||
{ commit, dispatch, state },
|
|
||||||
{ content: 'hello' }
|
|
||||||
);
|
|
||||||
spy.mockRestore();
|
|
||||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
|
||||||
message: expect.objectContaining({ content: 'hello' }),
|
|
||||||
pendingCustomAttributes: { plan: 'enterprise' },
|
|
||||||
pendingLabels: ['vip'],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -161,10 +136,9 @@ describe('#actions', () => {
|
|||||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||||
const thumbUrl = '';
|
const thumbUrl = '';
|
||||||
const attachment = { thumbUrl, fileType: 'file' };
|
const attachment = { thumbUrl, fileType: 'file' };
|
||||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
|
||||||
|
|
||||||
actions.sendAttachment(
|
actions.sendAttachment(
|
||||||
{ commit, dispatch, state },
|
{ commit, dispatch },
|
||||||
{ attachment, replyTo: 135 }
|
{ attachment, replyTo: 135 }
|
||||||
);
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
@@ -206,58 +180,6 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#setCustomAttributes', () => {
|
|
||||||
it('queues to pending state when no conversation exists', async () => {
|
|
||||||
const rootGetters = {
|
|
||||||
'conversationAttributes/getConversationParams': { id: '' },
|
|
||||||
};
|
|
||||||
await actions.setCustomAttributes(
|
|
||||||
{ commit, rootGetters },
|
|
||||||
{ plan: 'enterprise' }
|
|
||||||
);
|
|
||||||
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
|
|
||||||
plan: 'enterprise',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls API when conversation exists', async () => {
|
|
||||||
API.post.mockResolvedValue({ data: {} });
|
|
||||||
const rootGetters = {
|
|
||||||
'conversationAttributes/getConversationParams': { id: 123 },
|
|
||||||
};
|
|
||||||
await actions.setCustomAttributes(
|
|
||||||
{ commit, rootGetters },
|
|
||||||
{ plan: 'enterprise' }
|
|
||||||
);
|
|
||||||
expect(commit).not.toBeCalledWith(
|
|
||||||
'setPendingCustomAttributes',
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#deleteCustomAttribute', () => {
|
|
||||||
it('removes from pending state when no conversation exists', async () => {
|
|
||||||
const rootGetters = {
|
|
||||||
'conversationAttributes/getConversationParams': { id: '' },
|
|
||||||
};
|
|
||||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
|
||||||
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls API when conversation exists', async () => {
|
|
||||||
API.post.mockResolvedValue({ data: {} });
|
|
||||||
const rootGetters = {
|
|
||||||
'conversationAttributes/getConversationParams': { id: 123 },
|
|
||||||
};
|
|
||||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
|
||||||
expect(commit).not.toBeCalledWith(
|
|
||||||
'removePendingCustomAttribute',
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#clearConversations', () => {
|
describe('#clearConversations', () => {
|
||||||
it('sends correct mutations', () => {
|
it('sends correct mutations', () => {
|
||||||
actions.clearConversations({ commit });
|
actions.clearConversations({ commit });
|
||||||
|
|||||||
@@ -169,77 +169,10 @@ describe('#mutations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('#clearConversations', () => {
|
describe('#clearConversations', () => {
|
||||||
it('clears conversations and pending metadata', () => {
|
it('clears the state', () => {
|
||||||
const state = {
|
const state = { conversations: { 1: { id: 1 } } };
|
||||||
conversations: { 1: { id: 1 } },
|
|
||||||
pendingCustomAttributes: { plan: 'enterprise' },
|
|
||||||
pendingLabels: ['vip'],
|
|
||||||
};
|
|
||||||
mutations.clearConversations(state);
|
mutations.clearConversations(state);
|
||||||
expect(state.conversations).toEqual({});
|
expect(state.conversations).toEqual({});
|
||||||
expect(state.pendingCustomAttributes).toEqual({});
|
|
||||||
expect(state.pendingLabels).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#setPendingCustomAttributes', () => {
|
|
||||||
it('merges custom attributes into pending state', () => {
|
|
||||||
const state = { pendingCustomAttributes: { existing: 'value' } };
|
|
||||||
mutations.setPendingCustomAttributes(state, { plan: 'enterprise' });
|
|
||||||
expect(state.pendingCustomAttributes).toEqual({
|
|
||||||
existing: 'value',
|
|
||||||
plan: 'enterprise',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#setPendingLabels', () => {
|
|
||||||
it('adds label to pending state', () => {
|
|
||||||
const state = { pendingLabels: [] };
|
|
||||||
mutations.setPendingLabels(state, 'vip');
|
|
||||||
expect(state.pendingLabels).toEqual(['vip']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not add duplicate labels', () => {
|
|
||||||
const state = { pendingLabels: ['vip'] };
|
|
||||||
mutations.setPendingLabels(state, 'vip');
|
|
||||||
expect(state.pendingLabels).toEqual(['vip']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#removePendingCustomAttribute', () => {
|
|
||||||
it('removes a single key from pending custom attributes', () => {
|
|
||||||
const state = {
|
|
||||||
pendingCustomAttributes: { plan: 'enterprise', region: 'us' },
|
|
||||||
};
|
|
||||||
mutations.removePendingCustomAttribute(state, 'plan');
|
|
||||||
expect(state.pendingCustomAttributes).toEqual({ region: 'us' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#removePendingLabel', () => {
|
|
||||||
it('removes a label from pending labels', () => {
|
|
||||||
const state = { pendingLabels: ['vip', 'premium'] };
|
|
||||||
mutations.removePendingLabel(state, 'vip');
|
|
||||||
expect(state.pendingLabels).toEqual(['premium']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing if label not present', () => {
|
|
||||||
const state = { pendingLabels: ['vip'] };
|
|
||||||
mutations.removePendingLabel(state, 'premium');
|
|
||||||
expect(state.pendingLabels).toEqual(['vip']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#clearPendingConversationMetadata', () => {
|
|
||||||
it('clears pending custom attributes and labels', () => {
|
|
||||||
const state = {
|
|
||||||
pendingCustomAttributes: { plan: 'enterprise' },
|
|
||||||
pendingLabels: ['vip'],
|
|
||||||
};
|
|
||||||
mutations.clearPendingConversationMetadata(state);
|
|
||||||
expect(state.pendingCustomAttributes).toEqual({});
|
|
||||||
expect(state.pendingLabels).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-n-solid-1 h-full">
|
<div class="bg-white h-full">
|
||||||
<IframeLoader :url="$route.query.link" />
|
<IframeLoader :url="$route.query.link" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
|
||||||
|
queue_as :scheduled_jobs
|
||||||
|
include BillingHelper
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Account.feature_assignment_v2.find_each do |account|
|
||||||
|
if should_skip_auto_assignment?(account)
|
||||||
|
Rails.logger.info("Skipping auto assignment for account #{account.id}")
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
|
||||||
|
process_assignment(inbox)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def process_assignment(inbox)
|
||||||
|
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
|
||||||
|
|
||||||
|
if allowed_agent_ids.blank?
|
||||||
|
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
assign_conversations(inbox, allowed_agent_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_conversations(inbox, allowed_agent_ids)
|
||||||
|
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
|
||||||
|
unassigned_conversations.find_each do |conversation|
|
||||||
|
::AutoAssignment::AgentAssignmentService.new(
|
||||||
|
conversation: conversation,
|
||||||
|
allowed_agent_ids: allowed_agent_ids
|
||||||
|
).perform
|
||||||
|
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_skip_auto_assignment?(account)
|
||||||
|
return false unless ChatwootApp.chatwoot_cloud?
|
||||||
|
|
||||||
|
default_plan?(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,15 +15,6 @@ class AgentBotListener < BaseListener
|
|||||||
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_updated(event)
|
|
||||||
conversation = extract_conversation_and_account(event)[0]
|
|
||||||
changed_attributes = extract_changed_attributes(event)
|
|
||||||
inbox = conversation.inbox
|
|
||||||
event_name = __method__.to_s
|
|
||||||
payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes)
|
|
||||||
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_created(event)
|
def message_created(event)
|
||||||
message = extract_message_and_account(event)[0]
|
message = extract_message_and_account(event)[0]
|
||||||
inbox = message.inbox
|
inbox = message.inbox
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class Attachment < ApplicationRecord
|
|||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :message
|
belongs_to :message
|
||||||
has_one_attached :file
|
has_one_attached :file
|
||||||
before_save :set_extension
|
|
||||||
validate :acceptable_file
|
validate :acceptable_file
|
||||||
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||||
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
||||||
@@ -112,7 +111,6 @@ class Attachment < ApplicationRecord
|
|||||||
def file_metadata
|
def file_metadata
|
||||||
metadata = {
|
metadata = {
|
||||||
extension: extension,
|
extension: extension,
|
||||||
content_type: file.content_type,
|
|
||||||
data_url: file_url,
|
data_url: file_url,
|
||||||
thumb_url: thumb_url,
|
thumb_url: thumb_url,
|
||||||
file_size: file.byte_size,
|
file_size: file.byte_size,
|
||||||
@@ -156,13 +154,6 @@ class Attachment < ApplicationRecord
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_extension
|
|
||||||
return unless file.attached?
|
|
||||||
return if extension.present?
|
|
||||||
|
|
||||||
self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_validate_file?
|
def should_validate_file?
|
||||||
return unless file.attached?
|
return unless file.attached?
|
||||||
# we are only limiting attachment types in case of website widget
|
# we are only limiting attachment types in case of website widget
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ module AccountEmailRateLimitable
|
|||||||
end
|
end
|
||||||
|
|
||||||
def within_email_rate_limit?
|
def within_email_rate_limit?
|
||||||
return true unless ChatwootApp.chatwoot_cloud?
|
|
||||||
return true if emails_sent_today < email_rate_limit
|
return true if emails_sent_today < email_rate_limit
|
||||||
|
|
||||||
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
|
Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}")
|
||||||
|
|||||||
@@ -2,28 +2,24 @@
|
|||||||
#
|
#
|
||||||
# Table name: csat_survey_responses
|
# Table name: csat_survey_responses
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# csat_review_notes :text
|
# feedback_message :text
|
||||||
# feedback_message :text
|
# rating :integer not null
|
||||||
# rating :integer not null
|
# created_at :datetime not null
|
||||||
# review_notes_updated_at :datetime
|
# updated_at :datetime not null
|
||||||
# created_at :datetime not null
|
# account_id :bigint not null
|
||||||
# updated_at :datetime not null
|
# assigned_agent_id :bigint
|
||||||
# account_id :bigint not null
|
# contact_id :bigint not null
|
||||||
# assigned_agent_id :bigint
|
# conversation_id :bigint not null
|
||||||
# contact_id :bigint not null
|
# message_id :bigint not null
|
||||||
# conversation_id :bigint not null
|
|
||||||
# message_id :bigint not null
|
|
||||||
# review_notes_updated_by_id :bigint
|
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_csat_survey_responses_on_account_id (account_id)
|
# index_csat_survey_responses_on_account_id (account_id)
|
||||||
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
|
# index_csat_survey_responses_on_assigned_agent_id (assigned_agent_id)
|
||||||
# index_csat_survey_responses_on_contact_id (contact_id)
|
# index_csat_survey_responses_on_contact_id (contact_id)
|
||||||
# index_csat_survey_responses_on_conversation_id (conversation_id)
|
# index_csat_survey_responses_on_conversation_id (conversation_id)
|
||||||
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
|
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
|
||||||
# index_csat_survey_responses_on_review_notes_updated_by_id (review_notes_updated_by_id)
|
|
||||||
#
|
#
|
||||||
class CsatSurveyResponse < ApplicationRecord
|
class CsatSurveyResponse < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord
|
|||||||
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
|
||||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||||
# Fix configuration in application.rb
|
# Fix configuration in application.rb
|
||||||
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access
|
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||||
|
|
||||||
before_validation :set_lock
|
before_validation :set_lock
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
@@ -33,6 +33,10 @@ class InstallationConfig < ApplicationRecord
|
|||||||
after_commit :clear_cache
|
after_commit :clear_cache
|
||||||
|
|
||||||
def value
|
def value
|
||||||
|
# This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin
|
||||||
|
# It was throwing error as the default value of column '{}' was failing in deserialization.
|
||||||
|
return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}'
|
||||||
|
|
||||||
serialized_value[:value]
|
serialized_value[:value]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class Message < ApplicationRecord
|
|||||||
additional_attributes: additional_attributes,
|
additional_attributes: additional_attributes,
|
||||||
content_attributes: content_attributes,
|
content_attributes: content_attributes,
|
||||||
content_type: content_type,
|
content_type: content_type,
|
||||||
content: webhook_content,
|
content: outgoing_content,
|
||||||
conversation: conversation.webhook_data,
|
conversation: conversation.webhook_data,
|
||||||
created_at: created_at,
|
created_at: created_at,
|
||||||
id: id,
|
id: id,
|
||||||
@@ -195,11 +195,6 @@ class Message < ApplicationRecord
|
|||||||
MessageContentPresenter.new(self).outgoing_content
|
MessageContentPresenter.new(self).outgoing_content
|
||||||
end
|
end
|
||||||
|
|
||||||
# Raw content with survey URL (no markdown rendering) for webhook consumers
|
|
||||||
def webhook_content
|
|
||||||
MessageContentPresenter.new(self).webhook_content
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_notifiable_message?
|
def email_notifiable_message?
|
||||||
return false if private?
|
return false if private?
|
||||||
return false if %w[outgoing template].exclude?(message_type)
|
return false if %w[outgoing template].exclude?(message_type)
|
||||||
|
|||||||
@@ -17,14 +17,13 @@
|
|||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
|
# index_reporting_events_on_account_id (account_id)
|
||||||
# index_reporting_events_on_account_id (account_id)
|
# index_reporting_events_on_conversation_id (conversation_id)
|
||||||
# index_reporting_events_on_conversation_id (conversation_id)
|
# index_reporting_events_on_created_at (created_at)
|
||||||
# index_reporting_events_on_created_at (created_at)
|
# index_reporting_events_on_inbox_id (inbox_id)
|
||||||
# index_reporting_events_on_inbox_id (inbox_id)
|
# index_reporting_events_on_name (name)
|
||||||
# index_reporting_events_on_name (name)
|
# index_reporting_events_on_user_id (user_id)
|
||||||
# index_reporting_events_on_user_id (user_id)
|
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
|
||||||
#
|
#
|
||||||
|
|
||||||
class ReportingEvent < ApplicationRecord
|
class ReportingEvent < ApplicationRecord
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
#
|
#
|
||||||
# Table name: reporting_events_rollups
|
# Table name: reporting_events_rollups
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# count :bigint default(0), not null
|
# count :bigint default(0), not null
|
||||||
# date :date not null
|
# date :date not null
|
||||||
# dimension_type :string not null
|
# dimension_id :bigint not null
|
||||||
# metric :string not null
|
# dimension_type :string not null
|
||||||
# sum_value :float default(0.0), not null
|
# metric :string not null
|
||||||
# sum_value_business_hours :float default(0.0), not null
|
# sum_value :float default(0.0), not null
|
||||||
# created_at :datetime not null
|
# sum_value_business_hours :float default(0.0), not null
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
# account_id :integer not null
|
# updated_at :datetime not null
|
||||||
# dimension_id :bigint not null
|
# account_id :integer not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# name :string
|
# name :string
|
||||||
# secret :string
|
|
||||||
# subscriptions :jsonb
|
# subscriptions :jsonb
|
||||||
# url :text
|
# url :text
|
||||||
# webhook_type :integer default("account_type")
|
# webhook_type :integer default("account_type")
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
class MessageContentPresenter < SimpleDelegator
|
class MessageContentPresenter < SimpleDelegator
|
||||||
def outgoing_content
|
def outgoing_content
|
||||||
|
content_to_send = if should_append_survey_link?
|
||||||
|
survey_link = survey_url(conversation.uuid)
|
||||||
|
custom_message = inbox.csat_config&.dig('message')
|
||||||
|
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
|
||||||
|
else
|
||||||
|
content
|
||||||
|
end
|
||||||
|
|
||||||
Messages::MarkdownRendererService.new(
|
Messages::MarkdownRendererService.new(
|
||||||
content_with_survey_link,
|
content_to_send,
|
||||||
conversation.inbox.channel_type,
|
conversation.inbox.channel_type,
|
||||||
conversation.inbox.channel
|
conversation.inbox.channel
|
||||||
).render
|
).render
|
||||||
end
|
end
|
||||||
|
|
||||||
def webhook_content
|
|
||||||
content_with_survey_link
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def content_with_survey_link
|
|
||||||
if should_append_survey_link?
|
|
||||||
survey_link = survey_url(conversation.uuid)
|
|
||||||
custom_message = inbox.csat_config&.dig('message')
|
|
||||||
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
|
|
||||||
else
|
|
||||||
content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_append_survey_link?
|
def should_append_survey_link?
|
||||||
input_csat? && !inbox.web_widget?
|
input_csat? && !inbox.web_widget?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
module SocialLinkParser
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
SOCIAL_DOMAIN_MAP = {
|
|
||||||
whatsapp: %w[wa.me api.whatsapp.com],
|
|
||||||
line: %w[line.me],
|
|
||||||
facebook: %w[facebook.com fb.com fb.me],
|
|
||||||
instagram: %w[instagram.com],
|
|
||||||
telegram: %w[t.me telegram.me],
|
|
||||||
tiktok: %w[tiktok.com]
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def extract_social_from_links(links)
|
|
||||||
handles = {}
|
|
||||||
SOCIAL_DOMAIN_MAP.each do |platform, domains|
|
|
||||||
handles[platform] = find_social_handle(links, platform, domains)
|
|
||||||
end
|
|
||||||
handles
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_social_handle(links, platform, domains)
|
|
||||||
matching_links = links.select do |l|
|
|
||||||
uri = URI.parse(l)
|
|
||||||
domains.any? { |d| match_social_domain?(uri.host, d) }
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
|
|
||||||
matching_links.each do |link|
|
|
||||||
handle = parse_social_handle(platform, link)
|
|
||||||
return handle if handle.present?
|
|
||||||
end
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def match_social_domain?(host, domain)
|
|
||||||
return false if host.blank?
|
|
||||||
|
|
||||||
host == domain || host.end_with?(".#{domain}")
|
|
||||||
end
|
|
||||||
|
|
||||||
SHARE_PATH_PREFIXES = %w[sharer share intent dialog].freeze
|
|
||||||
|
|
||||||
def parse_social_handle(platform, link)
|
|
||||||
uri = URI.parse(link)
|
|
||||||
return extract_whatsapp_phone(uri) if platform == :whatsapp
|
|
||||||
|
|
||||||
handle = uri.path.to_s.delete_prefix('/').delete_suffix('/')
|
|
||||||
return nil if handle.blank?
|
|
||||||
return nil if SHARE_PATH_PREFIXES.any? { |prefix| handle.start_with?(prefix) }
|
|
||||||
|
|
||||||
handle.presence
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# wa.me/1234567890 or api.whatsapp.com/send?phone=1234567890
|
|
||||||
def extract_whatsapp_phone(uri)
|
|
||||||
phone = CGI.parse(uri.query.to_s)['phone']&.first
|
|
||||||
phone = uri.path.to_s.delete_prefix('/').delete_suffix('/') if phone.blank?
|
|
||||||
phone.presence&.gsub(/[^\d]/, '')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -12,21 +12,16 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
|||||||
# https://apidocs.leadsquared.com/create-or-update/#api
|
# https://apidocs.leadsquared.com/create-or-update/#api
|
||||||
# The email address and phone fields are used as the default search criteria.
|
# The email address and phone fields are used as the default search criteria.
|
||||||
# If none of these match with an existing lead, a new lead will be created.
|
# If none of these match with an existing lead, a new lead will be created.
|
||||||
# We pass the "SearchBy" attribute with value "Phone" when a MXDuplicateEntryException
|
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
|
||||||
# occurs, indicating a duplicate mobile number match that the default search missed.
|
# we don't need this capability at the moment
|
||||||
def create_or_update_lead(lead_data)
|
def create_or_update_lead(lead_data)
|
||||||
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
||||||
|
|
||||||
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
|
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
|
||||||
|
|
||||||
formatted_data = format_lead_data(lead_data)
|
formatted_data = format_lead_data(lead_data)
|
||||||
|
|
||||||
response = post(path, {}, formatted_data)
|
response = post(path, {}, formatted_data)
|
||||||
response['Message']['Id']
|
|
||||||
rescue ApiError => e
|
|
||||||
raise unless duplicate_phone_error?(e) && lead_data.key?('Mobile')
|
|
||||||
|
|
||||||
Rails.logger.warn 'LeadSquared duplicate phone detected, retrying with SearchBy=Phone'
|
|
||||||
response = post(path, {}, formatted_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }])
|
|
||||||
response['Message']['Id']
|
response['Message']['Id']
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,13 +47,4 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def duplicate_phone_error?(error)
|
|
||||||
return false if error.response.blank?
|
|
||||||
|
|
||||||
parsed = error.response.parsed_response
|
|
||||||
parsed.is_a?(Hash) && parsed['ExceptionType'] == 'MXDuplicateEntryException'
|
|
||||||
rescue StandardError
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,15 +44,10 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
|||||||
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
|
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
|
||||||
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
|
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
|
||||||
|
|
||||||
# Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes).
|
|
||||||
# LINE mobile app lazy-loads images and may fetch them well after the message is sent.
|
|
||||||
original_url = attachment.file_url
|
|
||||||
preview_url = attachment.thumb_url.presence || original_url
|
|
||||||
|
|
||||||
{
|
{
|
||||||
type: attachment.file_type,
|
type: attachment.file_type,
|
||||||
originalContentUrl: original_url,
|
originalContentUrl: attachment.download_url,
|
||||||
previewImageUrl: preview_url
|
previewImageUrl: attachment.download_url
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
class Notification::MarkConversationReadService
|
|
||||||
pattr_initialize [:user!, :account!, :conversation!]
|
|
||||||
|
|
||||||
def perform
|
|
||||||
return unless user.is_a?(User)
|
|
||||||
|
|
||||||
notifications.find_each do |notification|
|
|
||||||
notification.update!(read_at: read_at)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def notifications
|
|
||||||
user.notifications.where(
|
|
||||||
account: account,
|
|
||||||
primary_actor: conversation,
|
|
||||||
read_at: nil
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_at
|
|
||||||
@read_at ||= Time.current
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -79,7 +79,7 @@ class Notification::PushNotificationService
|
|||||||
subscription.destroy!
|
subscription.destroy!
|
||||||
when WebPush::TooManyRequests
|
when WebPush::TooManyRequests
|
||||||
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
||||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError
|
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
||||||
Rails.logger.error "WebPush operation error: #{error.message}"
|
Rails.logger.error "WebPush operation error: #{error.message}"
|
||||||
else
|
else
|
||||||
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
class WebsiteBrandingService
|
|
||||||
include SocialLinkParser
|
|
||||||
|
|
||||||
def initialize(url)
|
|
||||||
@url = normalize_url(url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def perform
|
|
||||||
doc = fetch_page
|
|
||||||
return nil if doc.nil?
|
|
||||||
|
|
||||||
links = extract_links(doc)
|
|
||||||
|
|
||||||
{
|
|
||||||
business_name: extract_business_name(doc),
|
|
||||||
language: extract_language(doc),
|
|
||||||
industry_category: nil,
|
|
||||||
social_handles: extract_social_from_links(links),
|
|
||||||
branding: extract_branding(doc)
|
|
||||||
}
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def normalize_url(url)
|
|
||||||
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_page
|
|
||||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
|
||||||
return nil unless response.success?
|
|
||||||
|
|
||||||
Nokogiri::HTML(response.body)
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_business_name(doc)
|
|
||||||
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
|
|
||||||
return og_site_name.strip if og_site_name.present?
|
|
||||||
|
|
||||||
title = doc.at_xpath('//title')&.text
|
|
||||||
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_language(doc)
|
|
||||||
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_links(doc)
|
|
||||||
doc.css('a[href]').filter_map do |a|
|
|
||||||
href = a['href']&.strip
|
|
||||||
next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
|
|
||||||
|
|
||||||
href.start_with?('http') ? href : URI.join(@url, href).to_s
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end.uniq
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_branding(doc)
|
|
||||||
{
|
|
||||||
favicon: extract_favicon(doc),
|
|
||||||
primary_color: extract_theme_color(doc)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_favicon(doc)
|
|
||||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
|
||||||
return nil if favicon.blank?
|
|
||||||
|
|
||||||
resolve_url(favicon)
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_theme_color(doc)
|
|
||||||
doc.at_css('meta[name="theme-color"]')&.[]('content')
|
|
||||||
end
|
|
||||||
|
|
||||||
def resolve_url(url)
|
|
||||||
return nil if url.blank?
|
|
||||||
return url if url.start_with?('http')
|
|
||||||
|
|
||||||
URI.join(@url, url).to_s
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
|
|
||||||
@@ -21,10 +21,7 @@ class Whatsapp::EmbeddedSignupService
|
|||||||
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
|
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
|
||||||
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
||||||
channel.setup_webhooks
|
channel.setup_webhooks
|
||||||
# Skip health check during reauthorization — phone numbers in pending provisioning state
|
check_channel_health_and_prompt_reauth(channel)
|
||||||
# (platform_type: NOT_APPLICABLE) would incorrectly trigger a disconnect email right after
|
|
||||||
# a successful reauth. Only run health check for new channel creation.
|
|
||||||
check_channel_health_and_prompt_reauth(channel) if @inbox_id.blank?
|
|
||||||
channel
|
channel
|
||||||
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ By default, it renders:
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white dark:bg-slate-900">
|
<body>
|
||||||
<div id="portal" class="antialiased">
|
<div id="portal" class="antialiased">
|
||||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||||
<% if !@is_plain_layout_enabled %>
|
<% if !@is_plain_layout_enabled %>
|
||||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
|
|
||||||
<span class="text-5xl font-medium">i</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.not_active.title') %></h1>
|
|
||||||
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.not_active.description') %></p>
|
|
||||||
<div class="text-center my-8">
|
|
||||||
<p class="text-slate-600 dark:text-slate-400 text-sm"><%= I18n.t('public_portal.not_active.action') %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
shared: &shared
|
shared: &shared
|
||||||
version: '4.12.1'
|
version: '4.12.0'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ module Chatwoot
|
|||||||
# Disable PDF/video preview generation as we don't use them
|
# Disable PDF/video preview generation as we don't use them
|
||||||
config.active_storage.previewers = []
|
config.active_storage.previewers = []
|
||||||
|
|
||||||
|
# Restrict content types served inline to prevent XSS via uploaded files (e.g. PDFs with embedded JavaScript).
|
||||||
|
config.active_storage.content_types_allowed_inline = %w[
|
||||||
|
image/png image/gif image/jpeg image/tiff image/vnd.adobe.photoshop
|
||||||
|
image/vnd.microsoft.icon image/webp image/avif image/heic image/heif
|
||||||
|
]
|
||||||
|
|
||||||
# Active Record Encryption configuration
|
# Active Record Encryption configuration
|
||||||
# Required for MFA/2FA features - skip if not using encryption
|
# Required for MFA/2FA features - skip if not using encryption
|
||||||
if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
|
if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
|
||||||
|
|||||||
+3
-3
@@ -104,10 +104,10 @@
|
|||||||
display_name: Audit Logs
|
display_name: Audit Logs
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
premium: true
|
||||||
- name: custom_tools
|
- name: response_bot
|
||||||
display_name: Custom Tools
|
display_name: Response Bot
|
||||||
enabled: false
|
enabled: false
|
||||||
premium: true
|
deprecated: true
|
||||||
- name: message_reply_to
|
- name: message_reply_to
|
||||||
display_name: Message Reply To
|
display_name: Message Reply To
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ LANGUAGES_CONFIG = {
|
|||||||
37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true },
|
37 => { name: 'עִברִית (he)', iso_639_3_code: 'heb', iso_639_1_code: 'he', enabled: true },
|
||||||
38 => { name: 'lietuvių (lt)', iso_639_3_code: 'lit', iso_639_1_code: 'lt', enabled: true },
|
38 => { name: 'lietuvių (lt)', iso_639_3_code: 'lit', iso_639_1_code: 'lt', enabled: true },
|
||||||
39 => { name: 'Српски (sr)', iso_639_3_code: 'srp', iso_639_1_code: 'sr', enabled: true },
|
39 => { name: 'Српски (sr)', iso_639_3_code: 'srp', iso_639_1_code: 'sr', enabled: true },
|
||||||
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true },
|
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', enabled: true }
|
||||||
41 => { name: 'Eesti keel (et)', iso_639_3_code: 'est', iso_639_1_code: 'et', enabled: true }
|
|
||||||
}.filter { |_key, val| val[:enabled] }.freeze
|
}.filter { |_key, val| val[:enabled] }.freeze
|
||||||
|
|
||||||
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }
|
Rails.configuration.i18n.available_locales = LANGUAGES_CONFIG.map { |_index, lang| lang[:iso_639_1_code].to_sym }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present?
|
|||||||
# We recommend adjusting the value in production:
|
# We recommend adjusting the value in production:
|
||||||
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
|
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
|
||||||
|
|
||||||
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
|
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
|
||||||
|
|
||||||
# to track post data in sentry
|
# to track post data in sentry
|
||||||
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
|
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
|
||||||
|
|||||||
@@ -34,18 +34,7 @@ end
|
|||||||
|
|
||||||
# https://github.com/ondrejbartas/sidekiq-cron
|
# https://github.com/ondrejbartas/sidekiq-cron
|
||||||
Rails.application.reloader.to_prepare do
|
Rails.application.reloader.to_prepare do
|
||||||
# load_from_hash! upserts jobs from the YAML and removes any Redis-persisted
|
# TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a
|
||||||
# jobs that share the same source tag but are no longer in the file.
|
# safe cleanup path for YAML-backed cron jobs already persisted in Redis.
|
||||||
# This ensures deleted schedule entries are cleaned up on deploy.
|
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
|
||||||
if File.exist?(schedule_file) && Sidekiq.server?
|
|
||||||
schedule = YAML.load_file(schedule_file)
|
|
||||||
|
|
||||||
# Cron entries removed from schedule.yml but possibly still in Redis
|
|
||||||
# with source:'dynamic' (predating the source tag). load_from_hash!
|
|
||||||
# only cleans up source:'schedule' entries, so these need explicit removal.
|
|
||||||
# Remove names from this list once they've been through a deploy cycle.
|
|
||||||
%w[bulk_auto_assignment_job].each { |name| Sidekiq::Cron::Job.destroy(name) }
|
|
||||||
|
|
||||||
Sidekiq::Cron::Job.load_from_hash!(schedule, source: 'schedule')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ dialogflow:
|
|||||||
'project_id': { 'type': 'string' },
|
'project_id': { 'type': 'string' },
|
||||||
'credentials': { 'type': 'object' },
|
'credentials': { 'type': 'object' },
|
||||||
'region': { 'type': 'string' },
|
'region': { 'type': 'string' },
|
||||||
'language_code': { 'type': 'string' },
|
|
||||||
},
|
},
|
||||||
'required': ['project_id', 'credentials'],
|
'required': ['project_id', 'credentials'],
|
||||||
'additionalProperties': false,
|
'additionalProperties': false,
|
||||||
@@ -127,40 +126,8 @@ dialogflow:
|
|||||||
{ 'label': 'EU-W2 - London, England', 'value': 'europe-west2' },
|
{ 'label': 'EU-W2 - London, England', 'value': 'europe-west2' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'label': 'Language Code',
|
|
||||||
'type': 'select',
|
|
||||||
'name': 'language_code',
|
|
||||||
'default': 'en-US',
|
|
||||||
'help': 'Language code for Dialogflow agent. Use "auto" to detect from contact language.',
|
|
||||||
'options': [
|
|
||||||
{ 'label': 'Auto-detect from contact', 'value': 'auto' },
|
|
||||||
{ 'label': 'English (US)', 'value': 'en-US' },
|
|
||||||
{ 'label': 'English (UK)', 'value': 'en-GB' },
|
|
||||||
{ 'label': 'Spanish (Spain)', 'value': 'es-ES' },
|
|
||||||
{ 'label': 'Spanish (Latin America)', 'value': 'es-419' },
|
|
||||||
{ 'label': 'French', 'value': 'fr-FR' },
|
|
||||||
{ 'label': 'German', 'value': 'de-DE' },
|
|
||||||
{ 'label': 'Portuguese (Brazil)', 'value': 'pt-BR' },
|
|
||||||
{ 'label': 'Portuguese (Portugal)', 'value': 'pt-PT' },
|
|
||||||
{ 'label': 'Italian', 'value': 'it-IT' },
|
|
||||||
{ 'label': 'Japanese', 'value': 'ja-JP' },
|
|
||||||
{ 'label': 'Korean', 'value': 'ko-KR' },
|
|
||||||
{ 'label': 'Chinese (Simplified)', 'value': 'zh-CN' },
|
|
||||||
{ 'label': 'Chinese (Traditional)', 'value': 'zh-TW' },
|
|
||||||
{ 'label': 'Hindi', 'value': 'hi-IN' },
|
|
||||||
{ 'label': 'Arabic', 'value': 'ar' },
|
|
||||||
{ 'label': 'Russian', 'value': 'ru-RU' },
|
|
||||||
{ 'label': 'Dutch', 'value': 'nl-NL' },
|
|
||||||
{ 'label': 'Polish', 'value': 'pl-PL' },
|
|
||||||
{ 'label': 'Turkish', 'value': 'tr-TR' },
|
|
||||||
{ 'label': 'Thai', 'value': 'th-TH' },
|
|
||||||
{ 'label': 'Vietnamese', 'value': 'vi-VN' },
|
|
||||||
{ 'label': 'Indonesian', 'value': 'id-ID' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
visible_properties: ['project_id', 'region', 'language_code']
|
visible_properties: ['project_id', 'region']
|
||||||
google_translate:
|
google_translate:
|
||||||
id: google_translate
|
id: google_translate
|
||||||
logo: google-translate.png
|
logo: google-translate.png
|
||||||
|
|||||||
@@ -387,7 +387,6 @@ en:
|
|||||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||||
custom_tool:
|
custom_tool:
|
||||||
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
||||||
limit_exceeded: 'You can create a maximum of %{limit} custom tools per account'
|
|
||||||
public_portal:
|
public_portal:
|
||||||
search:
|
search:
|
||||||
search_placeholder: Search for article by title or body...
|
search_placeholder: Search for article by title or body...
|
||||||
@@ -424,10 +423,6 @@ en:
|
|||||||
title: Page not found
|
title: Page not found
|
||||||
description: We couldn't find the page you were looking for.
|
description: We couldn't find the page you were looking for.
|
||||||
back_to_home: Go to home page
|
back_to_home: Go to home page
|
||||||
not_active:
|
|
||||||
title: Help Center Unavailable
|
|
||||||
description: Please contact the site administrator for more information.
|
|
||||||
action: If you are the administrator, please upgrade your plan to restore access.
|
|
||||||
slack_unfurl:
|
slack_unfurl:
|
||||||
fields:
|
fields:
|
||||||
name: Name
|
name: Name
|
||||||
|
|||||||
@@ -134,18 +134,6 @@ codepen:
|
|||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
guidejar:
|
|
||||||
regex: 'https?://(?:www\.)?guidejar\.com/(?:embed|guides)/(?<guide_id>[^&/?]+)'
|
|
||||||
template: |
|
|
||||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
|
||||||
<iframe
|
|
||||||
src="https://www.guidejar.com/embed/%{guide_id}?type=1&controls=on"
|
|
||||||
frameborder="0"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
github_gist:
|
github_gist:
|
||||||
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
|
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
|
||||||
template: |
|
template: |
|
||||||
|
|||||||
+1
-4
@@ -72,9 +72,7 @@ Rails.application.routes.draw do
|
|||||||
resources :copilot_threads, only: [:index, :create] do
|
resources :copilot_threads, only: [:index, :create] do
|
||||||
resources :copilot_messages, only: [:index, :create]
|
resources :copilot_messages, only: [:index, :create]
|
||||||
end
|
end
|
||||||
resources :custom_tools do
|
resources :custom_tools
|
||||||
post :test, on: :collection
|
|
||||||
end
|
|
||||||
resources :documents, only: [:index, :show, :create, :destroy]
|
resources :documents, only: [:index, :show, :create, :destroy]
|
||||||
resource :tasks, only: [], controller: 'tasks' do
|
resource :tasks, only: [], controller: 'tasks' do
|
||||||
post :rewrite
|
post :rewrite
|
||||||
@@ -509,7 +507,6 @@ Rails.application.routes.draw do
|
|||||||
delete :destroy
|
delete :destroy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :email_channel_migrations, only: [:create]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
# executed daily at 0000 UTC
|
# executed daily at 0000 UTC
|
||||||
# schedules daily deferred jobs at stable times for each installation
|
# schedules daily deferred jobs at stable times for each installation
|
||||||
|
# keep the existing schedule key while the cron loader still uses load_from_hash
|
||||||
internal_check_new_versions_job:
|
internal_check_new_versions_job:
|
||||||
cron: '0 0 * * *'
|
cron: '0 0 * * *'
|
||||||
class: 'Internal::TriggerDailyScheduledItemsJob'
|
class: 'Internal::TriggerDailyScheduledItemsJob'
|
||||||
@@ -49,6 +50,13 @@ delete_accounts_job:
|
|||||||
class: 'Internal::DeleteAccountsJob'
|
class: 'Internal::DeleteAccountsJob'
|
||||||
queue: scheduled_jobs
|
queue: scheduled_jobs
|
||||||
|
|
||||||
|
# executed every 15 minutes
|
||||||
|
# to assign unassigned conversations for all inboxes
|
||||||
|
bulk_auto_assignment_job:
|
||||||
|
cron: '*/15 * * * *'
|
||||||
|
class: 'Inboxes::BulkAutoAssignmentJob'
|
||||||
|
queue: scheduled_jobs
|
||||||
|
|
||||||
# executed every 30 minutes for assignment_v2
|
# executed every 30 minutes for assignment_v2
|
||||||
periodic_assignment_job:
|
periodic_assignment_job:
|
||||||
cron: '*/30 * * * *'
|
cron: '*/30 * * * *'
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1]
|
|
||||||
def up
|
|
||||||
# The response_bot flag (deprecated) has been renamed to custom_tools.
|
|
||||||
# Disable it on any accounts that had response_bot enabled so the repurposed
|
|
||||||
# flag starts in its intended default-off state.
|
|
||||||
Account.feature_custom_tools.find_each(batch_size: 100) do |account|
|
|
||||||
account.disable_features(:custom_tools)
|
|
||||||
account.save!(validate: false)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
|
||||||
# ConfigLoader only adds new flags; it never removes renamed ones.
|
|
||||||
# Leaving it would cause NoMethodError in enable_default_features when
|
|
||||||
# creating new accounts (feature_response_bot= no longer exists).
|
|
||||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
|
||||||
return if config&.value.blank?
|
|
||||||
|
|
||||||
config.value = config.value.reject { |f| f['name'] == 'response_bot' }
|
|
||||||
config.save!
|
|
||||||
GlobalConfig.clear_cache
|
|
||||||
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: 2026_03_24_102005) do
|
ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) 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"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
|||||||
before_action :validate_params
|
before_action :validate_params
|
||||||
before_action :type_matches?
|
before_action :type_matches?
|
||||||
|
|
||||||
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
|
MODEL_TYPE = ['AssistantResponse'].freeze
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@responses = process_bulk_action
|
@responses = process_bulk_action
|
||||||
@@ -28,8 +28,6 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
|||||||
case params[:type]
|
case params[:type]
|
||||||
when 'AssistantResponse'
|
when 'AssistantResponse'
|
||||||
handle_assistant_responses
|
handle_assistant_responses
|
||||||
when 'AssistantDocument'
|
|
||||||
handle_documents
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -47,16 +45,6 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_documents
|
|
||||||
return [] unless params[:fields][:status] == 'delete'
|
|
||||||
|
|
||||||
documents = Current.account.captain_documents.where(id: params[:ids])
|
|
||||||
return [] unless documents.exists?
|
|
||||||
|
|
||||||
documents.destroy_all
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:type, ids: [], fields: [:status])
|
params.permit(:type, ids: [], fields: [:status])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||||
before_action :current_account
|
before_action :current_account
|
||||||
before_action :ensure_custom_tools_enabled
|
|
||||||
before_action -> { check_authorization(Captain::CustomTool) }
|
before_action -> { check_authorization(Captain::CustomTool) }
|
||||||
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@custom_tools = account_custom_tools
|
@custom_tools = account_custom_tools.enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
||||||
rescue Captain::CustomTool::LimitExceededError => e
|
|
||||||
render_could_not_create_error(e.message)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -25,22 +22,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
|||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
def test
|
|
||||||
tool = account_custom_tools.new(custom_tool_params)
|
|
||||||
result = execute_test_request(tool)
|
|
||||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
|
||||||
rescue StandardError => e
|
|
||||||
render json: { error: e.message }, status: :unprocessable_content
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_custom_tools_enabled
|
|
||||||
return if Current.account.feature_enabled?('custom_tools') || Current.account.feature_enabled?('captain_integration_v2')
|
|
||||||
|
|
||||||
render json: { error: 'Custom tools are not enabled for this account' }, status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_custom_tool
|
def set_custom_tool
|
||||||
@custom_tool = account_custom_tools.find(params[:id])
|
@custom_tool = account_custom_tools.find(params[:id])
|
||||||
end
|
end
|
||||||
@@ -49,11 +32,6 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
|||||||
@account_custom_tools ||= Current.account.captain_custom_tools
|
@account_custom_tools ||= Current.account.captain_custom_tools
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute_test_request(tool)
|
|
||||||
http_tool = Captain::Tools::HttpTool.new(nil, tool)
|
|
||||||
http_tool.send(:execute_http_request, tool.endpoint_url, nil, nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def custom_tool_params
|
def custom_tool_params
|
||||||
params.require(:custom_tool).permit(
|
params.require(:custom_tool).permit(
|
||||||
:title,
|
:title,
|
||||||
|
|||||||
@@ -24,10 +24,6 @@
|
|||||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||||
#
|
#
|
||||||
class Captain::CustomTool < ApplicationRecord
|
class Captain::CustomTool < ApplicationRecord
|
||||||
class LimitExceededError < StandardError; end
|
|
||||||
|
|
||||||
MAX_PER_ACCOUNT = 15
|
|
||||||
|
|
||||||
include Concerns::Toolable
|
include Concerns::Toolable
|
||||||
include Concerns::SafeEndpointValidatable
|
include Concerns::SafeEndpointValidatable
|
||||||
|
|
||||||
@@ -35,10 +31,6 @@ class Captain::CustomTool < ApplicationRecord
|
|||||||
|
|
||||||
NAME_PREFIX = 'custom'.freeze
|
NAME_PREFIX = 'custom'.freeze
|
||||||
NAME_SEPARATOR = '_'.freeze
|
NAME_SEPARATOR = '_'.freeze
|
||||||
# OpenAI enforces a 64-char limit on function names. The slug is used
|
|
||||||
# verbatim as the tool name in LLM requests, so it must fit within this limit.
|
|
||||||
MAX_SLUG_LENGTH = 64
|
|
||||||
COLLISION_SUFFIX_LENGTH = 7 # "_" + 6 random alphanumeric chars
|
|
||||||
PARAM_SCHEMA_VALIDATION = {
|
PARAM_SCHEMA_VALIDATION = {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {
|
'items': {
|
||||||
@@ -60,9 +52,8 @@ class Captain::CustomTool < ApplicationRecord
|
|||||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||||
|
|
||||||
before_validation :generate_slug
|
before_validation :generate_slug
|
||||||
before_create :ensure_within_limit
|
|
||||||
|
|
||||||
validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
|
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||||
validates :title, presence: true
|
validates :title, presence: true
|
||||||
validates :endpoint_url, presence: true
|
validates :endpoint_url, presence: true
|
||||||
validates_with JsonSchemaValidator,
|
validates_with JsonSchemaValidator,
|
||||||
@@ -82,29 +73,21 @@ class Captain::CustomTool < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_within_limit
|
|
||||||
# Lock the account row to serialize concurrent creates and prevent exceeding the cap
|
|
||||||
Account.lock.find(account_id)
|
|
||||||
return if account.captain_custom_tools.count < MAX_PER_ACCOUNT
|
|
||||||
|
|
||||||
raise LimitExceededError, I18n.t('captain.custom_tool.limit_exceeded', limit: MAX_PER_ACCOUNT)
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_slug
|
def generate_slug
|
||||||
return if slug.present?
|
return if slug.present?
|
||||||
return if title.blank?
|
return if title.blank?
|
||||||
|
|
||||||
parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
|
|
||||||
|
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||||
self.slug = find_unique_slug(base_slug)
|
self.slug = find_unique_slug(base_slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_unique_slug(base_slug)
|
def find_unique_slug(base_slug)
|
||||||
return base_slug unless slug_exists?(base_slug)
|
return base_slug unless slug_exists?(base_slug)
|
||||||
|
|
||||||
truncated = base_slug.truncate(MAX_SLUG_LENGTH - COLLISION_SUFFIX_LENGTH, omission: '')
|
|
||||||
5.times do
|
5.times do
|
||||||
slug_candidate = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||||
return slug_candidate unless slug_exists?(slug_candidate)
|
return slug_candidate unless slug_exists?(slug_candidate)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
module Concerns::Toolable
|
module Concerns::Toolable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
# Isolated namespace for user-defined custom tool classes.
|
def tool(assistant)
|
||||||
# Keeps them separate from built-in classes in Captain::Tools (e.g., HttpTool, CustomHttpTool).
|
|
||||||
module CustomTools; end
|
|
||||||
|
|
||||||
def tool(assistant, base_class: Captain::Tools::HttpTool, **)
|
|
||||||
custom_tool_record = self
|
custom_tool_record = self
|
||||||
|
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||||
class_name = custom_tool_record.slug.underscore.camelize
|
class_name = custom_tool_record.slug.underscore.camelize
|
||||||
|
|
||||||
# Always create a fresh class to reflect current metadata
|
# Always create a fresh class to reflect current metadata
|
||||||
tool_slug = custom_tool_record.slug
|
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||||
tool_class = Class.new(base_class) do
|
|
||||||
description custom_tool_record.description
|
description custom_tool_record.description
|
||||||
|
|
||||||
# Override name to use the slug directly, avoiding the namespace prefix
|
|
||||||
# that RubyLLM's default normalization would produce (e.g., "captain--tools--custom_dog_facts").
|
|
||||||
define_method(:name) { tool_slug }
|
|
||||||
|
|
||||||
custom_tool_record.param_schema.each do |param_def|
|
custom_tool_record.param_schema.each do |param_def|
|
||||||
param param_def['name'].to_sym,
|
param param_def['name'].to_sym,
|
||||||
type: param_def['type'],
|
type: param_def['type'],
|
||||||
@@ -26,14 +18,17 @@ module Concerns::Toolable
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Register as a constant so the class gets a proper name (Class#name).
|
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||||
# Anonymous classes return nil for #name, which causes "Invalid 'tools[].function.name':
|
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||||
# empty string" errors from the LLM API. We use CustomTools as the namespace to avoid
|
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||||
# collisions with real classes in Captain::Tools.
|
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||||
CustomTools.send(:remove_const, class_name) if CustomTools.const_defined?(class_name, false)
|
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||||
CustomTools.const_set(class_name, tool_class)
|
# which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
|
||||||
|
# We refresh the constant on each call to ensure tool metadata changes are reflected.
|
||||||
|
Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
|
||||||
|
Captain::Tools.const_set(class_name, tool_class)
|
||||||
|
|
||||||
tool_class.new(assistant, self, **)
|
tool_class.new(assistant, self)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_request_url(params)
|
def build_request_url(params)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
module Enterprise::Inbox
|
module Enterprise::Inbox
|
||||||
def member_ids_with_assignment_capacity
|
def member_ids_with_assignment_capacity
|
||||||
return super unless enable_auto_assignment?
|
return super unless enable_auto_assignment?
|
||||||
return filter_by_capacity(available_agents).map(&:user_id) if auto_assignment_v2_enabled?
|
|
||||||
|
|
||||||
max_assignment_limit = auto_assignment_config['max_assignment_limit']
|
max_assignment_limit = auto_assignment_config['max_assignment_limit']
|
||||||
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ module Enterprise::InboxAgentAvailability
|
|||||||
end
|
end
|
||||||
|
|
||||||
def capacity_filtering_enabled?
|
def capacity_filtering_enabled?
|
||||||
account.feature_enabled?('advanced_assignment') &&
|
account.feature_enabled?('assignment_v2') &&
|
||||||
account.account_users.joins(:agent_capacity_policy).exists?
|
account.account_users.joins(:agent_capacity_policy).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ class Captain::CustomToolPolicy < ApplicationPolicy
|
|||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
end
|
end
|
||||||
|
|
||||||
def test?
|
|
||||||
@account_user.administrator?
|
|
||||||
end
|
|
||||||
|
|
||||||
def update?
|
def update?
|
||||||
@account_user.administrator?
|
@account_user.administrator?
|
||||||
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