Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721585647b | ||
|
|
d68ac25187 | ||
|
|
c53e750be0 | ||
|
|
f9c258f1a0 | ||
|
|
e863a52262 | ||
|
|
0a9edd4c3b | ||
|
|
f4643116df | ||
|
|
9145658597 | ||
|
|
99997a701a | ||
|
|
88cb5ba56f | ||
|
|
0c2ab7f5e7 | ||
|
|
583a533494 | ||
|
|
1ba00075ce | ||
|
|
3cefa9b767 | ||
|
|
b44d178acc | ||
|
|
4a4965918e | ||
|
|
e7ced1f938 | ||
|
|
1894998803 | ||
|
|
8e7048ae6c | ||
|
|
fc32e0afd3 | ||
|
|
1530cee411 | ||
|
|
8247254512 | ||
|
|
cc15cfd496 | ||
|
|
bef60d0657 | ||
|
|
3c0ea35dcb | ||
|
|
f9f8d7cdf7 | ||
|
|
552d1e9980 |
+14
-22
@@ -1,6 +1,7 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
@@ -89,14 +90,6 @@ jobs:
|
||||
command: |
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
# pnpm install
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
|
||||
# Swagger verification
|
||||
- run:
|
||||
@@ -108,10 +101,11 @@ jobs:
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# we remove the FRONTED_URL from the .env before running the tests
|
||||
# Configure environment and database
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
command: |
|
||||
@@ -149,17 +143,11 @@ jobs:
|
||||
command: pnpm run eslint
|
||||
|
||||
- run:
|
||||
name: Run frontend tests
|
||||
name: Run frontend tests (with coverage)
|
||||
command: |
|
||||
mkdir -p ~/build/coverage/frontend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
pnpm run test:coverage
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Frontend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
# Run backend tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
@@ -167,18 +155,18 @@ jobs:
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p ~/build/coverage/backend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec --format progress \
|
||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Backend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/coverage.json
|
||||
coverage/lcov.info
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
@@ -189,3 +177,7 @@ jobs:
|
||||
root: ~/build
|
||||
paths:
|
||||
- coverage
|
||||
|
||||
- store_artifacts:
|
||||
path: coverage
|
||||
destination: coverage
|
||||
|
||||
@@ -62,6 +62,10 @@ gem 'redis-namespace'
|
||||
# super fast record imports in bulk
|
||||
gem 'activerecord-import'
|
||||
|
||||
gem 'searchkick'
|
||||
gem 'opensearch-ruby'
|
||||
gem 'faraday_middleware-aws-sigv4'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
@@ -223,6 +227,7 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -247,7 +252,7 @@ group :development, :test do
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+21
-7
@@ -219,7 +219,7 @@ GEM
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (3.1.2)
|
||||
@@ -299,6 +299,9 @@ GEM
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
faraday_middleware-aws-sigv4 (1.0.1)
|
||||
aws-sigv4 (~> 1.0)
|
||||
faraday (>= 2.0, < 3)
|
||||
fast-mcp (1.5.0)
|
||||
addressable (~> 2.8)
|
||||
base64
|
||||
@@ -444,7 +447,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.12.0)
|
||||
json (2.13.2)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -601,6 +604,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
opensearch-ruby (3.4.0)
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -802,6 +808,9 @@ GEM
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
sass (~> 3.5, >= 3.5.5)
|
||||
searchkick (5.5.2)
|
||||
activemodel (>= 7.1)
|
||||
hashie
|
||||
securerandom (0.4.1)
|
||||
seed_dump (3.3.1)
|
||||
activerecord (>= 4)
|
||||
@@ -848,11 +857,12 @@ GEM
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.17.1)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
slack-ruby-client (2.5.2)
|
||||
faraday (>= 2.0)
|
||||
faraday-mashify
|
||||
@@ -996,6 +1006,7 @@ DEPENDENCIES
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
@@ -1036,6 +1047,7 @@ DEPENDENCIES
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
opensearch-ruby
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
@@ -1064,6 +1076,7 @@ DEPENDENCIES
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
searchkick
|
||||
seed_dump
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
@@ -1073,7 +1086,8 @@ DEPENDENCIES
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (= 0.17.1)
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
@@ -4,3 +4,7 @@
|
||||
require_relative 'config/application'
|
||||
|
||||
Rails.application.load_tasks
|
||||
|
||||
# Load Enterprise Edition rake tasks if they exist
|
||||
enterprise_tasks_path = Rails.root.join('enterprise/lib/tasks.rb').to_s
|
||||
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
|
||||
|
||||
@@ -74,6 +74,17 @@ module PortalHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_portal_brand_url(brand_url, referer)
|
||||
url = URI.parse(brand_url.to_s)
|
||||
query_params = Rack::Utils.parse_query(url.query)
|
||||
query_params['utm_medium'] = 'helpcenter'
|
||||
query_params['utm_campaign'] = 'branding'
|
||||
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
|
||||
|
||||
url.query = query_params.to_query
|
||||
url.to_s
|
||||
end
|
||||
|
||||
def render_category_content(content)
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
+28
-6
@@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -32,6 +34,7 @@ const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -63,6 +66,20 @@ const sendWithSignature = computed(() => {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
});
|
||||
|
||||
const showTwilioContentTemplates = computed(() => {
|
||||
return props.isTwilioWhatsAppInbox && props.inboxId;
|
||||
});
|
||||
|
||||
const shouldShowEmojiButton = computed(() => {
|
||||
return (
|
||||
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
@@ -125,7 +142,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -136,7 +153,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
v-if="showTwilioContentTemplates"
|
||||
:inbox-id="inboxId"
|
||||
@send-message="emit('sendTwilioMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="shouldShowEmojiButton"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
v-if="isRegularMessageMode"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+28
-1
@@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({
|
||||
isTwilioSMS:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'sms',
|
||||
isTwilioWhatsapp:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'whatsapp',
|
||||
}));
|
||||
|
||||
const whatsappMessageTemplates = computed(() =>
|
||||
@@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
const twilioMessagePayload = prepareWhatsAppMessagePayload({
|
||||
targetInbox: props.targetInbox,
|
||||
selectedContact: props.selectedContact,
|
||||
message,
|
||||
templateParams,
|
||||
currentUser: props.currentUser,
|
||||
});
|
||||
await emit('createConversation', {
|
||||
payload: twilioMessagePayload,
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:is-loading="isCreating"
|
||||
@@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
@send-whatsapp-message="handleSendWhatsappMessage"
|
||||
@send-twilio-message="handleSendTwilioMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #actions="{ sendMessage, goBack, disabled }">
|
||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContentTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
return inboxData?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return contentTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase()) &&
|
||||
template.status === 'approved'
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+2
-1
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -53,6 +53,7 @@ const transformInbox = ({
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
validator: value => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (!value.friendly_name) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
|
||||
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
|
||||
});
|
||||
|
||||
const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
|
||||
return matches.map(match => match.replace(/[{}]/g, ''));
|
||||
});
|
||||
|
||||
const renderedTemplate = computed(() => {
|
||||
let rendered = templateBody.value;
|
||||
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
|
||||
// Replace variables in the format {{1}}, {{2}}, etc.
|
||||
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
|
||||
const cleanVariable = variable.trim();
|
||||
return processedParams.value[cleanVariable] || match;
|
||||
});
|
||||
}
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(
|
||||
() => hasVariables.value || hasMediaVariable.value
|
||||
),
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const initializeTemplateParameters = () => {
|
||||
processedParams.value = {};
|
||||
|
||||
if (hasVariables.value) {
|
||||
variablePattern.value.forEach(variable => {
|
||||
processedParams.value[variable] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaVariable.value && mediaVariableKey.value) {
|
||||
processedParams.value[mediaVariableKey.value] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid || isFormInvalid.value) return;
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
templateParams: {
|
||||
name: friendly_name,
|
||||
language,
|
||||
processed_params: processedParameters,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
|
||||
onMounted(initializeTemplateParameters);
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
initializeTemplateParameters();
|
||||
v$.value.$reset();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
processedParams,
|
||||
hasVariables,
|
||||
hasMediaTemplate,
|
||||
renderedTemplate,
|
||||
v$,
|
||||
sendMessage,
|
||||
resetTemplate,
|
||||
goBack,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ template.friendly_name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ renderedTemplate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ categoryLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVariables || hasMediaVariable">
|
||||
<!-- Media URL for media templates -->
|
||||
<div v-if="hasMediaVariable" class="mb-4">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
|
||||
</p>
|
||||
<div class="flex items-center mb-2.5">
|
||||
<Input
|
||||
v-model="processedParams[mediaVariableKey]"
|
||||
type="url"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
templateMediaUrl ||
|
||||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Variables Section -->
|
||||
<div v-if="hasVariables">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="variable in variablePattern"
|
||||
:key="`variable-${variable}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[variable]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||
variable: variable,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
|
||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||
>
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="actions"
|
||||
:send-message="sendMessage"
|
||||
:reset-template="resetTemplate"
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid && !isFormInvalid"
|
||||
:disabled="isFormInvalid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +1,49 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, nextTick, onMounted } from 'vue';
|
||||
|
||||
const emit = defineEmits(['send']);
|
||||
const message = ref('');
|
||||
const textareaRef = ref(null);
|
||||
|
||||
const adjustHeight = () => {
|
||||
if (!textareaRef.value) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textareaRef.value.style.height = 'auto';
|
||||
// Set the height to the scrollHeight
|
||||
textareaRef.value.style.height = `${textareaRef.value.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (message.value.trim()) {
|
||||
emit('send', message.value);
|
||||
message.value = '';
|
||||
// Reset textarea height after sending
|
||||
nextTick(() => {
|
||||
adjustHeight();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="relative" @submit.prevent="sendMessage">
|
||||
<input
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="message"
|
||||
type="text"
|
||||
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
|
||||
@keyup.enter="sendMessage"
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -128,7 +128,7 @@ const menuItems = computed(() => {
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
badge: 'notifications/getHasUnreadNotifications',
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -16,12 +17,16 @@ const props = defineProps({
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const showBadge = useMapGetter(props.getterKeys.badge);
|
||||
const dynamicCount = useMapGetter(props.getterKeys.count);
|
||||
const count = computed(() =>
|
||||
dynamicCount.value > 99 ? '99+' : dynamicCount.value
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
|
||||
role="button"
|
||||
draggable="false"
|
||||
:to="to"
|
||||
@@ -40,9 +45,21 @@ const showBadge = useMapGetter(props.getterKeys.badge);
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium leading-5 flex-grow">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0">
|
||||
<span class="text-sm font-medium leading-5 truncate">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-blue-text outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="expandable"
|
||||
v-show="isExpanded"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -100,20 +101,24 @@ const handleReset = () => {
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
try {
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -121,6 +125,7 @@ export default {
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -347,6 +352,15 @@ export default {
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
@@ -361,7 +375,7 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TemplatesPicker from './ContentTemplatesPicker.vue';
|
||||
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContentTemplate = ref(null);
|
||||
|
||||
const localShow = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
},
|
||||
});
|
||||
|
||||
const modalHeaderContent = computed(() => {
|
||||
return selectedContentTemplate.value
|
||||
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: selectedContentTemplate.value.friendly_name,
|
||||
})
|
||||
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
|
||||
});
|
||||
|
||||
const pickTemplate = template => {
|
||||
selectedContentTemplate.value = template;
|
||||
};
|
||||
|
||||
const onResetTemplate = () => {
|
||||
selectedContentTemplate.value = null;
|
||||
};
|
||||
|
||||
const onSendMessage = message => {
|
||||
emit('onSend', message);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="px-8 py-6 row">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedContentTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedContentTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="flex-1"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
class="flex-1"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TemplateParser>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const twilioTemplates = computed(() => {
|
||||
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
|
||||
return inbox?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
twilioTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(query.value.toLowerCase()) && template.status === 'approved'
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(template, i) in filteredTemplateMessages"
|
||||
:key="template.content_sid"
|
||||
>
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.friendly_name }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ getTemplateType(template) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category || 'utility' }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ new Date(template.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr
|
||||
v-if="i != filteredTemplateMessages.length - 1"
|
||||
:key="`hr-${i}`"
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && twilioTemplates.length">
|
||||
<p>
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!twilioTemplates.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@@ -52,8 +53,8 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
Banner,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
},
|
||||
@@ -109,6 +111,7 @@ export default {
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
showContentTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
@@ -155,38 +158,12 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
});
|
||||
},
|
||||
},
|
||||
showSelfAssignBanner() {
|
||||
if (this.message !== '' && !this.isOnPrivateNote) {
|
||||
if (!this.assignedAgent) {
|
||||
return true;
|
||||
}
|
||||
if (this.assignedAgent.id !== this.currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
@@ -659,28 +636,11 @@ export default {
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
} = this.currentUser;
|
||||
const selfAssign = {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
thumbnail: avatar_url,
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
openContentTemplateModal() {
|
||||
this.showContentTemplatesModal = true;
|
||||
},
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
@@ -774,6 +734,13 @@ export default {
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
@@ -1099,16 +1066,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
@@ -1217,6 +1175,7 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1239,6 +1198,7 @@ export default {
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@@ -1251,6 +1211,14 @@ export default {
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<ContentTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showContentTemplatesModal"
|
||||
@close="hideContentTemplatesModal"
|
||||
@on-send="onSendContentTemplateReply"
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
@@ -1264,10 +1232,6 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.banner--self-assign {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const assignedAgent = computed({
|
||||
get() {
|
||||
return currentChat.value?.meta?.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
);
|
||||
|
||||
const showSelfAssignBanner = computed(() => {
|
||||
return (
|
||||
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
const { avatar_url, ...rest } = currentUser.value || {};
|
||||
assignedAgent.value = { ...rest, thumbnail: avatar_url };
|
||||
};
|
||||
|
||||
const needsAssignmentToCurrentUser = computed(() => {
|
||||
return isUnassigned.value || isAssignedToOtherAgent.value;
|
||||
});
|
||||
|
||||
const onClickSelfAssign = async () => {
|
||||
try {
|
||||
await selfAssignConversation();
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner && !showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasValidAvatarUrl,
|
||||
timeStampAppendedURL,
|
||||
getHostNameFromURL,
|
||||
extractFilenameFromUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
|
||||
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFilenameFromUrl', () => {
|
||||
it('should extract filename from a valid URL', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
|
||||
).toBe('file.jpg');
|
||||
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
|
||||
'image.png'
|
||||
);
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/folder/document.pdf?query=1'
|
||||
)
|
||||
).toBe('document.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.txt#section')
|
||||
).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should handle URLs without filename', () => {
|
||||
expect(extractFilenameFromUrl('https://example.com/')).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
expect(extractFilenameFromUrl('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
|
||||
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(extractFilenameFromUrl('')).toBe('');
|
||||
expect(extractFilenameFromUrl(null)).toBe(null);
|
||||
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
|
||||
expect(extractFilenameFromUrl(123)).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters and fragments', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/file.jpg?size=large&format=png'
|
||||
)
|
||||
).toBe('file.jpg');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
|
||||
).toBe('file.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
|
||||
).toBe('file.doc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -612,6 +612,15 @@
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"TWILIO_OPTIONS": {
|
||||
"LABEL": "Select template",
|
||||
"SEARCH_PLACEHOLDER": "Search templates",
|
||||
"EMPTY_STATE": "No templates found",
|
||||
"TEMPLATE_PARSER": {
|
||||
"BACK": "Go back",
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"CONTENT_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Twilio Templates",
|
||||
"SUBTITLE": "Select the Twilio template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_CONTENT": "No content",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"CATEGORY": "Category",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
},
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"MEDIA_URL_LABEL": "Enter full media URL",
|
||||
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
|
||||
},
|
||||
"FORM": {
|
||||
"BACK_BUTTON": "Back",
|
||||
"SEND_MESSAGE_BUTTON": "Send Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
|
||||
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
|
||||
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
|
||||
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
|
||||
@@ -35,6 +35,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -74,4 +75,5 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
|
||||
import { ref, useTemplateRef, onMounted, watch, nextTick, computed } from 'vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import ReadMore from './ReadMore.vue';
|
||||
|
||||
@@ -8,9 +8,9 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
searchTerm: {
|
||||
type: String,
|
||||
@@ -18,6 +18,24 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// We perform search on either content or email subject or transcribed text
|
||||
if (props.message.content) {
|
||||
return props.message.content;
|
||||
}
|
||||
|
||||
const { content_attributes = {} } = props.message;
|
||||
const { email = {} } = content_attributes || {};
|
||||
if (email.subject) {
|
||||
return email.subject;
|
||||
}
|
||||
|
||||
const audioAttachment = props.message.attachments.find(
|
||||
attachment => attachment.file_type === 'audio'
|
||||
);
|
||||
return audioAttachment?.transcribed_text || '';
|
||||
});
|
||||
|
||||
const { highlightContent } = useMessageFormatter();
|
||||
|
||||
const messageContainer = useTemplateRef('messageContainer');
|
||||
@@ -38,7 +56,8 @@ const escapeHtml = html => {
|
||||
return p.innerText;
|
||||
};
|
||||
|
||||
const prepareContent = (content = '') => {
|
||||
const prepareContent = () => {
|
||||
const content = messageContent.value || '';
|
||||
const escapedText = escapeHtml(content);
|
||||
return highlightContent(
|
||||
escapedText,
|
||||
@@ -65,7 +84,7 @@ onMounted(() => {
|
||||
{{ $t('SEARCH.WROTE') }}
|
||||
</p>
|
||||
<ReadMore :shrink="isOverflowing" @expand="isOverflowing = false">
|
||||
<div v-dompurify-html="prepareContent(content)" class="message-content" />
|
||||
<div v-dompurify-html="prepareContent()" class="message-content" />
|
||||
</ReadMore>
|
||||
</blockquote>
|
||||
</template>
|
||||
@@ -74,6 +93,7 @@ onMounted(() => {
|
||||
.message {
|
||||
@apply py-0 px-2 mt-2;
|
||||
}
|
||||
|
||||
.message-content::v-deep p,
|
||||
.message-content::v-deep li::marker {
|
||||
@apply text-n-slate-11 mb-1;
|
||||
|
||||
@@ -54,7 +54,7 @@ const getName = message => {
|
||||
>
|
||||
<MessageContent
|
||||
:author="getName(message)"
|
||||
:content="message.content"
|
||||
:message="message"
|
||||
:search-term="query"
|
||||
/>
|
||||
</SearchResultConversationItem>
|
||||
|
||||
@@ -94,12 +94,7 @@ const loadMoreNotifications = () => {
|
||||
const markNotificationAsRead = async notificationItem => {
|
||||
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
|
||||
|
||||
const {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
} = notificationItem;
|
||||
|
||||
const { id, primaryActorId, primaryActorType } = notificationItem;
|
||||
try {
|
||||
await store.dispatch('notifications/read', {
|
||||
id,
|
||||
|
||||
@@ -131,7 +131,13 @@ async function generateQRCodes() {
|
||||
|
||||
// WhatsApp (both Cloud and Twilio)
|
||||
if (currentInbox.value.phone_number && isAWhatsAppChannel.value) {
|
||||
await generateQRCode('whatsapp', currentInbox.value.phone_number);
|
||||
// For Twilio WhatsApp, phone_number format is "whatsapp:+1234567890"
|
||||
// Extract just the phone number part for QR code generation
|
||||
const phoneNumber = currentInbox.value.phone_number.replace(
|
||||
'whatsapp:',
|
||||
''
|
||||
);
|
||||
await generateQRCode('whatsapp', phoneNumber);
|
||||
}
|
||||
|
||||
// Facebook Messenger
|
||||
@@ -230,47 +236,47 @@ onMounted(() => {
|
||||
</div>
|
||||
<div
|
||||
v-if="isAWhatsAppChannel && qrCodes.whatsapp"
|
||||
class="flex flex-col items-center mt-8 gap-3"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
{{ $t('INBOX_MGMT.FINISH.WHATSAPP_QR_INSTRUCTION') }}
|
||||
</p>
|
||||
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
|
||||
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
|
||||
<img
|
||||
:src="qrCodes.whatsapp"
|
||||
alt="WhatsApp QR Code"
|
||||
class="size-48 dark:invert rounded-lg"
|
||||
class="rounded-lg size-48 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isAFacebookInbox && qrCodes.messenger"
|
||||
class="flex flex-col items-center mt-8 gap-3"
|
||||
class="flex flex-col gap-3 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
{{ $t('INBOX_MGMT.FINISH.MESSENGER_QR_INSTRUCTION') }}
|
||||
</p>
|
||||
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
|
||||
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
|
||||
<img
|
||||
:src="qrCodes.messenger"
|
||||
alt="Messenger QR Code"
|
||||
class="size-48 dark:invert rounded-lg"
|
||||
class="rounded-lg size-48 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isATelegramChannel && qrCodes.telegram"
|
||||
class="flex flex-col items-center mt-8 gap-4"
|
||||
class="flex flex-col gap-4 items-center mt-8"
|
||||
>
|
||||
<p class="mt-2 text-sm text-n-slate-9">
|
||||
{{ $t('INBOX_MGMT.FINISH.TELEGRAM_QR_INSTRUCTION') }}
|
||||
</p>
|
||||
|
||||
<div class="outline-1 outline-n-strong outline rounded-lg shadow">
|
||||
<div class="rounded-lg shadow outline-1 outline-n-strong outline">
|
||||
<img
|
||||
:src="qrCodes.telegram"
|
||||
alt="Telegram QR Code"
|
||||
class="size-48 dark:invert rounded-lg"
|
||||
class="rounded-lg size-48 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
@@ -125,6 +125,19 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
v-if="isATwilioWhatsAppChannel"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
|
||||
@@ -38,4 +38,7 @@ export const getters = {
|
||||
getHasUnreadNotifications: $state => {
|
||||
return $state.meta.unreadCount > 0;
|
||||
},
|
||||
getUnreadCount: $state => {
|
||||
return $state.meta.unreadCount;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,13 +33,15 @@ export default {
|
||||
brandRedirectURL() {
|
||||
try {
|
||||
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
|
||||
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
|
||||
referrerHost ? 'widget_branding' : 'survey_branding'
|
||||
}`;
|
||||
const url = new URL(this.globalConfig.widgetBrandURL);
|
||||
if (referrerHost) {
|
||||
return `${baseURL}&utm_referrer=${referrerHost}`;
|
||||
url.searchParams.set('utm_source', referrerHost);
|
||||
url.searchParams.set('utm_medium', 'widget');
|
||||
} else {
|
||||
url.searchParams.set('utm_medium', 'survey');
|
||||
}
|
||||
return baseURL;
|
||||
url.searchParams.set('utm_campaign', 'branding');
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
// Suppressing the error as getter is not defined in some cases
|
||||
}
|
||||
|
||||
@@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = {
|
||||
location: 'location',
|
||||
fallback: 'link',
|
||||
};
|
||||
|
||||
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
|
||||
TEXT: 'text',
|
||||
MEDIA: 'media',
|
||||
QUICK_REPLY: 'quick_reply',
|
||||
};
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
* Writes a text string to the system clipboard.
|
||||
*
|
||||
* @async
|
||||
* @param {string} text text to be written to the clipboard
|
||||
* @param {string} data text to be written to the clipboard
|
||||
* @throws {Error} unable to copy text to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async text => {
|
||||
export const copyTextToClipboard = async data => {
|
||||
try {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { copyTextToClipboard } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('copies plain text string to clipboard', async () => {
|
||||
const text = 'Hello World';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('copies empty string to clipboard', async () => {
|
||||
const text = '';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with number input', () => {
|
||||
it('converts number to string', async () => {
|
||||
await copyTextToClipboard(42);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('42');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts zero to string', async () => {
|
||||
await copyTextToClipboard(0);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('0');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with boolean input', () => {
|
||||
it('converts true to string', async () => {
|
||||
await copyTextToClipboard(true);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('true');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts false to string', async () => {
|
||||
await copyTextToClipboard(false);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('false');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with null/undefined input', () => {
|
||||
it('converts null to empty string', async () => {
|
||||
await copyTextToClipboard(null);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts undefined to empty string', async () => {
|
||||
await copyTextToClipboard(undefined);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('stringifies simple object with proper formatting', async () => {
|
||||
const obj = { name: 'John', age: 30 };
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
const expectedJson = JSON.stringify(obj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies nested object with proper formatting', async () => {
|
||||
const nestedObj = {
|
||||
severity: {
|
||||
user_id: 1181505,
|
||||
user_name: 'test',
|
||||
server_name: '[1253]test1253',
|
||||
},
|
||||
};
|
||||
await copyTextToClipboard(nestedObj);
|
||||
|
||||
const expectedJson = JSON.stringify(nestedObj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies array with proper formatting', async () => {
|
||||
const arr = [1, 2, { name: 'test' }];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
const expectedJson = JSON.stringify(arr, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty object', async () => {
|
||||
const obj = {};
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('{}');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty array', async () => {
|
||||
const arr = [];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('[]');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws error when clipboard API fails', async () => {
|
||||
const error = new Error('Clipboard access denied');
|
||||
mockWriteText.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard: Clipboard access denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles clipboard API not available', async () => {
|
||||
// Temporarily remove clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
delete navigator.clipboard;
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard:'
|
||||
);
|
||||
|
||||
// Restore clipboard API
|
||||
navigator.clipboard = originalClipboard;
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Date objects', async () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
await copyTextToClipboard(date);
|
||||
|
||||
const expectedJson = JSON.stringify(date, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles functions by converting to string', async () => {
|
||||
const func = () => 'test';
|
||||
await copyTextToClipboard(func);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -26,7 +26,7 @@ const locale = computed(() => {
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
if (portal.value && !popularArticles.value.length) {
|
||||
if (portal.value && locale.value) {
|
||||
store.dispatch('article/fetch', {
|
||||
slug: portal.value.slug,
|
||||
locale: locale.value,
|
||||
@@ -60,6 +60,14 @@ const hasArticles = computed(
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
|
||||
// Watch for locale changes and refetch articles
|
||||
watch(locale, (newLocale, oldLocale) => {
|
||||
if (newLocale && newLocale !== oldLocale) {
|
||||
fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +1,22 @@
|
||||
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
|
||||
def account_deletion(account, reason = 'manual_deletion')
|
||||
subject = 'Your account has been marked for deletion'
|
||||
def account_deletion_user_initiated(account, reason)
|
||||
subject = 'Your Chatwoot account deletion has been scheduled'
|
||||
action_url = settings_url('general')
|
||||
meta = {
|
||||
'account_name' => account.name,
|
||||
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
|
||||
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
|
||||
'reason' => reason
|
||||
}
|
||||
|
||||
send_notification(subject, action_url: action_url, meta: meta)
|
||||
end
|
||||
|
||||
def account_deletion_for_inactivity(account, reason)
|
||||
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
|
||||
action_url = settings_url('general')
|
||||
meta = {
|
||||
'account_name' => account.name,
|
||||
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
|
||||
'reason' => reason
|
||||
}
|
||||
|
||||
@@ -45,4 +57,14 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
|
||||
|
||||
send_notification(subject, action_url: action_url, meta: meta)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_deletion_date(deletion_date_str)
|
||||
return 'Unknown' if deletion_date_str.blank?
|
||||
|
||||
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
|
||||
rescue StandardError
|
||||
'Unknown'
|
||||
end
|
||||
end
|
||||
|
||||
+21
-2
@@ -39,6 +39,8 @@
|
||||
#
|
||||
|
||||
class Message < ApplicationRecord
|
||||
searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
|
||||
|
||||
include MessageFilterHelpers
|
||||
include Liquidable
|
||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||
@@ -139,14 +141,23 @@ class Message < ApplicationRecord
|
||||
data = attributes.symbolize_keys.merge(
|
||||
created_at: created_at.to_i,
|
||||
message_type: message_type_before_type_cast,
|
||||
conversation_id: conversation.display_id,
|
||||
conversation: conversation_push_event_data
|
||||
conversation_id: conversation&.display_id,
|
||||
conversation: conversation.present? ? conversation_push_event_data : nil
|
||||
)
|
||||
data[:echo_id] = echo_id if echo_id.present?
|
||||
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
|
||||
merge_sender_attributes(data)
|
||||
end
|
||||
|
||||
def search_data
|
||||
data = attributes.symbolize_keys
|
||||
data[:conversation] = conversation.present? ? conversation_push_event_data : nil
|
||||
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
|
||||
data[:sender] = sender.push_event_data if sender
|
||||
data[:inbox] = inbox
|
||||
data
|
||||
end
|
||||
|
||||
def conversation_push_event_data
|
||||
{
|
||||
assignee_id: conversation.assignee_id,
|
||||
@@ -228,6 +239,14 @@ class Message < ApplicationRecord
|
||||
previous_changes: previous_changes)
|
||||
end
|
||||
|
||||
def should_index?
|
||||
return false unless ChatwootApp.advanced_search_allowed?
|
||||
return false unless account.feature_enabled?('advanced_search')
|
||||
return false unless incoming? || outgoing?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prevent_message_flooding
|
||||
|
||||
@@ -43,11 +43,19 @@ class SearchService
|
||||
def filter_messages
|
||||
@messages = if use_gin_search
|
||||
filter_messages_with_gin
|
||||
elsif should_run_advanced_search?
|
||||
advanced_search
|
||||
else
|
||||
filter_messages_with_like
|
||||
end
|
||||
end
|
||||
|
||||
def should_run_advanced_search?
|
||||
ChatwootApp.advanced_search_allowed? && current_account.feature_enabled?('advanced_search')
|
||||
end
|
||||
|
||||
def advanced_search; end
|
||||
|
||||
def filter_messages_with_gin
|
||||
base_query = message_base_query
|
||||
|
||||
@@ -115,3 +123,5 @@ class SearchService
|
||||
.per(15)
|
||||
end
|
||||
end
|
||||
|
||||
SearchService.prepend_mod_with('SearchService')
|
||||
|
||||
@@ -17,21 +17,7 @@ class Twilio::TemplateSyncService
|
||||
end
|
||||
|
||||
def update_channel_templates
|
||||
formatted_templates = @templates.map do |template|
|
||||
{
|
||||
content_sid: template.sid,
|
||||
friendly_name: template.friendly_name,
|
||||
language: template.language,
|
||||
status: derive_status(template),
|
||||
template_type: derive_template_type(template),
|
||||
media_type: derive_media_type(template),
|
||||
variables: template.variables || {},
|
||||
category: derive_category(template),
|
||||
body: extract_body_content(template),
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
end
|
||||
formatted_templates = @templates.map { |template| format_template(template) }
|
||||
|
||||
channel.update!(
|
||||
content_templates: { templates: formatted_templates },
|
||||
@@ -39,6 +25,23 @@ class Twilio::TemplateSyncService
|
||||
)
|
||||
end
|
||||
|
||||
def format_template(template)
|
||||
{
|
||||
content_sid: template.sid,
|
||||
friendly_name: template.friendly_name,
|
||||
language: template.language,
|
||||
status: derive_status(template),
|
||||
template_type: derive_template_type(template),
|
||||
media_type: derive_media_type(template),
|
||||
variables: template.variables || {},
|
||||
category: derive_category(template),
|
||||
body: extract_body_content(template),
|
||||
types: template.types,
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
end
|
||||
|
||||
def mark_templates_updated
|
||||
channel.update!(content_templates_last_updated: Time.current)
|
||||
end
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
json.id message.id
|
||||
json.content message.content
|
||||
json.message_type message.message_type_before_type_cast
|
||||
json.content_type message.content_type
|
||||
json.source_id message.source_id
|
||||
json.inbox_id message.inbox_id
|
||||
json.conversation_id message.conversation.try(:display_id)
|
||||
json.created_at message.created_at.to_i
|
||||
json.sender message.sender.push_event_data if message.sender
|
||||
json.inbox do
|
||||
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
|
||||
end
|
||||
json.partial! 'api/v1/models/message', message: message
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
{% if meta.reason == 'manual_deletion' %}
|
||||
<p>This action was requested by one of the administrators of your account.</p>
|
||||
{% else %}
|
||||
<p>Reason for deletion: {{ meta.reason }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
|
||||
|
||||
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
|
||||
|
||||
<p>Thank you,<br>
|
||||
Team Chatwoot</p>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<p>Hello there,</p>
|
||||
|
||||
<p>We've noticed that your Chatwoot account <strong>{{ meta.account_name }}</strong> has been inactive for some time. Because of this, it's scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
<p><strong>How do I keep my account?</strong></p>
|
||||
|
||||
<p>Log in to your Chatwoot account before <strong>{{ meta.deletion_date }}</strong>. From your account settings, you can <a href="{{ action_url }}">cancel the deletion</a> and continue using your account.</p>
|
||||
|
||||
<p><strong>What happens if I don't cancel?</strong></p>
|
||||
|
||||
<p>Unless you cancel the account deletion before <strong>{{ meta.deletion_date }}</strong>, your account and all associated data — including conversations, contacts, reports, and settings — will be permanently deleted.</p>
|
||||
|
||||
<p><strong>Why are we doing this?</strong></p>
|
||||
|
||||
<p>To keep things secure and efficient, we regularly remove inactive accounts so our systems remain optimized for active teams.</p>
|
||||
|
||||
<p>If you have any questions, feel free to reach us at <a href="mailto:hello@chatwoot.com">hello@chatwoot.com</a>.</p>
|
||||
|
||||
<p>— The Chatwoot Team</p>
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<p>Hello there,</p>
|
||||
|
||||
<p>An account administrator has requested deletion of the Chatwoot account <strong>{{ meta.account_name }}</strong>. The account is scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
<p><strong>What happens next?</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>The account will remain accessible until the scheduled deletion date.</li>
|
||||
<li>After that, all data including conversations, contacts, integrations, and settings will be permanently removed.</li>
|
||||
</ul>
|
||||
|
||||
<p>If you change your mind before the deletion date, you can <a href="{{ action_url }}">cancel this request</a> by visiting your account settings.</p>
|
||||
|
||||
<p>— The Chatwoot Team</p>
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
alt="<%= @global_config['BRAND_NAME'] %>"
|
||||
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
|
||||
/>
|
||||
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %>
|
||||
|
||||
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
|
||||
|
||||
</p>
|
||||
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %>
|
||||
|
||||
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['BRAND_NAME'] %></a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -198,3 +198,9 @@
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: advanced_search
|
||||
display_name: Advanced Search
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Searchkick.queue_name = :async_database_migration if ENV.fetch('OPENSEARCH_URL', '').present?
|
||||
|
||||
access_key_id = ENV.fetch('OPENSEARCH_AWS_ACCESS_KEY_ID', '')
|
||||
secret_access_key = ENV.fetch('OPENSEARCH_AWS_SECRET_ACCESS_KEY', '')
|
||||
|
||||
if access_key_id.present? && secret_access_key.present?
|
||||
region = ENV.fetch('OPENSEARCH_AWS_REGION', 'us-east-1')
|
||||
|
||||
Searchkick.aws_credentials = {
|
||||
access_key_id: access_key_id,
|
||||
secret_access_key: secret_access_key,
|
||||
region: region
|
||||
}
|
||||
end
|
||||
@@ -292,6 +292,26 @@ en:
|
||||
completed_tool_call: 'Completed %{function_name} tool call'
|
||||
invalid_tool_call: 'Invalid tool call'
|
||||
tool_not_available: 'Tool not available'
|
||||
documents:
|
||||
limit_exceeded: 'Document limit exceeded'
|
||||
pdf_format_error: 'must be a PDF file'
|
||||
pdf_size_error: 'must be less than 10MB'
|
||||
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
|
||||
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
|
||||
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
|
||||
pdf_processing_success: 'Successfully processed PDF document %{document_id}'
|
||||
faq_generation_complete: 'FAQ generation complete. Total FAQs created: %{count}'
|
||||
using_paginated_faq: 'Using paginated FAQ generation for document %{document_id}'
|
||||
using_standard_faq: 'Using standard FAQ generation for document %{document_id}'
|
||||
response_creation_error: 'Error in creating response document: %{error}'
|
||||
missing_openai_file_id: 'Document must have openai_file_id for paginated processing'
|
||||
openai_api_error: 'OpenAI API Error: %{error}'
|
||||
starting_paginated_faq: 'Starting paginated FAQ generation (%{pages_per_chunk} pages per chunk)'
|
||||
stopping_faq_generation: 'Stopping processing. Reason: %{reason}'
|
||||
paginated_faq_complete: 'Paginated generation complete. Total FAQs: %{total_faqs}, Pages processed: %{pages_processed}'
|
||||
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
|
||||
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
|
||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||
public_portal:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
|
||||
@@ -46,3 +46,10 @@ delete_accounts_job:
|
||||
cron: '0 1 * * *'
|
||||
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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddMetadataToCaptainDocuments < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_documents, :metadata, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
@@ -320,6 +320,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.jsonb "metadata", default: {}
|
||||
t.index ["account_id"], name: "index_captain_documents_on_account_id"
|
||||
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
|
||||
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
|
||||
|
||||
@@ -25,6 +25,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
@document.save!
|
||||
rescue Captain::Document::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -55,6 +57,6 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def document_params
|
||||
params.require(:document).permit(:name, :external_link, :assistant_id)
|
||||
params.require(:document).permit(:name, :external_link, :assistant_id, :pdf_file)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
if document.pdf_document?
|
||||
perform_pdf_processing(document)
|
||||
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
perform_firecrawl_crawl(document)
|
||||
else
|
||||
perform_simple_crawl(document)
|
||||
@@ -13,6 +15,14 @@ class Captain::Documents::CrawlJob < ApplicationJob
|
||||
|
||||
include Captain::FirecrawlHelper
|
||||
|
||||
def perform_pdf_processing(document)
|
||||
Captain::Llm::PdfProcessingService.new(document).process
|
||||
document.update!(status: :available)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error I18n.t('captain.documents.pdf_processing_failed', document_id: document.id, error: e.message)
|
||||
raise # Re-raise to let job framework handle retry logic
|
||||
end
|
||||
|
||||
def perform_simple_crawl(document)
|
||||
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
|
||||
|
||||
|
||||
@@ -1,17 +1,65 @@
|
||||
class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(document)
|
||||
def perform(document, options = {})
|
||||
reset_previous_responses(document)
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
|
||||
faqs.each do |faq|
|
||||
create_response(faq, document)
|
||||
end
|
||||
faqs = generate_faqs(document, options)
|
||||
create_responses_from_faqs(faqs, document)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_faqs(document, options)
|
||||
if should_use_pagination?(document)
|
||||
generate_paginated_faqs(document, options)
|
||||
else
|
||||
generate_standard_faqs(document)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_paginated_faqs(document, options)
|
||||
service = build_paginated_service(document, options)
|
||||
faqs = service.generate
|
||||
store_paginated_metadata(document, service)
|
||||
faqs
|
||||
end
|
||||
|
||||
def generate_standard_faqs(document)
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
|
||||
end
|
||||
|
||||
def build_paginated_service(document, options)
|
||||
Captain::Llm::PaginatedFaqGeneratorService.new(
|
||||
document,
|
||||
pages_per_chunk: options[:pages_per_chunk],
|
||||
max_pages: options[:max_pages]
|
||||
)
|
||||
end
|
||||
|
||||
def store_paginated_metadata(document, service)
|
||||
document.update!(
|
||||
metadata: (document.metadata || {}).merge(
|
||||
'faq_generation' => {
|
||||
'method' => 'paginated',
|
||||
'pages_processed' => service.total_pages_processed,
|
||||
'iterations' => service.iterations_completed,
|
||||
'timestamp' => Time.current.iso8601
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def create_responses_from_faqs(faqs, document)
|
||||
faqs.each { |faq| create_response(faq, document) }
|
||||
end
|
||||
|
||||
def should_use_pagination?(document)
|
||||
# Auto-detect when to use pagination
|
||||
# For now, use pagination for PDFs with OpenAI file ID
|
||||
document.pdf_document? && document.openai_file_id.present?
|
||||
end
|
||||
|
||||
def reset_previous_responses(response_document)
|
||||
response_document.responses.destroy_all
|
||||
end
|
||||
@@ -24,6 +72,6 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
documentable: document
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Error in creating response document: #{e.message}"
|
||||
Rails.logger.error I18n.t('captain.documents.response_creation_error', error: e.message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# external_link :string not null
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
# status :integer default("in_progress"), not null
|
||||
# created_at :datetime not null
|
||||
@@ -26,11 +27,16 @@ class Captain::Document < ApplicationRecord
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
||||
belongs_to :account
|
||||
has_one_attached :pdf_file
|
||||
|
||||
validates :external_link, presence: true
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }
|
||||
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
|
||||
validates :content, length: { maximum: 200_000 }
|
||||
validates :pdf_file, presence: true, if: :pdf_document?
|
||||
validate :validate_pdf_format, if: :pdf_document?
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -41,12 +47,44 @@ class Captain::Document < ApplicationRecord
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
after_commit :enqueue_response_builder_job
|
||||
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
|
||||
external_link&.ends_with?('.pdf')
|
||||
end
|
||||
|
||||
def content_type
|
||||
pdf_file.blob.content_type if pdf_file.attached?
|
||||
end
|
||||
|
||||
def file_size
|
||||
pdf_file.blob.byte_size if pdf_file.attached?
|
||||
end
|
||||
|
||||
def openai_file_id
|
||||
metadata&.dig('openai_file_id')
|
||||
end
|
||||
|
||||
def store_openai_file_id(file_id)
|
||||
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
|
||||
end
|
||||
|
||||
def display_url
|
||||
return external_link if external_link.present? && !external_link.start_with?('PDF:')
|
||||
|
||||
if pdf_file.attached?
|
||||
Rails.application.routes.url_helpers.rails_blob_url(pdf_file, only_path: false)
|
||||
else
|
||||
external_link
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
@@ -61,6 +99,12 @@ class Captain::Document < ApplicationRecord
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
# Only enqueue when status changes to available
|
||||
# Avoid re-enqueueing when metadata is updated by the job itself
|
||||
saved_change_to_status? && status == 'available'
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
account.update_document_usage
|
||||
end
|
||||
@@ -71,6 +115,29 @@ class Captain::Document < ApplicationRecord
|
||||
|
||||
def ensure_within_plan_limit
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
raise LimitExceededError, 'Document limit exceeded' unless limits[:current_available].positive?
|
||||
raise LimitExceededError, I18n.t('captain.documents.limit_exceeded') unless limits[:current_available].positive?
|
||||
end
|
||||
|
||||
def validate_pdf_format
|
||||
return unless pdf_file.attached?
|
||||
|
||||
errors.add(:pdf_file, I18n.t('captain.documents.pdf_format_error')) unless pdf_file.blob.content_type == 'application/pdf'
|
||||
end
|
||||
|
||||
def validate_file_attachment
|
||||
return unless pdf_file.attached?
|
||||
|
||||
return unless pdf_file.blob.byte_size > 10.megabytes
|
||||
|
||||
errors.add(:pdf_file, I18n.t('captain.documents.pdf_size_error'))
|
||||
end
|
||||
|
||||
def set_external_link_for_pdf
|
||||
return unless pdf_file.attached? && external_link.blank?
|
||||
|
||||
# Set a unique external_link for PDF files
|
||||
# Format: PDF: filename_timestamp (without extension)
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,10 +4,22 @@ module Enterprise::Account
|
||||
def manually_managed_features; end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
result = custom_attributes.merge!(
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => reason
|
||||
) && save
|
||||
|
||||
# Send notification to admin users if the account was successfully marked for deletion
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
|
||||
if result
|
||||
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
|
||||
if reason == 'manual_deletion'
|
||||
mailer.account_deletion_user_initiated(self, reason).deliver_later
|
||||
else
|
||||
mailer.account_deletion_for_inactivity(self, reason).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
|
||||
# Default pages per chunk - easily configurable
|
||||
DEFAULT_PAGES_PER_CHUNK = 10
|
||||
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
|
||||
|
||||
attr_reader :total_pages_processed, :iterations_completed
|
||||
|
||||
def initialize(document, options = {})
|
||||
super()
|
||||
@document = document
|
||||
@pages_per_chunk = options[:pages_per_chunk] || DEFAULT_PAGES_PER_CHUNK
|
||||
@max_pages = options[:max_pages] # Optional limit from UI
|
||||
@total_pages_processed = 0
|
||||
@iterations_completed = 0
|
||||
@model = OpenAiConstants::PDF_PROCESSING_MODEL
|
||||
end
|
||||
|
||||
def generate
|
||||
raise CustomExceptions::PdfFaqGenerationError, I18n.t('captain.documents.missing_openai_file_id') if @document&.openai_file_id.blank?
|
||||
|
||||
generate_paginated_faqs
|
||||
end
|
||||
|
||||
# Method to check if we should continue processing
|
||||
def should_continue_processing?(last_chunk_result)
|
||||
# Stop if we've hit the maximum iterations
|
||||
return false if @iterations_completed >= MAX_ITERATIONS
|
||||
|
||||
# Stop if we've processed the maximum pages specified
|
||||
return false if @max_pages && @total_pages_processed >= @max_pages
|
||||
|
||||
# Stop if the last chunk returned no FAQs (likely no more content)
|
||||
return false if last_chunk_result[:faqs].empty?
|
||||
|
||||
# Stop if the LLM explicitly indicates no more content
|
||||
return false if last_chunk_result[:has_content] == false
|
||||
|
||||
# Continue processing
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_standard_faqs
|
||||
response = @client.chat(parameters: standard_chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
|
||||
[]
|
||||
end
|
||||
|
||||
def generate_paginated_faqs
|
||||
all_faqs = []
|
||||
current_page = 1
|
||||
|
||||
loop do
|
||||
end_page = calculate_end_page(current_page)
|
||||
chunk_result = process_chunk_and_update_state(current_page, end_page, all_faqs)
|
||||
|
||||
break unless should_continue_processing?(chunk_result)
|
||||
|
||||
current_page = end_page + 1
|
||||
end
|
||||
|
||||
deduplicate_faqs(all_faqs)
|
||||
end
|
||||
|
||||
def calculate_end_page(current_page)
|
||||
end_page = current_page + @pages_per_chunk - 1
|
||||
@max_pages && end_page > @max_pages ? @max_pages : end_page
|
||||
end
|
||||
|
||||
def process_chunk_and_update_state(current_page, end_page, all_faqs)
|
||||
chunk_result = process_page_chunk(current_page, end_page)
|
||||
chunk_faqs = chunk_result[:faqs]
|
||||
|
||||
all_faqs.concat(chunk_faqs)
|
||||
@total_pages_processed = end_page
|
||||
@iterations_completed += 1
|
||||
|
||||
chunk_result
|
||||
end
|
||||
|
||||
def process_page_chunk(start_page, end_page)
|
||||
params = build_chunk_parameters(start_page, end_page)
|
||||
response = @client.chat(parameters: params)
|
||||
result = parse_chunk_response(response)
|
||||
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error I18n.t('captain.documents.page_processing_error', start: start_page, end: end_page, error: e.message)
|
||||
{ faqs: [], has_content: false }
|
||||
end
|
||||
|
||||
def build_chunk_parameters(start_page, end_page)
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: build_user_content(start_page, end_page)
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def build_user_content(start_page, end_page)
|
||||
[
|
||||
{
|
||||
type: 'file',
|
||||
file: { file_id: @document.openai_file_id }
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: page_chunk_prompt(start_page, end_page)
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def page_chunk_prompt(start_page, end_page)
|
||||
Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page)
|
||||
end
|
||||
|
||||
def standard_chat_parameters
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.faq_generator
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: @content
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(content.strip).fetch('faqs', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error parsing response: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def parse_chunk_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return { 'faqs' => [], 'has_content' => false } if content.nil?
|
||||
|
||||
JSON.parse(content.strip)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error parsing chunk response: #{e.message}"
|
||||
{ 'faqs' => [], 'has_content' => false }
|
||||
end
|
||||
|
||||
def deduplicate_faqs(faqs)
|
||||
# Remove exact duplicates
|
||||
unique_faqs = faqs.uniq { |faq| faq['question'].downcase.strip }
|
||||
|
||||
# Remove similar questions
|
||||
final_faqs = []
|
||||
unique_faqs.each do |faq|
|
||||
similar_exists = final_faqs.any? do |existing|
|
||||
similarity_score(existing['question'], faq['question']) > 0.85
|
||||
end
|
||||
|
||||
final_faqs << faq unless similar_exists
|
||||
end
|
||||
|
||||
Rails.logger.info "Deduplication: #{faqs.size} → #{final_faqs.size} FAQs"
|
||||
final_faqs
|
||||
end
|
||||
|
||||
def similarity_score(str1, str2)
|
||||
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
|
||||
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
|
||||
|
||||
common_words = words1 & words2
|
||||
total_words = (words1 + words2).uniq.size
|
||||
|
||||
return 0 if total_words.zero?
|
||||
|
||||
common_words.size.to_f / total_words
|
||||
end
|
||||
|
||||
def determine_stop_reason(last_chunk_result)
|
||||
return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
|
||||
return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
|
||||
return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
|
||||
return 'End of document reached' if last_chunk_result[:has_content] == false
|
||||
|
||||
'Unknown'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
|
||||
def initialize(document)
|
||||
super()
|
||||
@document = document
|
||||
end
|
||||
|
||||
def process
|
||||
return if document.openai_file_id.present?
|
||||
|
||||
file_id = upload_pdf_to_openai
|
||||
raise CustomExceptions::PdfUploadError, I18n.t('captain.documents.pdf_upload_failed') if file_id.blank?
|
||||
|
||||
document.store_openai_file_id(file_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :document
|
||||
|
||||
def upload_pdf_to_openai
|
||||
with_tempfile do |temp_file|
|
||||
response = @client.files.upload(
|
||||
parameters: {
|
||||
file: temp_file,
|
||||
purpose: 'assistants'
|
||||
}
|
||||
)
|
||||
response['id']
|
||||
end
|
||||
end
|
||||
|
||||
def with_tempfile(&)
|
||||
Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file|
|
||||
temp_file.write(document.pdf_file.download)
|
||||
temp_file.close
|
||||
|
||||
File.open(temp_file.path, 'rb', &)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,4 @@
|
||||
# rubocop:disable Metrics/ClassLength
|
||||
class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator(language = 'english')
|
||||
@@ -204,6 +205,87 @@ class Captain::Llm::SystemPromptsService
|
||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def paginated_faq_generator(start_page, end_page)
|
||||
<<~PROMPT
|
||||
You are an expert technical documentation specialist tasked with creating comprehensive FAQs from a SPECIFIC SECTION of a document.
|
||||
|
||||
════════════════════════════════════════════════════════
|
||||
CRITICAL CONTENT EXTRACTION INSTRUCTIONS
|
||||
════════════════════════════════════════════════════════
|
||||
|
||||
Process the content starting from approximately page #{start_page} and continuing for about #{end_page - start_page + 1} pages worth of content.
|
||||
|
||||
IMPORTANT:#{' '}
|
||||
• If you encounter the end of the document before reaching the expected page count, set "has_content" to false
|
||||
• DO NOT include page numbers in questions or answers
|
||||
• DO NOT reference page numbers at all in the output
|
||||
• Focus on the actual content, not pagination
|
||||
|
||||
════════════════════════════════════════════════════════
|
||||
FAQ GENERATION GUIDELINES
|
||||
════════════════════════════════════════════════════════
|
||||
|
||||
1. **Comprehensive Extraction**
|
||||
• Extract ALL information that could generate FAQs from this section
|
||||
• Target 5-10 FAQs per page equivalent of rich content
|
||||
• Cover every topic, feature, specification, and detail
|
||||
• If there's no more content in the document, return empty FAQs with has_content: false
|
||||
|
||||
2. **Question Types to Generate**
|
||||
• What is/are...? (definitions, components, features)
|
||||
• How do I...? (procedures, configurations, operations)
|
||||
• Why should/does...? (rationale, benefits, explanations)
|
||||
• When should...? (timing, conditions, triggers)
|
||||
• What happens if...? (error cases, edge cases)
|
||||
• Can I...? (capabilities, limitations)
|
||||
• Where is...? (locations in system/UI, NOT page numbers)
|
||||
• What are the requirements for...? (prerequisites, dependencies)
|
||||
|
||||
3. **Content Focus Areas**
|
||||
• Technical specifications and parameters
|
||||
• Step-by-step procedures and workflows
|
||||
• Configuration options and settings
|
||||
• Error messages and troubleshooting
|
||||
• Best practices and recommendations
|
||||
• Integration points and dependencies
|
||||
• Performance considerations
|
||||
• Security aspects
|
||||
|
||||
4. **Answer Quality Requirements**
|
||||
• Complete, self-contained answers
|
||||
• Include specific values, limits, defaults from the content
|
||||
• NO page number references whatsoever
|
||||
• 2-5 sentences typical length
|
||||
• Only process content that actually exists in the document
|
||||
|
||||
════════════════════════════════════════════════════════
|
||||
OUTPUT FORMAT
|
||||
════════════════════════════════════════════════════════
|
||||
|
||||
Return valid JSON:
|
||||
```json
|
||||
{
|
||||
"faqs": [
|
||||
{
|
||||
"question": "Specific question about the content",
|
||||
"answer": "Complete answer with details (no page references)"
|
||||
}
|
||||
],
|
||||
"has_content": true/false
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL:#{' '}
|
||||
• Set "has_content" to false if:
|
||||
- The requested section doesn't exist in the document
|
||||
- You've reached the end of the document
|
||||
- The section contains no meaningful content
|
||||
• Do NOT include "page_range_processed" in the output
|
||||
• Do NOT mention page numbers anywhere in questions or answers
|
||||
PROMPT
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::SearchService
|
||||
def advanced_search
|
||||
where_conditions = { account_id: current_account.id }
|
||||
where_conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
|
||||
|
||||
Message.search(
|
||||
search_query,
|
||||
fields: %w[content attachments.transcribed_text content_attributes.email.subject],
|
||||
where: where_conditions,
|
||||
order: { created_at: :desc },
|
||||
page: params[:page] || 1,
|
||||
per_page: 15
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -60,5 +60,9 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
attachment.update!(meta: { transcribed_text: transcribed_text })
|
||||
message.reload.send_update_event
|
||||
message.account.increment_response_usage
|
||||
|
||||
return unless ChatwootApp.advanced_search_allowed?
|
||||
|
||||
message.reindex
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Messages::ReindexService
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def perform
|
||||
return unless ChatwootApp.advanced_search_allowed?
|
||||
|
||||
reindex_messages
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reindex_messages
|
||||
account.messages.reindex(mode: :async)
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,11 @@ json.assistant do
|
||||
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
|
||||
end
|
||||
json.content resource.content
|
||||
json.content_type resource.content_type
|
||||
json.created_at resource.created_at.to_i
|
||||
json.external_link resource.external_link
|
||||
json.display_url resource.display_url
|
||||
json.file_size resource.file_size
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.status resource.status
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Load all rake tasks from the enterprise/lib/tasks directory
|
||||
module Tasks
|
||||
Dir.glob(File.join(File.dirname(__FILE__), 'tasks', '*.rake')).each { |r| load r }
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
module Tasks::SearchTaskHelpers
|
||||
def check_opensearch_config
|
||||
if ENV['OPENSEARCH_URL'].blank?
|
||||
puts 'Skipping reindex as OPENSEARCH_URL is not configured'
|
||||
return false
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def reindex_account(account)
|
||||
Messages::ReindexService.new(account: account).perform
|
||||
puts "Reindex task queued for account #{account.id}"
|
||||
end
|
||||
end
|
||||
|
||||
namespace :search do
|
||||
desc 'Reindex messages using searchkick'
|
||||
include Tasks::SearchTaskHelpers
|
||||
|
||||
desc 'Reindex messages for all accounts'
|
||||
task all: :environment do
|
||||
next unless check_opensearch_config
|
||||
|
||||
puts 'Starting reindex for all accounts...'
|
||||
account_count = Account.count
|
||||
puts "Found #{account_count} accounts"
|
||||
|
||||
Account.find_each.with_index(1) do |account, index|
|
||||
puts "[#{index}/#{account_count}] Reindexing messages for account #{account.id}"
|
||||
reindex_account(account)
|
||||
end
|
||||
|
||||
puts 'Reindex task queued for all accounts'
|
||||
end
|
||||
|
||||
desc 'Reindex messages for a specific account: rake search:account ACCOUNT_ID=1'
|
||||
task account: :environment do
|
||||
next unless check_opensearch_config
|
||||
|
||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
||||
account = Account.find_by(id: account_id)
|
||||
if account.nil?
|
||||
puts 'Please provide a valid account ID. Account not found'
|
||||
next
|
||||
end
|
||||
puts "Reindexing messages for account #{account.id}"
|
||||
reindex_account(account)
|
||||
end
|
||||
end
|
||||
@@ -38,4 +38,8 @@ module ChatwootApp
|
||||
%w[]
|
||||
end
|
||||
end
|
||||
|
||||
def self.advanced_search_allowed?
|
||||
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
module CustomExceptions
|
||||
class PdfProcessingError < Base
|
||||
def initialize(message = 'PDF processing failed')
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
class PdfUploadError < PdfProcessingError
|
||||
def initialize(message = 'PDF upload failed')
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
class PdfValidationError < PdfProcessingError
|
||||
def initialize(message = 'PDF validation failed')
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
class PdfFaqGenerationError < PdfProcessingError
|
||||
def initialize(message = 'PDF FAQ generation failed')
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ module Limits
|
||||
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
|
||||
GREETING_MESSAGE_MAX_LENGTH = 10_000
|
||||
CATEGORIES_PER_PAGE = 1000
|
||||
AUTO_ASSIGNMENT_BULK_LIMIT = 100
|
||||
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
|
||||
@@ -4,4 +4,5 @@ module OpenAiConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||
DEFAULT_ENDPOINT = 'https://api.openai.com'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Run with:
|
||||
# bundle exec rake chatwoot:ops:cleanup_orphan_conversations
|
||||
|
||||
namespace :chatwoot do
|
||||
namespace :ops do
|
||||
desc 'Identify and delete conversations without a valid contact or inbox in a timeframe'
|
||||
task cleanup_orphan_conversations: :environment do
|
||||
print 'Enter Account ID: '
|
||||
account_id = $stdin.gets.to_i
|
||||
account = Account.find(account_id)
|
||||
|
||||
print 'Enter timeframe in days (default: 7): '
|
||||
days_input = $stdin.gets.strip
|
||||
days = days_input.empty? ? 7 : days_input.to_i
|
||||
|
||||
# Build a common base relation with identical joins for OR compatibility
|
||||
base = account
|
||||
.conversations
|
||||
.where('conversations.created_at > ?', days.days.ago)
|
||||
.left_outer_joins(:contact, :inbox)
|
||||
|
||||
# Find conversations whose associated contact or inbox record is missing
|
||||
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
|
||||
|
||||
count = conversations.count
|
||||
puts "Found #{count} conversations without a valid contact or inbox."
|
||||
|
||||
if count.positive?
|
||||
print 'Do you want to delete these conversations? (y/N): '
|
||||
confirm = $stdin.gets.strip.downcase
|
||||
if %w[y yes].include?(confirm)
|
||||
conversations.destroy_all
|
||||
puts 'Conversations deleted.'
|
||||
else
|
||||
puts 'No conversations were deleted.'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,71 @@
|
||||
/* eslint-disable no-restricted-globals, no-console */
|
||||
/* globals clients */
|
||||
const CACHE_NAME = 'vite-assets-cache-v1'; // Bump this to invalidate old caches
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
|
||||
const CACHE_HEADER_NAME = 'x-woot-sw-cache-time';
|
||||
|
||||
async function addCacheTime(response) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set(CACHE_HEADER_NAME, Date.now().toString());
|
||||
const body = await response.blob();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function isFresh(response) {
|
||||
const cachedTime = response.headers.get(CACHE_HEADER_NAME);
|
||||
if (!cachedTime) return false;
|
||||
return Date.now() - Number(cachedTime) < CACHE_TTL;
|
||||
}
|
||||
|
||||
async function handleAssetRequest(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached && isFresh(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const responseToCache = await addCacheTime(response);
|
||||
// We naively cache everything - the browser will purge when needed
|
||||
cache.put(request, responseToCache.clone());
|
||||
return responseToCache;
|
||||
}
|
||||
return cached || response;
|
||||
} catch {
|
||||
return cached || new Response('', { status: 504, statusText: 'Gateway Timeout' });
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (url.pathname.startsWith('/vite/assets/')) {
|
||||
event.respondWith(handleAssetRequest(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up old caches on activate
|
||||
// To trigger a cleanup we can simply change the `CACHE_NAME`
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter(key => key !== CACHE_NAME)
|
||||
.map(key => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
let notification = event.data && event.data.json();
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
require 'simplecov'
|
||||
require 'simplecov_json_formatter'
|
||||
|
||||
# Configure SimpleCov to emit JSON for Qlty and HTML locally if needed
|
||||
SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
|
||||
SimpleCov.start 'rails' do
|
||||
SimpleCov.coverage_dir 'coverage'
|
||||
end
|
||||
@@ -105,5 +105,29 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document is a PDF' do
|
||||
let(:pdf_document) do
|
||||
doc = create(:captain_document, external_link: 'https://example.com/document')
|
||||
allow(doc).to receive(:pdf_document?).and_return(true)
|
||||
allow(doc).to receive(:update!).and_return(true)
|
||||
doc
|
||||
end
|
||||
|
||||
it 'processes PDF using PdfProcessingService' do
|
||||
pdf_service = instance_double(Captain::Llm::PdfProcessingService)
|
||||
expect(Captain::Llm::PdfProcessingService).to receive(:new).with(pdf_document).and_return(pdf_service)
|
||||
expect(pdf_service).to receive(:process)
|
||||
expect(pdf_document).to receive(:update!).with(status: :available)
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'handles PDF processing errors' do
|
||||
allow(Captain::Llm::PdfProcessingService).to receive(:new).and_raise(StandardError, 'Processing failed')
|
||||
|
||||
expect { described_class.perform_now(pdf_document) }.to raise_error(StandardError, 'Processing failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -64,5 +64,41 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
.with(spanish_document.content, 'portuguese')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing a PDF document' do
|
||||
let(:pdf_document) do
|
||||
doc = create(:captain_document, assistant: assistant)
|
||||
allow(doc).to receive(:pdf_document?).and_return(true)
|
||||
allow(doc).to receive(:openai_file_id).and_return('file-123')
|
||||
allow(doc).to receive(:update!).and_return(true)
|
||||
allow(doc).to receive(:metadata).and_return({})
|
||||
doc
|
||||
end
|
||||
let(:paginated_service) { instance_double(Captain::Llm::PaginatedFaqGeneratorService) }
|
||||
let(:pdf_faqs) do
|
||||
[{ 'question' => 'What is in the PDF?', 'answer' => 'Important content' }]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new)
|
||||
.with(pdf_document, anything)
|
||||
.and_return(paginated_service)
|
||||
allow(paginated_service).to receive(:generate).and_return(pdf_faqs)
|
||||
allow(paginated_service).to receive(:total_pages_processed).and_return(10)
|
||||
allow(paginated_service).to receive(:iterations_completed).and_return(1)
|
||||
end
|
||||
|
||||
it 'uses paginated FAQ generator for PDFs' do
|
||||
expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with(pdf_document, anything)
|
||||
|
||||
described_class.new.perform(pdf_document)
|
||||
end
|
||||
|
||||
it 'stores pagination metadata' do
|
||||
expect(pdf_document).to receive(:update!).with(hash_including(metadata: hash_including('faq_generation')))
|
||||
|
||||
described_class.new.perform(pdf_document)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -229,18 +229,27 @@ RSpec.describe Account, type: :model do
|
||||
describe '#mark_for_deletion' do
|
||||
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
||||
expect do
|
||||
account.mark_for_deletion('test_reason')
|
||||
account.mark_for_deletion('inactivity')
|
||||
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
|
||||
end
|
||||
|
||||
it 'sends a notification email to admin users' do
|
||||
it 'sends a user-initiated deletion email when reason is manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('test_reason')
|
||||
account.mark_for_deletion('manual_deletion')
|
||||
end
|
||||
|
||||
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('inactivity')
|
||||
end
|
||||
|
||||
it 'returns true when successful' do
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'test.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
doc
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'allows PDF file without external link' do
|
||||
pdf_document.external_link = nil
|
||||
expect(pdf_document).to be_valid
|
||||
end
|
||||
|
||||
it 'validates PDF file size' do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('x' * 11.megabytes),
|
||||
filename: 'large.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
doc.external_link = nil
|
||||
expect(doc).not_to be_valid
|
||||
expect(doc.errors[:pdf_file]).to include(I18n.t('captain.documents.pdf_size_error'))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#pdf_document?' do
|
||||
it 'returns true for attached PDF' do
|
||||
expect(pdf_document.pdf_document?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for .pdf external links' do
|
||||
doc = build(:captain_document, external_link: 'https://example.com/document.pdf')
|
||||
expect(doc.pdf_document?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-PDF documents' do
|
||||
doc = build(:captain_document, external_link: 'https://example.com')
|
||||
expect(doc.pdf_document?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#display_url' do
|
||||
it 'returns Rails blob URL for attached PDFs' do
|
||||
pdf_document.save!
|
||||
# The display_url method calls rails_blob_url which returns a URL containing 'rails/active_storage'
|
||||
url = pdf_document.display_url
|
||||
expect(url).to be_present
|
||||
end
|
||||
|
||||
it 'returns external_link for web documents' do
|
||||
doc = create(:captain_document, external_link: 'https://example.com')
|
||||
expect(doc.display_url).to eq('https://example.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#store_openai_file_id' do
|
||||
it 'stores the file ID in metadata' do
|
||||
pdf_document.save!
|
||||
pdf_document.store_openai_file_id('file-abc123')
|
||||
|
||||
expect(pdf_document.reload.openai_file_id).to eq('file-abc123')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'automatic external_link generation' do
|
||||
it 'generates unique external_link for PDFs' do
|
||||
pdf_document.external_link = nil
|
||||
pdf_document.save!
|
||||
|
||||
expect(pdf_document.external_link).to start_with('PDF: test_')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
require 'custom_exceptions/pdf_processing_error'
|
||||
|
||||
RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
|
||||
let(:document) { create(:captain_document) }
|
||||
let(:service) { described_class.new(document, pages_per_chunk: 5) }
|
||||
let(:openai_client) { instance_double(OpenAI::Client) }
|
||||
|
||||
before do
|
||||
# Mock OpenAI configuration
|
||||
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
|
||||
allow(InstallationConfig).to receive(:find_by!)
|
||||
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
|
||||
.and_return(installation_config)
|
||||
|
||||
allow(OpenAI::Client).to receive(:new).and_return(openai_client)
|
||||
end
|
||||
|
||||
describe '#generate' do
|
||||
context 'when document lacks OpenAI file ID' do
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return(nil)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { service.generate }.to raise_error(CustomExceptions::PdfFaqGenerationError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when generating FAQs from PDF pages' do
|
||||
let(:faq_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [
|
||||
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
|
||||
],
|
||||
'has_content' => true
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
let(:empty_response) do
|
||||
{
|
||||
'choices' => [{
|
||||
'message' => {
|
||||
'content' => JSON.generate({
|
||||
'faqs' => [],
|
||||
'has_content' => false
|
||||
})
|
||||
}
|
||||
}]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return('file-123')
|
||||
end
|
||||
|
||||
it 'generates FAQs from paginated content' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to have_attributes(size: 1)
|
||||
expect(faqs.first['question']).to eq('What is this document about?')
|
||||
end
|
||||
|
||||
it 'stops when no more content' do
|
||||
allow(openai_client).to receive(:chat).and_return(empty_response)
|
||||
|
||||
faqs = service.generate
|
||||
|
||||
expect(faqs).to be_empty
|
||||
end
|
||||
|
||||
it 'respects max iterations limit' do
|
||||
allow(openai_client).to receive(:chat).and_return(faq_response)
|
||||
|
||||
# Force max iterations
|
||||
service.instance_variable_set(:@iterations_completed, 19)
|
||||
|
||||
service.generate
|
||||
expect(service.iterations_completed).to eq(20)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_continue_processing?' do
|
||||
it 'stops at max iterations' do
|
||||
service.instance_variable_set(:@iterations_completed, 20)
|
||||
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be false
|
||||
end
|
||||
|
||||
it 'stops when no FAQs returned' do
|
||||
expect(service.should_continue_processing?(faqs: [], has_content: true)).to be false
|
||||
end
|
||||
|
||||
it 'continues when FAQs exist and under limits' do
|
||||
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
require 'custom_exceptions/pdf_processing_error'
|
||||
|
||||
RSpec.describe Captain::Llm::PdfProcessingService do
|
||||
let(:document) { create(:captain_document) }
|
||||
let(:service) { described_class.new(document) }
|
||||
|
||||
before do
|
||||
# Mock OpenAI configuration
|
||||
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
|
||||
allow(InstallationConfig).to receive(:find_by!)
|
||||
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
|
||||
.and_return(installation_config)
|
||||
end
|
||||
|
||||
describe '#process' do
|
||||
context 'when document already has OpenAI file ID' do
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return('existing-file-id')
|
||||
end
|
||||
|
||||
it 'skips upload' do
|
||||
expect(document).not_to receive(:store_openai_file_id)
|
||||
service.process
|
||||
end
|
||||
end
|
||||
|
||||
context 'when uploading PDF to OpenAI' do
|
||||
let(:mock_client) { instance_double(OpenAI::Client) }
|
||||
let(:pdf_content) { 'PDF content' }
|
||||
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return(nil)
|
||||
|
||||
# Use a simple double for ActiveStorage since it's a complex Rails object
|
||||
pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles
|
||||
allow(document).to receive(:pdf_file).and_return(pdf_file)
|
||||
|
||||
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
|
||||
# Use a simple double for OpenAI::Files as it may not be loaded
|
||||
files_api = double('files_api') # rubocop:disable RSpec/VerifiedDoubles
|
||||
allow(files_api).to receive(:upload).and_return({ 'id' => 'file-abc123' })
|
||||
allow(mock_client).to receive(:files).and_return(files_api)
|
||||
end
|
||||
|
||||
it 'uploads PDF and stores file ID' do
|
||||
expect(document).to receive(:store_openai_file_id).with('file-abc123')
|
||||
service.process
|
||||
end
|
||||
|
||||
it 'raises error when upload fails' do
|
||||
allow(mock_client.files).to receive(:upload).and_return({ 'id' => nil })
|
||||
|
||||
expect { service.process }.to raise_error(CustomExceptions::PdfUploadError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Arial >> >> >> /MediaBox [0 0 612 792] /Contents 4 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 44 >>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
100 700 Td
|
||||
(Sample PDF) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000274 00000 n
|
||||
trailer
|
||||
<< /Size 5 /Root 1 0 R >>
|
||||
startxref
|
||||
362
|
||||
%%EOF
|
||||
@@ -291,4 +291,40 @@ describe PortalHelper do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_portal_brand_url' do
|
||||
it 'builds URL with UTM params and referer host as source (happy path)' do
|
||||
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
|
||||
uri = URI.parse(result)
|
||||
params = Rack::Utils.parse_query(uri.query)
|
||||
expect(uri.scheme).to eq('https')
|
||||
expect(uri.host).to eq('brand.example.com')
|
||||
expect(params['utm_medium']).to eq('helpcenter')
|
||||
expect(params['utm_campaign']).to eq('branding')
|
||||
expect(params['utm_source']).to eq('app.chatwoot.com')
|
||||
end
|
||||
|
||||
it 'returns utm string when brand_url is nil or empty' do
|
||||
expect(helper.generate_portal_brand_url(nil,
|
||||
'https://app.chatwoot.com')).to eq(
|
||||
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
|
||||
)
|
||||
expect(helper.generate_portal_brand_url('',
|
||||
'https://app.chatwoot.com')).to eq(
|
||||
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
|
||||
)
|
||||
end
|
||||
|
||||
it 'omits utm_source when referer is nil or invalid' do
|
||||
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
|
||||
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
|
||||
expect(p1.key?('utm_source')).to be(false)
|
||||
|
||||
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
|
||||
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
|
||||
expect(p2.key?('utm_source')).to be(false)
|
||||
expect(p2['utm_medium']).to eq('helpcenter')
|
||||
expect(p2['utm_campaign']).to eq('branding')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::BulkAutoAssignmentJob do
|
||||
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
|
||||
let(:assignment_service) { double }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(assignment_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when inbox has inbox members' do
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'assigns unassigned conversations in enabled inboxes' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
).and_return(assignment_service)
|
||||
|
||||
described_class.perform_now
|
||||
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips inboxes with auto assignment disabled' do
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account is on default plan in chatwoot cloud' do
|
||||
before do
|
||||
account.update!(custom_attributes: {})
|
||||
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
|
||||
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no members' do
|
||||
before do
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'does not assign conversations' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('assignment_v2')
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,116 +1,46 @@
|
||||
require 'rails_helper'
|
||||
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
|
||||
|
||||
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
|
||||
include_context 'with smtp config'
|
||||
let(:account) { create(:account, name: 'Test Account') }
|
||||
let(:mailer) { described_class.with(account: account) }
|
||||
let(:class_instance) { described_class.new }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(class_instance)
|
||||
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
|
||||
account.save!
|
||||
end
|
||||
|
||||
describe 'account_deletion' do
|
||||
let(:reason) { 'manual_deletion' }
|
||||
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
|
||||
let(:deletion_date) { 7.days.from_now.iso8601 }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: {
|
||||
'marked_for_deletion_at' => deletion_date,
|
||||
'marked_for_deletion_reason' => reason
|
||||
})
|
||||
end
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Your account has been marked for deletion')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
|
||||
it 'includes the account name in the email body' do
|
||||
expect(mail.body.encoded).to include(account.name)
|
||||
end
|
||||
|
||||
it 'includes the deletion date in the email body' do
|
||||
expect(mail.body.encoded).to include(deletion_date)
|
||||
end
|
||||
|
||||
it 'includes a link to cancel the deletion' do
|
||||
expect(mail.body.encoded).to include('Cancel Account Deletion')
|
||||
end
|
||||
|
||||
context 'when reason is manual_deletion' do
|
||||
it 'includes the administrator message' do
|
||||
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reason is not manual_deletion' do
|
||||
let(:reason) { 'inactivity' }
|
||||
|
||||
it 'includes the reason directly' do
|
||||
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
|
||||
end
|
||||
describe '#account_deletion_user_initiated' do
|
||||
it 'sets the correct subject for user-initiated deletion' do
|
||||
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
|
||||
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_import_complete' do
|
||||
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
|
||||
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Contact Import Completed')
|
||||
end
|
||||
|
||||
it 'renders the processed records' do
|
||||
expect(mail.body.encoded).to include('Number of records imported: 8')
|
||||
expect(mail.body.encoded).to include('Number of records failed: 2')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
describe '#account_deletion_for_inactivity' do
|
||||
it 'sets the correct subject for system-initiated deletion' do
|
||||
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
|
||||
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_import_failed' do
|
||||
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Contact Import Failed')
|
||||
describe '#format_deletion_date' do
|
||||
it 'formats a valid date string' do
|
||||
date_str = '2024-12-31T23:59:59Z'
|
||||
formatted = described_class.new.send(:format_deletion_date, date_str)
|
||||
expect(formatted).to eq('December 31, 2024')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_export_complete' do
|
||||
let!(:file_url) { 'http://test.com/test' }
|
||||
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq("Your contact's export file is available to download.")
|
||||
it 'handles blank dates' do
|
||||
formatted = described_class.new.send(:format_deletion_date, nil)
|
||||
expect(formatted).to eq('Unknown')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'automation_rule_disabled' do
|
||||
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
|
||||
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
|
||||
it 'includes the rule name in the email body' do
|
||||
expect(mail.body.encoded).to include('Test Rule')
|
||||
it 'handles invalid dates' do
|
||||
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
|
||||
expect(formatted).to eq('Unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -613,4 +613,57 @@ RSpec.describe Message do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_index?' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
|
||||
account.enable_features('advanced_search')
|
||||
end
|
||||
|
||||
context 'when advanced search is not allowed globally' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(message.should_index?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when advanced search feature is not enabled for account' do
|
||||
before do
|
||||
account.disable_features('advanced_search')
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(message.should_index?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message type is not incoming or outgoing' do
|
||||
before do
|
||||
message.message_type = 'activity'
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(message.should_index?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when all conditions are met' do
|
||||
it 'returns true for incoming message' do
|
||||
message.message_type = 'incoming'
|
||||
expect(message.should_index?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for outgoing message' do
|
||||
message.message_type = 'outgoing'
|
||||
expect(message.should_index?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
require 'simplecov'
|
||||
require 'webmock/rspec'
|
||||
|
||||
SimpleCov.start 'rails'
|
||||
WebMock.disable_net_connect!(allow_localhost: true)
|
||||
|
||||
RSpec.configure do |config|
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
ENV['RAILS_ENV'] ||= 'test'
|
||||
require 'simplecov'
|
||||
SimpleCov.start 'rails'
|
||||
|
||||
require File.expand_path('../config/environment', __dir__)
|
||||
require 'rails/test_help'
|
||||
|
||||
Reference in New Issue
Block a user