Compare commits

..
150 changed files with 813 additions and 3323 deletions
-5
View File
@@ -2,8 +2,3 @@
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
View File
@@ -1 +1 @@
4.12.1
4.12.0
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# 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,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
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?
@conversation = create_conversation if conversation.nil?
end
def message_finder_params
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
# custom_attributes and labels are applied when a new conversation is created alongside the first message
params.permit(
:id, :before, :after, :website_token,
contact: [:name, :email],
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
custom_attributes: {},
labels: []
)
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
end
def set_message
@@ -10,12 +10,7 @@ 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
@@ -25,10 +20,7 @@ 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
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
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}"
@@ -90,15 +81,6 @@ 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
@@ -1,101 +0,0 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end
@@ -1,7 +1,6 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
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,7 +1,6 @@
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,8 +1,7 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@@ -25,7 +24,6 @@ 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
-7
View File
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, 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
+1 -3
View File
@@ -98,9 +98,7 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
if (locale) {
this.$root.$i18n.locale = locale;
}
this.$root.$i18n.locale = locale;
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
@@ -31,12 +31,6 @@ 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,10 +106,6 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea
@@ -1,63 +1,207 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
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>
<Icon
v-tooltip.top="{
content: tooltipContent,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
<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>
</template>
@@ -12,7 +12,6 @@ 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: {
@@ -35,34 +34,14 @@ 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', 'select', 'hover']);
const emit = defineEmits(['action']);
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 = [
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<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>
<CardLayout>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div v-if="showMenu" class="flex gap-2 items-center">
<div class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const i18nKey = computed(() => props.type.toUpperCase());
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
</Policy>
</div>
</div>
<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">
<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"
>
{{ description }}
</span>
<span
@@ -1,10 +1,9 @@
<script setup>
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, maxLength } from '@vuelidate/validators';
import { required } 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';
@@ -73,12 +72,8 @@ 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, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
title: { required },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
);
const getErrorMessage = (field, errorKey) => {
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`);
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
@@ -151,30 +140,6 @@ 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>
@@ -283,45 +248,6 @@ const handleTest = 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"
@@ -1,11 +1,8 @@
<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');
@@ -13,15 +10,6 @@ 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,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasCustomTools = computed(() => {
return (
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
) ||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
);
});
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_inboxes_index',
}),
},
...(hasCustomTools.value
? [
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
]
: []),
{
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 CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import PriorityMark from './PriorityMark.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-baseline gap-2 flex-shrink-0"
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
@@ -317,10 +317,7 @@ const deleteConversation = () => {
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
</div>
</div>
<h4
@@ -0,0 +1,53 @@
<script>
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
export default {
name: 'PriorityMark',
props: {
priority: {
type: String,
default: '',
validate: value =>
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
},
},
data() {
return {
CONVERSATION_PRIORITY,
};
},
computed: {
tooltipText() {
return this.$t(
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
);
},
isUrgent() {
return this.priority === CONVERSATION_PRIORITY.URGENT;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<span
v-if="priority"
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
'bg-n-slate-4 text-n-slate-11': !isUrgent,
}"
>
<fluent-icon
:icon="`priority-${priority.toLowerCase()}`"
:size="isUrgent ? 12 : 14"
class="flex-shrink-0"
view-box="0 0 14 14"
/>
</span>
</template>
@@ -253,9 +253,6 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
@@ -548,10 +545,7 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
this.copilotAcceptedMessages[key] = trimContent(message || '');
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -609,7 +603,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '', this.maxLength);
const draftToSave = trimContent(this.message || '');
this.$store.dispatch('draftMessages/set', {
key,
-1
View File
@@ -37,7 +37,6 @@ 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,8 +166,6 @@ 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 ──────────────────────────────────────────────────────
@@ -280,13 +278,8 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, 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);
}
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
@@ -82,9 +82,6 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const 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$`
);
@@ -92,9 +89,6 @@ 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})?)$`
);
@@ -251,7 +245,6 @@ 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)
);
};
@@ -310,13 +303,6 @@ const matchRelativeDay = (text, now) => {
);
}
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
if (dayMeridiemMatch) {
const [, dayKey, meridiem] = dayMeridiemMatch;
const hours = meridiem === 'am' ? 9 : 14;
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
@@ -1626,24 +1626,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
},
};
const zhTWSnoozeTranslations = {
UNITS: {
HOUR: '小時',
HOURS: '小時',
DAY: '天',
DAYS: '天',
},
HALF: '半',
RELATIVE: {
TOMORROW: '明天',
},
MERIDIEM: {
AM: '上午',
PM: '下午',
},
AFTER: '後',
};
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
it('Arabic "غد" does not produce half-duration suggestions', () => {
const results = generateDateSuggestions('غد', now, {
@@ -1739,37 +1721,6 @@ 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,17 +738,6 @@
"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."
@@ -807,7 +796,6 @@
"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.",
@@ -838,18 +826,11 @@
"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",
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
@@ -34,7 +34,6 @@ 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';
@@ -75,7 +74,6 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -1,72 +1,72 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "分鐘",
"MINUTES": "分鐘",
"HOUR": "小時",
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"HOURS": "小時",
"DAY": "",
"DAYS": "",
"WEEK": "",
"WEEKS": "",
"MONTH": "",
"MONTHS": "",
"YEAR": "",
"YEARS": ""
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "month",
"YEARS": "years"
},
"HALF": "",
"NEXT": "下一個",
"THIS": "這個",
"AT": "",
"IN": "",
"FROM_NOW": "之後",
"NEXT_YEAR": "明年",
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"MERIDIEM": {
"AM": "上午",
"PM": "下午"
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "明天",
"DAY_AFTER_TOMORROW": "後天",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"NEXT_WEEK": "下週",
"NEXT_MONTH": "下個月",
"THIS_WEEKEND": "這個週末",
"NEXT_WEEKEND": "下個週末"
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
},
"TIME_OF_DAY": {
"MORNING": "早上",
"AFTERNOON": "下午",
"EVENING": "晚上",
"NIGHT": "夜晚",
"NOON": "中午",
"MIDNIGHT": "午夜"
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
},
"WORD_NUMBERS": {
"ONE": "",
"TWO": "",
"THREE": "",
"FOUR": "",
"FIVE": "",
"SIX": "",
"SEVEN": "",
"EIGHT": "",
"NINE": "",
"TEN": "",
"TWELVE": "十二",
"FIFTEEN": "十五",
"TWENTY": "二十",
"THIRTY": "三十"
"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"
},
"ORDINALS": {
"FIRST": "第一",
"SECOND": "第二",
"THIRD": "第三",
"FOURTH": "第四",
"FIFTH": "第五"
"FIRST": "first",
"SECOND": "second",
"THIRD": "third",
"FOURTH": "fourth",
"FIFTH": "fifth"
},
"OF": "",
"AFTER": "",
"WEEK": "",
"DAY": ""
"OF": "of",
"AFTER": "after",
"WEEK": "week",
"DAY": "day"
}
}
@@ -46,7 +46,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta,
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
@@ -4,13 +4,9 @@ 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';
@@ -18,12 +14,9 @@ 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');
@@ -32,13 +25,9 @@ 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();
@@ -89,14 +78,7 @@ const fetchDocuments = (page = 1) => {
store.dispatch('captainDocuments/get', filterParams);
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchDocuments(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
const onPageChange = page => fetchDocuments(page);
const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
@@ -104,58 +86,6 @@ const onDeleteSuccess = () => {
}
};
const buildSelectedCountLabel = computed(() => {
const count = documents.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.DOCUMENTS.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
const shouldShowSelectionControl = docId => {
return (
canManageDocuments.value &&
(hoveredCard.value === docId || hasBulkSelection.value)
);
};
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
if (!canManageDocuments.value) return;
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchDocumentsAfterBulkAction = () => {
const hasNoDocumentsLeft = documents.value?.length === 0;
const currentPage = documentsMeta.value?.page;
if (hasNoDocumentsLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchDocuments(pageToFetch);
} else {
fetchDocuments(currentPage);
}
bulkSelectedIds.value = new Set();
};
const onBulkDeleteSuccess = () => {
fetchDocumentsAfterBulkAction();
};
onMounted(() => {
fetchDocuments();
});
@@ -176,21 +106,6 @@ 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')"
@@ -223,13 +138,7 @@ 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>
@@ -253,12 +162,5 @@ 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="AssistantResponse"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
@@ -361,7 +361,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantResponse"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
@@ -2,29 +2,21 @@
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);
@@ -94,23 +86,21 @@ 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'),
icon: 'i-woot-priority-empty',
thumbnail: `/assets/images/dashboard/priority/none.svg`,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
icon: 'i-woot-priority-urgent',
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
icon: 'i-woot-priority-high',
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
icon: 'i-woot-priority-medium',
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
icon: 'i-woot-priority-low',
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
},
],
};
@@ -103,10 +103,7 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
const effectiveLocale = this.uiSettings?.locale || locale;
if (effectiveLocale) {
this.$root.$i18n.locale = effectiveLocale;
}
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
this.name = name;
this.locale = locale;
this.id = id;
@@ -132,9 +129,11 @@ export default {
support_email: this.supportEmail,
});
// If user locale is set, update the locale with user locale
const updatedLocale = this.uiSettings?.locale || this.locale;
if (updatedLocale) {
this.$root.$i18n.locale = updatedLocale;
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;
}
this.getAccount(this.id).locale = this.locale;
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
@@ -57,10 +57,7 @@ export default {
},
});
} catch (error) {
useAlert(
error.message ||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
);
useAlert(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 h-full w-full !px-6">
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="t('MACROS.EDITOR.LOADING')"
@@ -25,26 +25,17 @@ export default createStore({
}
},
handleBulkDelete: async function handleBulkDelete(
{ dispatch },
{ type = 'AssistantResponse', ids }
) {
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type,
type: 'AssistantResponse',
actionType: 'delete',
ids,
});
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,
});
}
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
return response;
},
@@ -4,12 +4,4 @@ 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,8 +220,9 @@ 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 });
return throwErrorMessage(error);
throw new Error(errorMessage);
}
},
createWebsiteChannel: async ({ commit }, params) => {
@@ -5,7 +5,6 @@ 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({
@@ -54,10 +53,6 @@ const hasValue = computed(() => {
}
return false;
});
const hasIcon = computed(() => {
return props.selectedItem?.icon || false;
});
</script>
<template>
@@ -88,7 +83,7 @@ const hasIcon = computed(() => {
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && !hasIcon"
v-if="hasValue && hasThumbnail"
:src="selectedItem.thumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
@@ -96,11 +91,6 @@ const hasIcon = 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,7 +2,6 @@
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 {
@@ -10,7 +9,6 @@ export default {
WootDropdownItem,
WootDropdownMenu,
Avatar,
Icon,
NextButton,
},
@@ -108,7 +106,7 @@ export default {
</span>
</div>
<Avatar
v-if="hasThumbnail && !option.icon"
v-if="hasThumbnail"
:src="option.thumbnail"
:name="option.name"
:status="option.availability_status"
@@ -116,11 +114,6 @@ 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>
@@ -54,7 +54,6 @@ describe('#dateFormat', () => {
describe('#shortTimestamp', () => {
// Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(shortTimestamp('in less than a minute')).toEqual('now');
expect(shortTimestamp('less than a minute ago')).toEqual('now');
expect(shortTimestamp('1 minute ago')).toEqual('1m');
expect(shortTimestamp('12 minutes ago')).toEqual('12m');
@@ -68,7 +68,6 @@ export const shortTimestamp = (time, withAgo = false) => {
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'in less than a minute': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
+1 -3
View File
@@ -35,9 +35,7 @@ export default {
};
},
setLocale(locale) {
if (locale) {
this.$root.$i18n.locale = locale;
}
this.$root.$i18n.locale = locale;
},
},
};
+4 -17
View File
@@ -6,26 +6,13 @@ const createConversationAPI = async content => {
return API.post(urlData.url, urlData.params);
};
const sendMessageAPI = async (
content,
replyTo = null,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendMessage(content, replyTo, {
customAttributes,
labels,
});
const sendMessageAPI = async (content, replyTo = null) => {
const urlData = endPoints.sendMessage(content, replyTo);
return API.post(urlData.url, urlData.params);
};
const sendAttachmentAPI = async (
attachment,
{ customAttributes, labels } = {}
) => {
const urlData = endPoints.sendAttachment(attachment, {
customAttributes,
labels,
});
const sendAttachmentAPI = async (attachment, replyTo = null) => {
const urlData = endPoints.sendAttachment(attachment, replyTo);
return API.post(urlData.url, urlData.params);
};
+11 -28
View File
@@ -22,30 +22,23 @@ const createConversation = params => {
};
};
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
const sendMessage = (content, replyTo) => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
const params = {
message: {
content,
reply_to: replyTo,
timestamp: new Date().toString(),
referer_url: referrerURL,
return {
url: `/api/v1/widget/messages${search}`,
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 },
{ customAttributes, labels } = {}
) => {
const sendAttachment = ({ attachment, replyTo = null }) => {
const { referrerURL = '' } = window;
const timestamp = new Date().toString();
const { file } = attachment;
@@ -62,16 +55,6 @@ const sendAttachment = (
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,50 +32,6 @@ describe('#sendMessage', () => {
});
});
describe('#sendMessage with pending metadata', () => {
it('includes custom_attributes and labels in payload', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello', null, {
customAttributes: { plan: 'enterprise' },
labels: ['vip'],
});
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
expect(result.params.labels).toEqual(['vip']);
spy.mockRestore();
});
it('does not include metadata keys when not provided', () => {
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
vi.spyOn(window, 'location', 'get').mockReturnValue({
...window.location,
search: '?param=1',
});
window.WOOT_WIDGET = {
$root: { $i18n: { locale: 'ar' } },
};
const result = endPoints.sendMessage('hello');
expect(result.params.custom_attributes).toBeUndefined();
expect(result.params.labels).toBeUndefined();
spy.mockRestore();
});
});
describe('#getConversation', () => {
it('returns correct payload', () => {
vi.spyOn(window, 'location', 'get').mockReturnValue({
+1 -1
View File
@@ -7,7 +7,7 @@
html,
body {
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
@apply antialiased h-full;
}
.is-mobile {
@@ -85,9 +85,10 @@ export default {
},
methods: {
async retrySendMessage() {
await this.$store.dispatch('conversation/sendMessageWithData', {
message: this.message,
});
await this.$store.dispatch(
'conversation/sendMessageWithData',
this.message
);
},
onImageLoadError() {
this.hasImageError = true;
@@ -1,4 +1,4 @@
import { computed, watchEffect } from 'vue';
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
@@ -23,10 +23,6 @@ export function useDarkMode() {
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
watchEffect(() => {
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
});
return {
darkMode,
prefersDarkMode,
@@ -30,37 +30,18 @@ export const actions = {
commit('setConversationUIFlag', { isCreating: false });
}
},
sendMessage: async ({ dispatch, state: conversationState }, params) => {
sendMessage: async ({ dispatch }, params) => {
const { content, replyTo } = params;
const message = createTemporaryMessage({ content, replyTo });
const { pendingCustomAttributes, pendingLabels } = conversationState;
dispatch('sendMessageWithData', {
message,
pendingCustomAttributes,
pendingLabels,
});
dispatch('sendMessageWithData', message);
},
sendMessageWithData: async (
{ commit },
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
) => {
sendMessageWithData: async ({ commit }, message) => {
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, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
const { data } = await sendMessageAPI(content, replyTo);
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
// commit('deleteMessage', message.id);
@@ -78,7 +59,7 @@ export const actions = {
commit('setLastMessageId');
},
sendAttachment: async ({ commit, state: conversationState }, params) => {
sendAttachment: async ({ commit }, params) => {
const {
attachment: { thumbUrl, fileType },
meta = {},
@@ -93,22 +74,9 @@ 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, {
customAttributes: hasPendingMetadata
? pendingCustomAttributes
: undefined,
labels: hasPendingMetadata ? pendingLabels : undefined,
});
if (hasPendingMetadata) {
commit('clearPendingConversationMetadata');
}
const { data } = await sendAttachmentAPI(params);
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
@@ -212,14 +180,7 @@ export const actions = {
await toggleStatus();
},
setCustomAttributes: async (
{ commit, rootGetters },
customAttributes = {}
) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('setPendingCustomAttributes', customAttributes);
return;
}
setCustomAttributes: async (_, customAttributes = {}) => {
try {
await setCustomAttributes(customAttributes);
} catch (error) {
@@ -227,11 +188,7 @@ export const actions = {
}
},
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('removePendingCustomAttribute', customAttribute);
return;
}
deleteCustomAttribute: async (_, customAttribute) => {
try {
await deleteCustomAttribute(customAttribute);
} catch (error) {
@@ -33,8 +33,6 @@ 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,8 +14,6 @@ const state = {
isCreating: false,
},
lastMessageId: null,
pendingCustomAttributes: {},
pendingLabels: [],
};
export default {
@@ -4,8 +4,6 @@ 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;
@@ -115,31 +113,4 @@ 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,22 +5,14 @@ const state = {};
export const getters = {};
export const actions = {
create: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/setPendingLabels', label, { root: true });
return;
}
create: async (_, label) => {
try {
await conversationLabels.create(label);
} catch (error) {
// Ignore error
}
},
destroy: async ({ commit, rootGetters }, label) => {
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
commit('conversation/removePendingLabel', label, { root: true });
return;
}
destroy: async (_, label) => {
try {
await conversationLabels.destroy(label);
} catch (error) {
@@ -111,45 +111,20 @@ describe('#actions', () => {
search: '?param=1',
},
}));
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
await actions.sendMessage(
{ commit, dispatch, state },
{ commit, dispatch },
{ content: 'hello', replyTo: 124 }
);
spy.mockRestore();
windowSpy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
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'],
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
});
});
});
@@ -161,10 +136,9 @@ 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, state },
{ commit, dispatch },
{ attachment, replyTo: 135 }
);
spy.mockRestore();
@@ -206,58 +180,6 @@ describe('#actions', () => {
});
});
describe('#setCustomAttributes', () => {
it('queues to pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
plan: 'enterprise',
});
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.setCustomAttributes(
{ commit, rootGetters },
{ plan: 'enterprise' }
);
expect(commit).not.toBeCalledWith(
'setPendingCustomAttributes',
expect.anything()
);
});
});
describe('#deleteCustomAttribute', () => {
it('removes from pending state when no conversation exists', async () => {
const rootGetters = {
'conversationAttributes/getConversationParams': { id: '' },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
});
it('calls API when conversation exists', async () => {
API.post.mockResolvedValue({ data: {} });
const rootGetters = {
'conversationAttributes/getConversationParams': { id: 123 },
};
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
expect(commit).not.toBeCalledWith(
'removePendingCustomAttribute',
expect.anything()
);
});
});
describe('#clearConversations', () => {
it('sends correct mutations', () => {
actions.clearConversations({ commit });
@@ -169,77 +169,10 @@ describe('#mutations', () => {
});
describe('#clearConversations', () => {
it('clears conversations and pending metadata', () => {
const state = {
conversations: { 1: { id: 1 } },
pendingCustomAttributes: { plan: 'enterprise' },
pendingLabels: ['vip'],
};
it('clears the state', () => {
const state = { conversations: { 1: { id: 1 } } };
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-n-solid-1 h-full">
<div class="bg-white h-full">
<IframeLoader :url="$route.query.link" />
</div>
</template>
+1 -1
View File
@@ -8,7 +8,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook)
super(url, payload, webhook_type)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
raise
end
end
@@ -0,0 +1,47 @@
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
Account.feature_assignment_v2.find_each do |account|
if should_skip_auto_assignment?(account)
Rails.logger.info("Skipping auto assignment for account #{account.id}")
next
end
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
process_assignment(inbox)
end
end
end
private
def process_assignment(inbox)
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
if allowed_agent_ids.blank?
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
return
end
assign_conversations(inbox, allowed_agent_ids)
end
def assign_conversations(inbox, allowed_agent_ids)
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
unassigned_conversations.find_each do |conversation|
::AutoAssignment::AgentAssignmentService.new(
conversation: conversation,
allowed_agent_ids: allowed_agent_ids
).perform
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
end
end
def should_skip_auto_assignment?(account)
return false unless ChatwootApp.chatwoot_cloud?
default_plan?(account)
end
end
-9
View File
@@ -15,15 +15,6 @@ 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
-9
View File
@@ -37,7 +37,6 @@ 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,
@@ -112,7 +111,6 @@ 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,
@@ -156,13 +154,6 @@ 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,7 +17,6 @@ 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}")
+1 -6
View File
@@ -176,7 +176,7 @@ class Message < ApplicationRecord
additional_attributes: additional_attributes,
content_attributes: content_attributes,
content_type: content_type,
content: webhook_content,
content: outgoing_content,
conversation: conversation.webhook_data,
created_at: created_at,
id: id,
@@ -195,11 +195,6 @@ 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)
+9 -15
View File
@@ -1,28 +1,22 @@
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_with_survey_link,
content_to_send,
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
@@ -1,65 +0,0 @@
module SocialLinkParser
extend ActiveSupport::Concern
SOCIAL_DOMAIN_MAP = {
whatsapp: %w[wa.me api.whatsapp.com],
line: %w[line.me],
facebook: %w[facebook.com fb.com fb.me],
instagram: %w[instagram.com],
telegram: %w[t.me telegram.me],
tiktok: %w[tiktok.com]
}.freeze
private
def extract_social_from_links(links)
handles = {}
SOCIAL_DOMAIN_MAP.each do |platform, domains|
handles[platform] = find_social_handle(links, platform, domains)
end
handles
end
def find_social_handle(links, platform, domains)
matching_links = links.select do |l|
uri = URI.parse(l)
domains.any? { |d| match_social_domain?(uri.host, d) }
rescue URI::InvalidURIError
false
end
matching_links.each do |link|
handle = parse_social_handle(platform, link)
return handle if handle.present?
end
nil
end
def match_social_domain?(host, domain)
return false if host.blank?
host == domain || host.end_with?(".#{domain}")
end
SHARE_PATH_PREFIXES = %w[sharer share intent dialog].freeze
def parse_social_handle(platform, link)
uri = URI.parse(link)
return extract_whatsapp_phone(uri) if platform == :whatsapp
handle = uri.path.to_s.delete_prefix('/').delete_suffix('/')
return nil if handle.blank?
return nil if SHARE_PATH_PREFIXES.any? { |prefix| handle.start_with?(prefix) }
handle.presence
rescue URI::InvalidURIError
nil
end
# wa.me/1234567890 or api.whatsapp.com/send?phone=1234567890
def extract_whatsapp_phone(uri)
phone = CGI.parse(uri.query.to_s)['phone']&.first
phone = uri.path.to_s.delete_prefix('/').delete_suffix('/') if phone.blank?
phone.presence&.gsub(/[^\d]/, '')
end
end
@@ -12,21 +12,16 @@ class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
# https://apidocs.leadsquared.com/create-or-update/#api
# 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 pass the "SearchBy" attribute with value "Phone" when a MXDuplicateEntryException
# occurs, indicating a duplicate mobile number match that the default search missed.
# 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
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['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
@@ -52,13 +47,4 @@ 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
+2 -7
View File
@@ -44,15 +44,10 @@ class Line::SendOnLineService < Base::SendOnChannelService
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
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: original_url,
previewImageUrl: preview_url
originalContentUrl: attachment.download_url,
previewImageUrl: attachment.download_url
}
end
end
@@ -1,25 +0,0 @@
class Notification::MarkConversationReadService
pattr_initialize [:user!, :account!, :conversation!]
def perform
return unless user.is_a?(User)
notifications.find_each do |notification|
notification.update!(read_at: read_at)
end
end
private
def notifications
user.notifications.where(
account: account,
primary_actor: conversation,
read_at: nil
)
end
def read_at
@read_at ||= Time.current
end
end
@@ -79,7 +79,7 @@ class Notification::PushNotificationService
subscription.destroy!
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, Socket::ResolutionError
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
Rails.logger.error "WebPush operation error: #{error.message}"
else
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
-93
View File
@@ -1,93 +0,0 @@
class WebsiteBrandingService
include SocialLinkParser
def initialize(url)
@url = normalize_url(url)
end
def perform
doc = fetch_page
return nil if doc.nil?
links = extract_links(doc)
{
business_name: extract_business_name(doc),
language: extract_language(doc),
industry_category: nil,
social_handles: extract_social_from_links(links),
branding: extract_branding(doc)
}
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}"
nil
end
private
def normalize_url(url)
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
end
def fetch_page
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
return nil unless response.success?
Nokogiri::HTML(response.body)
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
def extract_business_name(doc)
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
return og_site_name.strip if og_site_name.present?
title = doc.at_xpath('//title')&.text
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
end
def extract_language(doc)
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
end
def extract_links(doc)
doc.css('a[href]').filter_map do |a|
href = a['href']&.strip
next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
href.start_with?('http') ? href : URI.join(@url, href).to_s
rescue URI::InvalidURIError
nil
end.uniq
end
def extract_branding(doc)
{
favicon: extract_favicon(doc),
primary_color: extract_theme_color(doc)
}
end
def extract_favicon(doc)
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
return nil if favicon.blank?
resolve_url(favicon)
end
def extract_theme_color(doc)
doc.at_css('meta[name="theme-color"]')&.[]('content')
end
def resolve_url(url)
return nil if url.blank?
return url if url.start_with?('http')
URI.join(@url, url).to_s
rescue URI::InvalidURIError
nil
end
end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
@@ -21,10 +21,7 @@ class Whatsapp::EmbeddedSignupService
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
# 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?
check_channel_health_and_prompt_reauth(channel)
channel
rescue StandardError => e
+2 -2
View File
@@ -58,9 +58,9 @@ By default, it renders:
}
</script>
</head>
<body class="bg-white dark:bg-slate-900">
<body>
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
@@ -1,12 +0,0 @@
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
<div class="text-center mb-12">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
<span class="text-5xl font-medium">i</span>
</div>
</div>
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.not_active.title') %></h1>
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.not_active.description') %></p>
<div class="text-center my-8">
<p class="text-slate-600 dark:text-slate-400 text-sm"><%= I18n.t('public_portal.not_active.action') %></p>
</div>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.12.1'
version: '4.12.0'
development:
<<: *shared
+3 -3
View File
@@ -104,10 +104,10 @@
display_name: Audit Logs
enabled: false
premium: true
- name: custom_tools
display_name: Custom Tools
- name: response_bot
display_name: Response Bot
enabled: false
premium: true
deprecated: true
- name: message_reply_to
display_name: Message Reply To
enabled: false
+1 -2
View File
@@ -42,8 +42,7 @@ 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 },
41 => { name: 'Eesti keel (et)', iso_639_3_code: 'est', iso_639_1_code: 'et', enabled: true }
40 => { name: 'български (bg)', iso_639_3_code: 'bul', iso_639_1_code: 'bg', 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 }
+1 -1
View File
@@ -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', 'MutexApplicationJob::LockAcquisitionError']
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException']
# to track post data in sentry
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
+3 -14
View File
@@ -34,18 +34,7 @@ end
# https://github.com/ondrejbartas/sidekiq-cron
Rails.application.reloader.to_prepare do
# 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
# 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?
end
+1 -34
View File
@@ -91,7 +91,6 @@ dialogflow:
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
'region': { 'type': 'string' },
'language_code': { 'type': 'string' },
},
'required': ['project_id', 'credentials'],
'additionalProperties': false,
@@ -127,40 +126,8 @@ 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', 'language_code']
visible_properties: ['project_id', 'region']
google_translate:
id: google_translate
logo: google-translate.png
-5
View File
@@ -387,7 +387,6 @@ 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...
@@ -424,10 +423,6 @@ 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
-12
View File
@@ -134,18 +134,6 @@ 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: |
+1 -4
View File
@@ -72,9 +72,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :custom_tools do
post :test, on: :collection
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
@@ -509,7 +507,6 @@ Rails.application.routes.draw do
delete :destroy
end
end
resources :email_channel_migrations, only: [:create]
end
end
end
+8
View File
@@ -4,6 +4,7 @@
# 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'
@@ -49,6 +50,13 @@ 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 * * * *'
@@ -1,22 +0,0 @@
class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1]
def up
# The response_bot flag (deprecated) has been renamed to custom_tools.
# Disable it on any accounts that had response_bot enabled so the repurposed
# flag starts in its intended default-off state.
Account.feature_custom_tools.find_each(batch_size: 100) do |account|
account.disable_features(:custom_tools)
account.save!(validate: false)
end
# Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
# ConfigLoader only adds new flags; it never removes renamed ones.
# Leaving it would cause NoMethodError in enable_default_features when
# creating new accounts (feature_response_bot= no longer exists).
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |f| f['name'] == 'response_bot' }
config.save!
GlobalConfig.clear_cache
end
end
+1 -1
View File
@@ -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_24_102005) do
ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
-1
View File
@@ -15,7 +15,6 @@ TimeoutStopSec=30
KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
LimitNOFILE=65536
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
-1
View File
@@ -15,7 +15,6 @@ TimeoutStopSec=30
KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
LimitNOFILE=65536
MemoryMax=1.2G
MemoryHigh=infinity
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
MODEL_TYPE = ['AssistantResponse'].freeze
def create
@responses = process_bulk_action
@@ -28,8 +28,6 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
when 'AssistantDocument'
handle_documents
end
end
@@ -47,16 +45,6 @@ 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,19 +1,16 @@
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
@custom_tools = account_custom_tools.enabled
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
@@ -25,22 +22,8 @@ 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
@@ -49,11 +32,6 @@ 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,
@@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
render json: { error: result[:error] }, status: :unprocessable_entity
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
@@ -69,5 +69,3 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')
+5 -22
View File
@@ -24,10 +24,6 @@
# 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
@@ -35,10 +31,6 @@ 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': {
@@ -60,9 +52,8 @@ class Captain::CustomTool < ApplicationRecord
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
before_validation :generate_slug
before_create :ensure_within_limit
validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
validates :slug, presence: true, uniqueness: { scope: :account_id }
validates :title, presence: true
validates :endpoint_url, presence: true
validates_with JsonSchemaValidator,
@@ -82,29 +73,21 @@ 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?
parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
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 = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
return slug_candidate unless slug_exists?(slug_candidate)
end
+13 -18
View File
@@ -1,23 +1,15 @@
module Concerns::Toolable
extend ActiveSupport::Concern
# 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, **)
def tool(assistant)
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_slug = custom_tool_record.slug
tool_class = Class.new(base_class) do
tool_class = Class.new(Captain::Tools::HttpTool) 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'],
@@ -26,14 +18,17 @@ module Concerns::Toolable
end
end
# 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)
# 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)
tool_class.new(assistant, self, **)
tool_class.new(assistant, self)
end
def build_request_url(params)
@@ -1,7 +1,6 @@
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?('advanced_assignment') &&
account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
@@ -11,10 +11,6 @@ class Captain::CustomToolPolicy < ApplicationPolicy
@account_user.administrator?
end
def test?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
@@ -30,12 +30,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
private
def build_tools
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
return tools unless custom_tools_enabled?
tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
ct.tool(@assistant, base_class: Captain::Tools::CustomHttpTool, conversation: @conversation)
end
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
end
def system_message
@@ -43,24 +38,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
@assistant.name, @assistant.config['product_name'], @assistant.config,
contact: contact_attributes,
custom_tools: custom_tools_metadata
contact: contact_attributes
)
}
end
def custom_tools_metadata
return [] unless custom_tools_enabled?
@assistant.account.captain_custom_tools.enabled.map do |ct|
{ name: ct.slug, description: ct.description }
end
end
def custom_tools_enabled?
@assistant.account.feature_enabled?('custom_tools')
end
def contact_attributes
return nil unless @conversation&.contact
return nil unless @assistant&.feature_contact_attributes

Some files were not shown because too many files have changed in this diff Show More