Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a3c551bc | ||
|
|
579bce3ee4 | ||
|
|
0126d8c668 | ||
|
|
b3d0af84c4 | ||
|
|
4ba3d18be0 | ||
|
|
27a7e4fa0b | ||
|
|
d83beb2148 | ||
|
|
8daf6cf6cb | ||
|
|
287a75b524 | ||
|
|
40d00d895d | ||
|
|
211fb1102d | ||
|
|
7b09b033ef | ||
|
|
65867b8b36 | ||
|
|
4cce7f6ad8 | ||
|
|
f2cb23d6e9 | ||
|
|
8824efe0e1 | ||
|
|
5de7ae492c | ||
|
|
b4b5de9b46 | ||
|
|
1987ac3d97 | ||
|
|
0012fa2c35 | ||
|
|
b4ce59eea8 | ||
|
|
42441dbd28 | ||
|
|
b9f824b43b | ||
|
|
7651c18b48 | ||
|
|
04acc16609 | ||
|
|
44a7a13117 | ||
|
|
9efd554693 | ||
|
|
2b296c06fb | ||
|
|
4381be5f3e | ||
|
|
127ac0a6b2 | ||
|
|
5d9d754961 | ||
|
|
cac7438fff | ||
|
|
0b41d7f483 | ||
|
|
4517c50227 | ||
|
|
4c4b70da25 | ||
|
|
d84ef4cfd6 | ||
|
|
23786bcb52 | ||
|
|
e4c3f0ac2f | ||
|
|
742c5cc1f4 | ||
|
|
d9e732c005 | ||
|
|
e0e321b8e2 | ||
|
|
ecc66e064d | ||
|
|
7144d55334 | ||
|
|
250650dd7a | ||
|
|
608be1036b | ||
|
|
6ff643b045 |
@@ -0,0 +1,65 @@
|
||||
---
|
||||
: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,3 +2,8 @@
|
||||
ignore:
|
||||
- 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+)
|
||||
# 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.0
|
||||
4.12.1
|
||||
|
||||
@@ -116,6 +116,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# 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.
|
||||
# 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, false) if !assignee? && @conversation.unread_messages.any?
|
||||
|
||||
|
||||
@@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = create_conversation if conversation.nil?
|
||||
return unless 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
|
||||
|
||||
def message_finder_params
|
||||
@@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||
# 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],
|
||||
custom_attributes: {},
|
||||
labels: []
|
||||
)
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
private
|
||||
|
||||
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?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
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?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
create_account_for_user
|
||||
set_random_password_if_oauth_user
|
||||
token = @resource.send(:set_reset_password_token)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||
@@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||
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
|
||||
'user'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :portal
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
def redirect_to_portal_with_locale
|
||||
return if params[:locale].present?
|
||||
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
|
||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||
}, status: :unauthorized and return
|
||||
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
|
||||
|
||||
@@ -98,7 +98,9 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
@@ -106,6 +106,10 @@ select {
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
option:not(:disabled) {
|
||||
@apply bg-n-solid-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
+51
-195
@@ -1,207 +1,63 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
defineProps({
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
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>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
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-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>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: tooltipContent,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -34,14 +35,34 @@ const props = defineProps({
|
||||
type: Number,
|
||||
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']);
|
||||
const emit = defineEmits(['action', 'select', 'hover']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
|
||||
+11
-5
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
const i18nTypeMap = {
|
||||
AssistantResponse: 'RESPONSES',
|
||||
AssistantDocument: 'DOCUMENTS',
|
||||
};
|
||||
return i18nTypeMap[props.type];
|
||||
});
|
||||
|
||||
const handleBulkDelete = async ids => {
|
||||
if (!ids) return;
|
||||
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkDelete',
|
||||
Array.from(props.bulkIds)
|
||||
);
|
||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||
ids: Array.from(props.bulkIds),
|
||||
type: props.type,
|
||||
});
|
||||
|
||||
emit('deleteSuccess');
|
||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||
|
||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+80
-6
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
||||
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 = {
|
||||
title: { required },
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
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(() => ({
|
||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+12
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
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';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<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
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -63,6 +63,16 @@ 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 => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -364,14 +374,18 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
@@ -305,7 +305,7 @@ const deleteConversation = () => {
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
@@ -317,7 +317,10 @@ const deleteConversation = () => {
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<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,6 +253,9 @@ export default {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
||||
}
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
@@ -545,7 +548,10 @@ export default {
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
@@ -603,7 +609,7 @@ export default {
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
key,
|
||||
|
||||
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
|
||||
@@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = {
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
const CJK_CHAR_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
if (CJK_CHAR_RE.test(local)) {
|
||||
const re = new RegExp(escapeRegex(local), 'g');
|
||||
r = r.replace(re, ` ${en} `);
|
||||
} else {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
}
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -82,6 +82,9 @@ 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 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(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
@@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${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(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
@@ -245,6 +251,7 @@ const matchDuration = (text, now) => {
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
@@ -303,6 +310,13 @@ 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);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
|
||||
@@ -1626,6 +1626,24 @@ 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', () => {
|
||||
it('Arabic "غد" does not produce half-duration suggestions', () => {
|
||||
const results = generateDateSuggestions('غد', now, {
|
||||
@@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -738,6 +738,17 @@
|
||||
"DOCUMENTS": {
|
||||
"HEADER": "Documents",
|
||||
"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": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
@@ -796,6 +807,7 @@
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -826,11 +838,18 @@
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"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": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"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": {
|
||||
"LABEL": "Description",
|
||||
|
||||
@@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json';
|
||||
import settings from './settings.json';
|
||||
import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
@@ -74,6 +75,7 @@ export default {
|
||||
...settings,
|
||||
...signup,
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
};
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minute",
|
||||
"MINUTES": "minutes",
|
||||
"HOUR": "hour",
|
||||
"MINUTE": "分鐘",
|
||||
"MINUTES": "分鐘",
|
||||
"HOUR": "小時",
|
||||
"HOURS": "小時",
|
||||
"DAY": "day",
|
||||
"DAYS": "days",
|
||||
"WEEK": "week",
|
||||
"WEEKS": "weeks",
|
||||
"MONTH": "month",
|
||||
"MONTHS": "months",
|
||||
"YEAR": "month",
|
||||
"YEARS": "years"
|
||||
"DAY": "天",
|
||||
"DAYS": "天",
|
||||
"WEEK": "週",
|
||||
"WEEKS": "週",
|
||||
"MONTH": "月",
|
||||
"MONTHS": "月",
|
||||
"YEAR": "年",
|
||||
"YEARS": "年"
|
||||
},
|
||||
"HALF": "half",
|
||||
"NEXT": "next",
|
||||
"THIS": "this",
|
||||
"AT": "at",
|
||||
"IN": "in",
|
||||
"FROM_NOW": "from now",
|
||||
"NEXT_YEAR": "next year",
|
||||
"HALF": "半",
|
||||
"NEXT": "下一個",
|
||||
"THIS": "這個",
|
||||
"AT": "在",
|
||||
"IN": "在",
|
||||
"FROM_NOW": "之後",
|
||||
"NEXT_YEAR": "明年",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
"AM": "上午",
|
||||
"PM": "下午"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "明天",
|
||||
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||
"DAY_AFTER_TOMORROW": "後天",
|
||||
"NEXT_WEEK": "下週",
|
||||
"NEXT_MONTH": "next month",
|
||||
"THIS_WEEKEND": "this weekend",
|
||||
"NEXT_WEEKEND": "next weekend"
|
||||
"NEXT_MONTH": "下個月",
|
||||
"THIS_WEEKEND": "這個週末",
|
||||
"NEXT_WEEKEND": "下個週末"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "morning",
|
||||
"AFTERNOON": "afternoon",
|
||||
"EVENING": "evening",
|
||||
"NIGHT": "night",
|
||||
"NOON": "noon",
|
||||
"MIDNIGHT": "midnight"
|
||||
"MORNING": "早上",
|
||||
"AFTERNOON": "下午",
|
||||
"EVENING": "晚上",
|
||||
"NIGHT": "夜晚",
|
||||
"NOON": "中午",
|
||||
"MIDNIGHT": "午夜"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "one",
|
||||
"TWO": "two",
|
||||
"THREE": "three",
|
||||
"FOUR": "four",
|
||||
"FIVE": "five",
|
||||
"SIX": "six",
|
||||
"SEVEN": "seven",
|
||||
"EIGHT": "eight",
|
||||
"NINE": "nine",
|
||||
"TEN": "ten",
|
||||
"TWELVE": "twelve",
|
||||
"FIFTEEN": "fifteen",
|
||||
"TWENTY": "twenty",
|
||||
"THIRTY": "thirty"
|
||||
"ONE": "一",
|
||||
"TWO": "二",
|
||||
"THREE": "三",
|
||||
"FOUR": "四",
|
||||
"FIVE": "五",
|
||||
"SIX": "六",
|
||||
"SEVEN": "七",
|
||||
"EIGHT": "八",
|
||||
"NINE": "九",
|
||||
"TEN": "十",
|
||||
"TWELVE": "十二",
|
||||
"FIFTEEN": "十五",
|
||||
"TWENTY": "二十",
|
||||
"THIRTY": "三十"
|
||||
},
|
||||
"ORDINALS": {
|
||||
"FIRST": "first",
|
||||
"SECOND": "second",
|
||||
"THIRD": "third",
|
||||
"FOURTH": "fourth",
|
||||
"FIFTH": "fifth"
|
||||
"FIRST": "第一",
|
||||
"SECOND": "第二",
|
||||
"THIRD": "第三",
|
||||
"FOURTH": "第四",
|
||||
"FIFTH": "第五"
|
||||
},
|
||||
"OF": "of",
|
||||
"AFTER": "after",
|
||||
"WEEK": "week",
|
||||
"DAY": "day"
|
||||
"OF": "的",
|
||||
"AFTER": "後",
|
||||
"WEEK": "週",
|
||||
"DAY": "天"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta: metaV2,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.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 CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
@@ -14,9 +18,12 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
|
||||
@@ -25,9 +32,13 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const documentsMeta = useMapGetter('captainDocuments/getMeta');
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
const canManageDocuments = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedDocument = ref(null);
|
||||
const deleteDocumentDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDocumentDialog.value.dialogRef.open();
|
||||
@@ -78,7 +89,14 @@ const fetchDocuments = (page = 1) => {
|
||||
store.dispatch('captainDocuments/get', filterParams);
|
||||
};
|
||||
|
||||
const onPageChange = page => fetchDocuments(page);
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
fetchDocuments(page);
|
||||
|
||||
if (hadSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
|
||||
@@ -86,6 +104,58 @@ 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(() => {
|
||||
fetchDocuments();
|
||||
});
|
||||
@@ -106,6 +176,21 @@ onMounted(() => {
|
||||
@update:current-page="onPageChange"
|
||||
@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>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
@@ -138,7 +223,13 @@ onMounted(() => {
|
||||
:external-link="doc.external_link"
|
||||
:assistant="doc.assistant"
|
||||
: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"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, doc.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,5 +253,12 @@ onMounted(() => {
|
||||
type="Documents"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantDocument"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
@@ -316,7 +316,7 @@ onMounted(() => {
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ onMounted(() => {
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,21 +2,29 @@
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
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 CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const SOFT_LIMIT = 10;
|
||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||
|
||||
const showSoftLimitWarning = computed(
|
||||
() => !isV2.value && customToolsMeta.value.totalCount > SOFT_LIMIT
|
||||
);
|
||||
|
||||
const createDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const selectedTool = ref(null);
|
||||
@@ -86,21 +94,23 @@ onMounted(() => {
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!customTools.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||
:show-know-more="false"
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<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
|
||||
v-for="tool in customTools"
|
||||
:id="tool.id"
|
||||
|
||||
@@ -36,27 +36,27 @@ export default {
|
||||
{
|
||||
id: null,
|
||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
||||
thumbnail: `/assets/images/dashboard/priority/none.svg`,
|
||||
icon: 'i-woot-priority-empty',
|
||||
},
|
||||
{
|
||||
id: CONVERSATION_PRIORITY.URGENT,
|
||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
||||
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
|
||||
icon: 'i-woot-priority-urgent',
|
||||
},
|
||||
{
|
||||
id: CONVERSATION_PRIORITY.HIGH,
|
||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
||||
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
|
||||
icon: 'i-woot-priority-high',
|
||||
},
|
||||
{
|
||||
id: CONVERSATION_PRIORITY.MEDIUM,
|
||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
||||
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
|
||||
icon: 'i-woot-priority-medium',
|
||||
},
|
||||
{
|
||||
id: CONVERSATION_PRIORITY.LOW,
|
||||
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
||||
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
|
||||
icon: 'i-woot-priority-low',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -103,7 +103,10 @@ export default {
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
this.getAccount(this.accountId);
|
||||
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
|
||||
const effectiveLocale = this.uiSettings?.locale || locale;
|
||||
if (effectiveLocale) {
|
||||
this.$root.$i18n.locale = effectiveLocale;
|
||||
}
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
this.id = id;
|
||||
@@ -129,11 +132,9 @@ export default {
|
||||
support_email: this.supportEmail,
|
||||
});
|
||||
// If user locale is set, update the locale with user locale
|
||||
if (this.uiSettings?.locale) {
|
||||
this.$root.$i18n.locale = this.uiSettings?.locale;
|
||||
} else {
|
||||
// If user locale is not set, update the locale with account locale
|
||||
this.$root.$i18n.locale = this.locale;
|
||||
const updatedLocale = this.uiSettings?.locale || this.locale;
|
||||
if (updatedLocale) {
|
||||
this.$root.$i18n.locale = updatedLocale;
|
||||
}
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
||||
|
||||
@@ -57,7 +57,10 @@ export default {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -128,7 +128,7 @@ const saveMacro = async macroData => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto h-full w-full !px-6">
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetchingItem"
|
||||
:message="t('MACROS.EDITOR.LOADING')"
|
||||
|
||||
@@ -25,17 +25,26 @@ export default createStore({
|
||||
}
|
||||
},
|
||||
|
||||
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
|
||||
handleBulkDelete: async function handleBulkDelete(
|
||||
{ dispatch },
|
||||
{ type = 'AssistantResponse', ids }
|
||||
) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantResponse',
|
||||
type,
|
||||
actionType: 'delete',
|
||||
ids,
|
||||
});
|
||||
|
||||
// Update the response store after successful API call
|
||||
await dispatch('captainResponses/removeBulkResponses', ids, {
|
||||
root: true,
|
||||
});
|
||||
if (type === 'AssistantResponse') {
|
||||
// Update the response store after successful API call
|
||||
await dispatch('captainResponses/removeBulkResponses', ids, {
|
||||
root: true,
|
||||
});
|
||||
} else if (type === 'AssistantDocument') {
|
||||
await dispatch('captainDocuments/removeBulkRecords', ids, {
|
||||
root: true,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
|
||||
@@ -4,4 +4,12 @@ import { createStore } from '../storeFactory';
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
actions: mutations => ({
|
||||
removeBulkRecords({ commit, getters }, ids) {
|
||||
const records = getters.getRecords.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, records);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -220,9 +220,8 @@ export const actions = {
|
||||
sendAnalyticsEvent(channel.type);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const errorMessage = error?.response?.data?.message;
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw new Error(errorMessage);
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
createWebsiteChannel: async ({ commit }, params) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -53,6 +54,10 @@ const hasValue = computed(() => {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const hasIcon = computed(() => {
|
||||
return props.selectedItem?.icon || false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,7 +88,7 @@ const hasValue = computed(() => {
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail"
|
||||
v-if="hasValue && hasThumbnail && !hasIcon"
|
||||
:src="selectedItem.thumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItem.name"
|
||||
@@ -91,6 +96,11 @@ const hasValue = computed(() => {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<Icon
|
||||
v-if="hasValue && hasIcon"
|
||||
:icon="selectedItem.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
:class="{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.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';
|
||||
|
||||
export default {
|
||||
@@ -9,6 +10,7 @@ export default {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
Avatar,
|
||||
Icon,
|
||||
NextButton,
|
||||
},
|
||||
|
||||
@@ -106,7 +108,7 @@ export default {
|
||||
</span>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasThumbnail"
|
||||
v-if="hasThumbnail && !option.icon"
|
||||
:src="option.thumbnail"
|
||||
:name="option.name"
|
||||
:status="option.availability_status"
|
||||
@@ -114,6 +116,11 @@ export default {
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<Icon
|
||||
v-if="option.icon"
|
||||
:icon="option.icon"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
</WootDropdownMenu>
|
||||
|
||||
@@ -35,7 +35,9 @@ export default {
|
||||
};
|
||||
},
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,13 +6,26 @@ const createConversationAPI = async content => {
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendMessageAPI = async (content, replyTo = null) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo);
|
||||
const sendMessageAPI = async (
|
||||
content,
|
||||
replyTo = null,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||
const sendAttachmentAPI = async (
|
||||
attachment,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,23 +22,30 @@ const createConversation = params => {
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage = (content, replyTo) => {
|
||||
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
|
||||
const referrerURL = window.referrerURL || '';
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return {
|
||||
url: `/api/v1/widget/messages${search}`,
|
||||
params: {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
const params = {
|
||||
message: {
|
||||
content,
|
||||
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 = ({ attachment, replyTo = null }) => {
|
||||
const sendAttachment = (
|
||||
{ attachment, replyTo = null },
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const { referrerURL = '' } = window;
|
||||
const timestamp = new Date().toString();
|
||||
const { file } = attachment;
|
||||
@@ -55,6 +62,16 @@ const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
if (replyTo !== null) {
|
||||
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 {
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: formData,
|
||||
|
||||
@@ -32,6 +32,50 @@ 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', () => {
|
||||
it('returns correct payload', () => {
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full;
|
||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
||||
@@ -85,10 +85,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async retrySendMessage() {
|
||||
await this.$store.dispatch(
|
||||
'conversation/sendMessageWithData',
|
||||
this.message
|
||||
);
|
||||
await this.$store.dispatch('conversation/sendMessageWithData', {
|
||||
message: this.message,
|
||||
});
|
||||
},
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
@@ -23,6 +23,10 @@ export function useDarkMode() {
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
||||
});
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
|
||||
@@ -30,18 +30,37 @@ export const actions = {
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
}
|
||||
},
|
||||
sendMessage: async ({ dispatch }, params) => {
|
||||
sendMessage: async ({ dispatch, state: conversationState }, params) => {
|
||||
const { content, replyTo } = params;
|
||||
const message = createTemporaryMessage({ content, replyTo });
|
||||
dispatch('sendMessageWithData', message);
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
dispatch('sendMessageWithData', {
|
||||
message,
|
||||
pendingCustomAttributes,
|
||||
pendingLabels,
|
||||
});
|
||||
},
|
||||
sendMessageWithData: async ({ commit }, message) => {
|
||||
sendMessageWithData: async (
|
||||
{ commit },
|
||||
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
|
||||
) => {
|
||||
const { id, content, replyTo, meta = {} } = message;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', message);
|
||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||
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
|
||||
// commit('deleteMessage', message.id);
|
||||
@@ -59,7 +78,7 @@ export const actions = {
|
||||
commit('setLastMessageId');
|
||||
},
|
||||
|
||||
sendAttachment: async ({ commit }, params) => {
|
||||
sendAttachment: async ({ commit, state: conversationState }, params) => {
|
||||
const {
|
||||
attachment: { thumbUrl, fileType },
|
||||
meta = {},
|
||||
@@ -74,9 +93,22 @@ export const actions = {
|
||||
attachments: [attachment],
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', tempMessage);
|
||||
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', {
|
||||
message: data,
|
||||
tempId: tempMessage.id,
|
||||
@@ -180,7 +212,14 @@ export const actions = {
|
||||
await toggleStatus();
|
||||
},
|
||||
|
||||
setCustomAttributes: async (_, customAttributes = {}) => {
|
||||
setCustomAttributes: async (
|
||||
{ commit, rootGetters },
|
||||
customAttributes = {}
|
||||
) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('setPendingCustomAttributes', customAttributes);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setCustomAttributes(customAttributes);
|
||||
} catch (error) {
|
||||
@@ -188,7 +227,11 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomAttribute: async (_, customAttribute) => {
|
||||
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('removePendingCustomAttribute', customAttribute);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCustomAttribute(customAttribute);
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,6 +33,8 @@ export const getters = {
|
||||
messages: groupConversationBySender(conversationGroupedByDate[date]),
|
||||
}));
|
||||
},
|
||||
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
|
||||
getPendingLabels: _state => _state.pendingLabels,
|
||||
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
|
||||
getMessageCount: _state => {
|
||||
return Object.values(_state.conversations).length;
|
||||
|
||||
@@ -14,6 +14,8 @@ const state = {
|
||||
isCreating: false,
|
||||
},
|
||||
lastMessageId: null,
|
||||
pendingCustomAttributes: {},
|
||||
pendingLabels: [],
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { findUndeliveredMessage } from './helpers';
|
||||
export const mutations = {
|
||||
clearConversations($state) {
|
||||
$state.conversations = {};
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
pushMessageToConversation($state, message) {
|
||||
const { id, status, message_type: type } = message;
|
||||
@@ -113,4 +115,31 @@ export const mutations = {
|
||||
const { id } = lastMessage;
|
||||
$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,14 +5,22 @@ const state = {};
|
||||
export const getters = {};
|
||||
|
||||
export const actions = {
|
||||
create: async (_, label) => {
|
||||
create: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/setPendingLabels', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.create(label);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
destroy: async (_, label) => {
|
||||
destroy: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/removePendingLabel', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.destroy(label);
|
||||
} catch (error) {
|
||||
|
||||
@@ -111,20 +111,45 @@ describe('#actions', () => {
|
||||
search: '?param=1',
|
||||
},
|
||||
}));
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
await actions.sendMessage(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ content: 'hello', replyTo: 124 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
windowSpy.mockRestore();
|
||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
status: 'in_progress',
|
||||
message: {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,9 +161,10 @@ describe('#actions', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
const thumbUrl = '';
|
||||
const attachment = { thumbUrl, fileType: 'file' };
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
|
||||
actions.sendAttachment(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ attachment, replyTo: 135 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
@@ -180,6 +206,58 @@ 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', () => {
|
||||
it('sends correct mutations', () => {
|
||||
actions.clearConversations({ commit });
|
||||
|
||||
@@ -169,10 +169,77 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('clears the state', () => {
|
||||
const state = { conversations: { 1: { id: 1 } } };
|
||||
it('clears conversations and pending metadata', () => {
|
||||
const state = {
|
||||
conversations: { 1: { id: 1 } },
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearConversations(state);
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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,6 +15,15 @@ class AgentBotListener < BaseListener
|
||||
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
|
||||
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)
|
||||
message = extract_message_and_account(event)[0]
|
||||
inbox = message.inbox
|
||||
|
||||
@@ -37,6 +37,7 @@ class Attachment < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :message
|
||||
has_one_attached :file
|
||||
before_save :set_extension
|
||||
validate :acceptable_file
|
||||
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,
|
||||
@@ -111,6 +112,7 @@ class Attachment < ApplicationRecord
|
||||
def file_metadata
|
||||
metadata = {
|
||||
extension: extension,
|
||||
content_type: file.content_type,
|
||||
data_url: file_url,
|
||||
thumb_url: thumb_url,
|
||||
file_size: file.byte_size,
|
||||
@@ -154,6 +156,13 @@ class Attachment < ApplicationRecord
|
||||
}
|
||||
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?
|
||||
return unless file.attached?
|
||||
# we are only limiting attachment types in case of website widget
|
||||
|
||||
@@ -17,6 +17,7 @@ module AccountEmailRateLimitable
|
||||
end
|
||||
|
||||
def within_email_rate_limit?
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
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}")
|
||||
|
||||
@@ -2,24 +2,28 @@
|
||||
#
|
||||
# Table name: csat_survey_responses
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# feedback_message :text
|
||||
# rating :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assigned_agent_id :bigint
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# csat_review_notes :text
|
||||
# feedback_message :text
|
||||
# rating :integer not null
|
||||
# review_notes_updated_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assigned_agent_id :bigint
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint not null
|
||||
# review_notes_updated_by_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# 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_contact_id (contact_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_account_id (account_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_conversation_id (conversation_id)
|
||||
# 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
|
||||
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
|
||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||
# Fix configuration in application.rb
|
||||
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access
|
||||
|
||||
before_validation :set_lock
|
||||
validates :name, presence: true
|
||||
@@ -33,10 +33,6 @@ class InstallationConfig < ApplicationRecord
|
||||
after_commit :clear_cache
|
||||
|
||||
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]
|
||||
end
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class Message < ApplicationRecord
|
||||
additional_attributes: additional_attributes,
|
||||
content_attributes: content_attributes,
|
||||
content_type: content_type,
|
||||
content: outgoing_content,
|
||||
content: webhook_content,
|
||||
conversation: conversation.webhook_data,
|
||||
created_at: created_at,
|
||||
id: id,
|
||||
@@ -195,6 +195,11 @@ class Message < ApplicationRecord
|
||||
MessageContentPresenter.new(self).outgoing_content
|
||||
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?
|
||||
return false if private?
|
||||
return false if %w[outgoing template].exclude?(message_type)
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_reporting_events_on_account_id (account_id)
|
||||
# index_reporting_events_on_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
# 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_conversation_id (conversation_id)
|
||||
# index_reporting_events_on_created_at (created_at)
|
||||
# index_reporting_events_on_inbox_id (inbox_id)
|
||||
# index_reporting_events_on_name (name)
|
||||
# index_reporting_events_on_user_id (user_id)
|
||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||
#
|
||||
|
||||
class ReportingEvent < ApplicationRecord
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
#
|
||||
# Table name: reporting_events_rollups
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_id :bigint not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# id :bigint not null, primary key
|
||||
# count :bigint default(0), not null
|
||||
# date :date not null
|
||||
# dimension_type :string not null
|
||||
# metric :string not null
|
||||
# sum_value :float default(0.0), not null
|
||||
# sum_value_business_hours :float default(0.0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# dimension_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string
|
||||
# secret :string
|
||||
# subscriptions :jsonb
|
||||
# url :text
|
||||
# webhook_type :integer default("account_type")
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
class MessageContentPresenter < SimpleDelegator
|
||||
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(
|
||||
content_to_send,
|
||||
content_with_survey_link,
|
||||
conversation.inbox.channel_type,
|
||||
conversation.inbox.channel
|
||||
).render
|
||||
end
|
||||
|
||||
def webhook_content
|
||||
content_with_survey_link
|
||||
end
|
||||
|
||||
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?
|
||||
input_csat? && !inbox.web_widget?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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,16 +12,21 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
||||
# https://apidocs.leadsquared.com/create-or-update/#api
|
||||
# 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.
|
||||
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
|
||||
# we don't need this capability at the moment
|
||||
# We pass the "SearchBy" attribute with value "Phone" when a MXDuplicateEntryException
|
||||
# occurs, indicating a duplicate mobile number match that the default search missed.
|
||||
def create_or_update_lead(lead_data)
|
||||
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
||||
|
||||
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
|
||||
|
||||
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']
|
||||
end
|
||||
|
||||
@@ -47,4 +52,13 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
||||
}
|
||||
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
|
||||
|
||||
@@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
||||
# 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'
|
||||
|
||||
# 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,
|
||||
originalContentUrl: attachment.download_url,
|
||||
previewImageUrl: attachment.download_url
|
||||
originalContentUrl: original_url,
|
||||
previewImageUrl: preview_url
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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!
|
||||
when WebPush::TooManyRequests
|
||||
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError
|
||||
Rails.logger.error "WebPush operation error: #{error.message}"
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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,7 +21,10 @@ class Whatsapp::EmbeddedSignupService
|
||||
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
|
||||
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
|
||||
channel.setup_webhooks
|
||||
check_channel_health_and_prompt_reauth(channel)
|
||||
# Skip health check during reauthorization — phone numbers in pending provisioning state
|
||||
# (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
|
||||
|
||||
rescue StandardError => e
|
||||
|
||||
@@ -58,9 +58,9 @@ By default, it renders:
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-white dark:bg-slate-900">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<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
|
||||
version: '4.12.0'
|
||||
version: '4.12.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -69,12 +69,6 @@ module Chatwoot
|
||||
# Disable PDF/video preview generation as we don't use them
|
||||
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
|
||||
# Required for MFA/2FA features - skip if not using encryption
|
||||
if ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present?
|
||||
|
||||
+3
-3
@@ -104,10 +104,10 @@
|
||||
display_name: Audit Logs
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: response_bot
|
||||
display_name: Response Bot
|
||||
- name: custom_tools
|
||||
display_name: Custom Tools
|
||||
enabled: false
|
||||
deprecated: true
|
||||
premium: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
|
||||
@@ -42,7 +42,8 @@ LANGUAGES_CONFIG = {
|
||||
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 },
|
||||
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
|
||||
|
||||
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:
|
||||
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
|
||||
|
||||
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
|
||||
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
|
||||
|
||||
# to track post data in sentry
|
||||
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
|
||||
|
||||
@@ -34,7 +34,18 @@ end
|
||||
|
||||
# https://github.com/ondrejbartas/sidekiq-cron
|
||||
Rails.application.reloader.to_prepare do
|
||||
# TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a
|
||||
# safe cleanup path for YAML-backed cron jobs already persisted in Redis.
|
||||
Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server?
|
||||
# load_from_hash! upserts jobs from the YAML and removes any Redis-persisted
|
||||
# jobs that share the same source tag but are no longer in the file.
|
||||
# This ensures deleted schedule entries are cleaned up on deploy.
|
||||
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
|
||||
|
||||
@@ -91,6 +91,7 @@ dialogflow:
|
||||
'project_id': { 'type': 'string' },
|
||||
'credentials': { 'type': 'object' },
|
||||
'region': { 'type': 'string' },
|
||||
'language_code': { 'type': 'string' },
|
||||
},
|
||||
'required': ['project_id', 'credentials'],
|
||||
'additionalProperties': false,
|
||||
@@ -126,8 +127,40 @@ dialogflow:
|
||||
{ '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']
|
||||
visible_properties: ['project_id', 'region', 'language_code']
|
||||
google_translate:
|
||||
id: google_translate
|
||||
logo: google-translate.png
|
||||
|
||||
@@ -387,6 +387,7 @@ en:
|
||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||
custom_tool:
|
||||
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:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
@@ -423,6 +424,10 @@ en:
|
||||
title: Page not found
|
||||
description: We couldn't find the page you were looking for.
|
||||
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:
|
||||
fields:
|
||||
name: Name
|
||||
|
||||
@@ -134,6 +134,18 @@ codepen:
|
||||
</iframe>
|
||||
</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:
|
||||
regex: 'https?://gist\.github\.com/(?<username>[^/]+)/(?<gist_id>[a-f0-9]+)'
|
||||
template: |
|
||||
|
||||
+4
-1
@@ -72,7 +72,9 @@ Rails.application.routes.draw do
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
end
|
||||
resources :custom_tools
|
||||
resources :custom_tools do
|
||||
post :test, on: :collection
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resource :tasks, only: [], controller: 'tasks' do
|
||||
post :rewrite
|
||||
@@ -507,6 +509,7 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resources :email_channel_migrations, only: [:create]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
# executed daily at 0000 UTC
|
||||
# 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:
|
||||
cron: '0 0 * * *'
|
||||
class: 'Internal::TriggerDailyScheduledItemsJob'
|
||||
@@ -50,13 +49,6 @@ delete_accounts_job:
|
||||
class: 'Internal::DeleteAccountsJob'
|
||||
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
|
||||
periodic_assignment_job:
|
||||
cron: '*/30 * * * *'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
before_action :validate_params
|
||||
before_action :type_matches?
|
||||
|
||||
MODEL_TYPE = ['AssistantResponse'].freeze
|
||||
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
|
||||
|
||||
def create
|
||||
@responses = process_bulk_action
|
||||
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
case params[:type]
|
||||
when 'AssistantResponse'
|
||||
handle_assistant_responses
|
||||
when 'AssistantDocument'
|
||||
handle_documents
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
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
|
||||
params.permit(:type, ids: [], fields: [:status])
|
||||
end
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :ensure_custom_tools_enabled
|
||||
before_action -> { check_authorization(Captain::CustomTool) }
|
||||
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@custom_tools = account_custom_tools.enabled
|
||||
@custom_tools = account_custom_tools
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
||||
rescue Captain::CustomTool::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -22,8 +25,22 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
head :no_content
|
||||
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
|
||||
|
||||
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
|
||||
@custom_tool = account_custom_tools.find(params[:id])
|
||||
end
|
||||
@@ -32,6 +49,11 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
@account_custom_tools ||= Current.account.captain_custom_tools
|
||||
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
|
||||
params.require(:custom_tool).permit(
|
||||
:title,
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||
#
|
||||
class Captain::CustomTool < ApplicationRecord
|
||||
class LimitExceededError < StandardError; end
|
||||
|
||||
MAX_PER_ACCOUNT = 15
|
||||
|
||||
include Concerns::Toolable
|
||||
include Concerns::SafeEndpointValidatable
|
||||
|
||||
@@ -31,6 +35,10 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
NAME_PREFIX = 'custom'.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 = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
@@ -52,8 +60,9 @@ class Captain::CustomTool < ApplicationRecord
|
||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||
|
||||
before_validation :generate_slug
|
||||
before_create :ensure_within_limit
|
||||
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
|
||||
validates :title, presence: true
|
||||
validates :endpoint_url, presence: true
|
||||
validates_with JsonSchemaValidator,
|
||||
@@ -73,21 +82,29 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
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
|
||||
return if slug.present?
|
||||
return if title.blank?
|
||||
|
||||
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||
parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
truncated = base_slug.truncate(MAX_SLUG_LENGTH - COLLISION_SUFFIX_LENGTH, omission: '')
|
||||
5.times do
|
||||
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
slug_candidate = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
return slug_candidate unless slug_exists?(slug_candidate)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
module Concerns::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def tool(assistant)
|
||||
# Isolated namespace for user-defined custom tool classes.
|
||||
# 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
|
||||
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||
class_name = custom_tool_record.slug.underscore.camelize
|
||||
|
||||
# Always create a fresh class to reflect current metadata
|
||||
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||
tool_slug = custom_tool_record.slug
|
||||
tool_class = Class.new(base_class) do
|
||||
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|
|
||||
param param_def['name'].to_sym,
|
||||
type: param_def['type'],
|
||||
@@ -18,17 +26,14 @@ module Concerns::Toolable
|
||||
end
|
||||
end
|
||||
|
||||
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||
# 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)
|
||||
# Register as a constant so the class gets a proper name (Class#name).
|
||||
# Anonymous classes return nil for #name, which causes "Invalid 'tools[].function.name':
|
||||
# empty string" errors from the LLM API. We use CustomTools as the namespace to avoid
|
||||
# collisions with real classes in Captain::Tools.
|
||||
CustomTools.send(:remove_const, class_name) if CustomTools.const_defined?(class_name, false)
|
||||
CustomTools.const_set(class_name, tool_class)
|
||||
|
||||
tool_class.new(assistant, self)
|
||||
tool_class.new(assistant, self, **)
|
||||
end
|
||||
|
||||
def build_request_url(params)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module Enterprise::Inbox
|
||||
def member_ids_with_assignment_capacity
|
||||
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']
|
||||
overloaded_agent_ids = max_assignment_limit.present? ? get_agent_ids_over_assignment_limit(max_assignment_limit) : []
|
||||
|
||||
@@ -21,7 +21,7 @@ module Enterprise::InboxAgentAvailability
|
||||
end
|
||||
|
||||
def capacity_filtering_enabled?
|
||||
account.feature_enabled?('assignment_v2') &&
|
||||
account.feature_enabled?('advanced_assignment') &&
|
||||
account.account_users.joins(:agent_capacity_policy).exists?
|
||||
end
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ class Captain::CustomToolPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def test?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user