Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5683a5850 | ||
|
|
285d136dbc | ||
|
|
7d39088cd6 | ||
|
|
c3341b40c7 | ||
|
|
db331e0193 | ||
|
|
034e951473 | ||
|
|
a5c72e44ec | ||
|
|
fb6409508b | ||
|
|
970e76ace8 | ||
|
|
fa4c1fadba | ||
|
|
59bae616cf | ||
|
|
948a118490 | ||
|
|
1a2e6dc4ee | ||
|
|
c63b583f90 | ||
|
|
ef6949e32d | ||
|
|
9194984091 | ||
|
|
ecc95478b9 | ||
|
|
73f768e0bf | ||
|
|
8d92862ba9 | ||
|
|
b2250877d4 | ||
|
|
2d7148931f |
+3
-3
@@ -4,8 +4,8 @@
|
||||
# lint js and vue files
|
||||
npx --no-install lint-staged
|
||||
|
||||
# lint only staged ruby files
|
||||
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
|
||||
# lint only staged ruby files that still exist (not deleted)
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true
|
||||
|
||||
# stage rubocop changes to files
|
||||
git diff --name-only --cached | xargs git add
|
||||
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true
|
||||
|
||||
+5
-5
@@ -485,7 +485,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.19)
|
||||
net-imap (0.4.20)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -501,14 +501,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.4)
|
||||
nokogiri (1.18.8)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.4-arm64-darwin)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.4-x86_64-darwin)
|
||||
nokogiri (1.18.8-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.4-x86_64-linux-gnu)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
|
||||
@@ -163,9 +163,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact.custom_attributes
|
||||
end
|
||||
|
||||
def contact_additional_attributes
|
||||
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
|
||||
|
||||
@contact.additional_attributes
|
||||
end
|
||||
|
||||
def contact_update_params
|
||||
# we want the merged custom attributes not the original one
|
||||
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
|
||||
permitted_params.except(:custom_attributes, :avatar_url)
|
||||
.merge({ custom_attributes: contact_custom_attributes })
|
||||
.merge({ additional_attributes: contact_additional_attributes })
|
||||
end
|
||||
|
||||
def set_include_contact_inboxes
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
|
||||
before_action :ensure_api_inbox, only: :update
|
||||
|
||||
def index
|
||||
@messages = message_finder.perform
|
||||
end
|
||||
@@ -11,6 +13,11 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform
|
||||
@message = message
|
||||
end
|
||||
|
||||
def destroy
|
||||
ActiveRecord::Base.transaction do
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||
@@ -21,7 +28,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
def retry
|
||||
return if message.blank?
|
||||
|
||||
message.update!(status: :sent, content_attributes: {})
|
||||
service = Messages::StatusUpdateService.new(message, 'sent')
|
||||
service.perform
|
||||
message.update!(content_attributes: {})
|
||||
::SendReplyJob.perform_later(message.id)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error(e.message)
|
||||
@@ -56,10 +65,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :target_language)
|
||||
params.permit(:id, :target_language, :status, :external_error)
|
||||
end
|
||||
|
||||
def already_translated_content_available?
|
||||
message.translations.present? && message.translations[permitted_params[:target_language]].present?
|
||||
end
|
||||
|
||||
# API inbox check
|
||||
def ensure_api_inbox
|
||||
# Only API inboxes can update messages
|
||||
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,3 +66,5 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
||||
# rubocop:enable Rails/I18nLocaleTexts
|
||||
end
|
||||
end
|
||||
|
||||
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')
|
||||
|
||||
@@ -9,10 +9,17 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# on pages throughout the dashboard.
|
||||
|
||||
enterprise_attribute_types = if ChatwootApp.enterprise?
|
||||
{
|
||||
limits: Enterprise::AccountLimitsField,
|
||||
all_features: Enterprise::AccountFeaturesField
|
||||
attributes = {
|
||||
limits: AccountLimitsField
|
||||
}
|
||||
|
||||
# Only show manually managed features in Chatwoot Cloud deployment
|
||||
attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud?
|
||||
|
||||
# Add all_features last so it appears after manually_managed_features
|
||||
attributes[:all_features] = AccountFeaturesField
|
||||
|
||||
attributes
|
||||
else
|
||||
{}
|
||||
end
|
||||
@@ -46,7 +53,14 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
|
||||
# SHOW_PAGE_ATTRIBUTES
|
||||
# an array of attributes that will be displayed on the model's show page.
|
||||
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
|
||||
enterprise_show_page_attributes = if ChatwootApp.enterprise?
|
||||
attrs = %i[custom_attributes limits]
|
||||
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||
attrs << :all_features
|
||||
attrs
|
||||
else
|
||||
[]
|
||||
end
|
||||
SHOW_PAGE_ATTRIBUTES = (%i[
|
||||
id
|
||||
name
|
||||
@@ -61,7 +75,14 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# FORM_ATTRIBUTES
|
||||
# an array of attributes that will be displayed
|
||||
# on the model's form (`new` and `edit`) pages.
|
||||
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
|
||||
enterprise_form_attributes = if ChatwootApp.enterprise?
|
||||
attrs = %i[limits]
|
||||
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||
attrs << :all_features
|
||||
attrs
|
||||
else
|
||||
[]
|
||||
end
|
||||
FORM_ATTRIBUTES = (%i[
|
||||
name
|
||||
locale
|
||||
@@ -96,6 +117,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# to prevent an error from being raised (wrong number of arguments)
|
||||
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
|
||||
def permitted_attributes(action)
|
||||
super + [limits: {}]
|
||||
attrs = super + [limits: {}]
|
||||
|
||||
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
|
||||
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
|
||||
|
||||
attrs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class Enterprise::AccountFeaturesField < Administrate::Field::Base
|
||||
def to_s
|
||||
data
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,7 @@ module SuperAdmin::AccountFeaturesHelper
|
||||
end
|
||||
|
||||
def self.filter_internal_features(features)
|
||||
return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||
return features if ChatwootApp.chatwoot_cloud?
|
||||
|
||||
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
|
||||
features.except(*internal_features)
|
||||
|
||||
@@ -14,6 +14,13 @@ class CaptainAssistant extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
playground({ assistantId, messageContent, messageHistory }) {
|
||||
return axios.post(`${this.url}/${assistantId}/playground`, {
|
||||
message_content: messageContent,
|
||||
message_history: messageHistory,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainAssistant();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
isOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const isExpanded = ref(props.isOpen);
|
||||
|
||||
const toggleAccordion = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
newValue => {
|
||||
isExpanded.value = newValue;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border rounded-lg border-n-slate-4">
|
||||
<button
|
||||
class="flex items-center justify-between w-full p-4 text-left"
|
||||
@click="toggleAccordion"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-12">{{ title }}</span>
|
||||
<span
|
||||
class="w-5 h-5 transition-transform duration-200 i-lucide-chevron-down"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="isExpanded" class="p-4 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents);
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="mx-6 py-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+56
-10
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -14,39 +16,63 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
allowDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const noteContentRef = useTemplateRef('noteContentRef');
|
||||
const needsCollapse = ref(false);
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.note.id);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.collapsible) {
|
||||
// Check if content height exceeds approximately 4 lines
|
||||
// Assuming line height is ~1.625 and font size is ~14px
|
||||
const threshold = 14 * 1.625 * 4; // ~84px
|
||||
needsCollapse.value = noteContentRef.value?.clientHeight > threshold;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
|
||||
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
:name="note?.user?.name || 'Bot'"
|
||||
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
|
||||
:src="
|
||||
note?.user?.name
|
||||
? note?.user?.thumbnail
|
||||
: '/assets/images/chatwoot_bot.png'
|
||||
"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 truncate">
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium">{{ writtenBy }}</span>
|
||||
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
@@ -56,8 +82,28 @@ const handleDelete = () => {
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
|
||||
}"
|
||||
/>
|
||||
<p v-if="collapsible && needsCollapse">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="blue"
|
||||
size="xs"
|
||||
:icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
@click="() => toggleExpanded()"
|
||||
>
|
||||
<template v-if="isExpanded">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.COLLAPSE') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EXPAND') }}
|
||||
</template>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-6
@@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||
import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -61,7 +61,16 @@ const liveChatWidgets = computed(() => {
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(2) },
|
||||
slug: { required },
|
||||
slug: {
|
||||
required: helpers.withMessage(
|
||||
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
|
||||
required
|
||||
),
|
||||
isValidSlug: helpers.withMessage(
|
||||
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
|
||||
isValidSlug
|
||||
),
|
||||
},
|
||||
homePageLink: { shouldBeUrl },
|
||||
};
|
||||
|
||||
@@ -71,9 +80,9 @@ const nameError = computed(() =>
|
||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||
);
|
||||
|
||||
const slugError = computed(() =>
|
||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
||||
);
|
||||
const slugError = computed(() => {
|
||||
return v$.value.slug.$errors[0]?.$message || '';
|
||||
});
|
||||
|
||||
const homePageLinkError = computed(() =>
|
||||
v$.value.homePageLink.$error
|
||||
|
||||
+18
-5
@@ -6,8 +6,9 @@ import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { required, minLength, helpers } from '@vuelidate/validators';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
import { isValidSlug } from 'shared/helpers/Validators';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -31,7 +32,16 @@ const state = reactive({
|
||||
|
||||
const rules = {
|
||||
name: { required, minLength: minLength(2) },
|
||||
slug: { required },
|
||||
slug: {
|
||||
required: helpers.withMessage(
|
||||
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'),
|
||||
required
|
||||
),
|
||||
isValidSlug: helpers.withMessage(
|
||||
() => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'),
|
||||
isValidSlug
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
@@ -40,9 +50,9 @@ const nameError = computed(() =>
|
||||
v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : ''
|
||||
);
|
||||
|
||||
const slugError = computed(() =>
|
||||
v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : ''
|
||||
);
|
||||
const slugError = computed(() => {
|
||||
return v$.value.slug.$errors[0]?.$message || '';
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||
|
||||
@@ -131,6 +141,7 @@ defineExpose({ dialogRef });
|
||||
:message="
|
||||
nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE')
|
||||
"
|
||||
@blur="v$.name.$touch()"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
@@ -140,6 +151,8 @@ defineExpose({ dialogRef });
|
||||
:label="t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.LABEL')"
|
||||
:message-type="slugError ? 'error' : 'info'"
|
||||
:message="slugError || buildPortalURL(state.slug)"
|
||||
@input="v$.slug.$touch()"
|
||||
@blur="v$.slug.$touch()"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
@@ -23,6 +24,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
backUrl: {
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
},
|
||||
buttonPolicy: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -39,6 +44,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showKnowMore: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -73,19 +82,23 @@ const handlePageChange = event => {
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<BackButton v-if="backUrl" :to="backUrl" />
|
||||
<slot name="headerTitle">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</slot>
|
||||
<div v-if="!isEmpty" class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!showPaywall"
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
@@ -104,7 +117,7 @@ const handlePageChange = event => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto py-4">
|
||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
||||
<slot v-if="!showPaywall" name="controls" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
|
||||
@@ -76,9 +76,12 @@ const handleAction = ({ action, value }) => {
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
<router-link
|
||||
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
||||
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</router-link>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
|
||||
const { assistantId } = defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const messages = ref([]);
|
||||
const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
messages.value = [];
|
||||
newMessage.value = '';
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || isLoading.value) return;
|
||||
|
||||
const userMessage = {
|
||||
content: newMessage.value,
|
||||
sender: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
messages.value.push(userMessage);
|
||||
const currentMessage = newMessage.value;
|
||||
newMessage.value = '';
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const { data } = await CaptainAssistant.playground({
|
||||
assistantId,
|
||||
messageContent: currentMessage,
|
||||
messageHistory: formatMessagesForApi(),
|
||||
});
|
||||
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error getting assistant response:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||
</h3>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
@click="resetConversation"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.PLAYGROUND.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||
|
||||
<div
|
||||
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
||||
>
|
||||
<input
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
:disabled="!newMessage.trim()"
|
||||
icon="i-lucide-send"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-n-slate-11 pt-2 text-center">
|
||||
{{ t('CAPTAIN.PLAYGROUND.CREDIT_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageContainer = ref(null);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isUserMessage = sender => sender === 'user';
|
||||
|
||||
const getMessageAlignment = sender =>
|
||||
isUserMessage(sender) ? 'justify-end' : 'justify-start';
|
||||
|
||||
const getMessageDirection = sender =>
|
||||
isUserMessage(sender) ? 'flex-row-reverse' : 'flex-row';
|
||||
|
||||
const getAvatarName = sender =>
|
||||
isUserMessage(sender)
|
||||
? t('CAPTAIN.PLAYGROUND.USER')
|
||||
: t('CAPTAIN.PLAYGROUND.ASSISTANT');
|
||||
|
||||
const getMessageStyle = sender =>
|
||||
isUserMessage(sender)
|
||||
? 'bg-n-strong text-n-white'
|
||||
: 'bg-n-solid-iris text-n-slate-12';
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.messages.length, scrollToBottom);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
class="flex"
|
||||
:class="getMessageAlignment(message.sender)"
|
||||
>
|
||||
<div
|
||||
class="flex items-start gap-1.5"
|
||||
:class="getMessageDirection(message.sender)"
|
||||
>
|
||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
||||
<div
|
||||
class="max-w-[80%] rounded-lg p-3 text-sm"
|
||||
:class="getMessageStyle(message.sender)"
|
||||
>
|
||||
<div v-html="formatMessage(message.content)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isLoading" class="flex justify-start">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />
|
||||
<div
|
||||
class="max-w-sm rounded-lg p-3 text-sm bg-n-solid-iris text-n-slate-12"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
|
||||
/>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
<script setup>
|
||||
import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['edit', 'create'].includes(value),
|
||||
},
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
description: '',
|
||||
productName: '',
|
||||
welcomeMessage: '',
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
features: {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
},
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const validationRules = {
|
||||
name: { required, minLength: minLength(1) },
|
||||
description: { required, minLength: minLength(1) },
|
||||
productName: { required, minLength: minLength(1) },
|
||||
welcomeMessage: { minLength: minLength(1) },
|
||||
handoffMessage: { minLength: minLength(1) },
|
||||
resolutionMessage: { minLength: minLength(1) },
|
||||
instructions: { minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const getErrorMessage = field => {
|
||||
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
name: getErrorMessage('name'),
|
||||
description: getErrorMessage('description'),
|
||||
productName: getErrorMessage('productName'),
|
||||
welcomeMessage: getErrorMessage('welcomeMessage'),
|
||||
handoffMessage: getErrorMessage('handoffMessage'),
|
||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||
instructions: getErrorMessage('instructions'),
|
||||
}));
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
const { config = {} } = assistant;
|
||||
state.name = assistant.name;
|
||||
state.description = assistant.description;
|
||||
state.productName = config.product_name;
|
||||
state.welcomeMessage = config.welcome_message;
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.features = {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleBasicInfoUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
v$.value.name.$validate(),
|
||||
v$.value.description.$validate(),
|
||||
v$.value.productName.$validate(),
|
||||
]).then(results => results.every(Boolean));
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
product_name: state.productName,
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
v$.value.welcomeMessage.$validate(),
|
||||
v$.value.handoffMessage.$validate(),
|
||||
v$.value.resolutionMessage.$validate(),
|
||||
]).then(results => results.every(Boolean));
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
welcome_message: state.welcomeMessage,
|
||||
handoff_message: state.handoffMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleInstructionsUpdate = async () => {
|
||||
const result = await v$.value.instructions.$validate();
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
instructions: state.instructions,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleFeaturesUpdate = () => {
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (props.mode === 'edit' && newAssistant) {
|
||||
updateStateFromAssistant(newAssistant);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<!-- Basic Information Section -->
|
||||
<Accordion
|
||||
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
|
||||
is-open
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="formErrors.name"
|
||||
:message-type="formErrors.name ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
:message-type="formErrors.description ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.productName"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
||||
:message="formErrors.productName"
|
||||
:message-type="formErrors.productName ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
@click="handleBasicInfoUpdate"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Instructions Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<Editor
|
||||
v-model="state.instructions"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||
:message="formErrors.instructions"
|
||||
:max-length="2000"
|
||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleInstructionsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Greeting Messages Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<Editor
|
||||
v-model="state.handoffMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.resolutionMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.resolutionMessage"
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSystemMessagesUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Features Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="state.features.conversationFaqs"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
{{
|
||||
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
||||
}}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="state.features.memories"
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleFeaturesUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</form>
|
||||
</template>
|
||||
@@ -56,7 +56,7 @@ const unlinkIssue = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
@@ -66,37 +66,37 @@ const unlinkIssue = () => {
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-ash-900">
|
||||
<span class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-ash-800 line-clamp-3"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">
|
||||
<h6 class="text-xs text-n-slate-12">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
|
||||
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
@@ -111,7 +111,7 @@ const unlinkIssue = () => {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-ash-800">
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
|
||||
@@ -100,14 +100,17 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative" :class="{ group: linkedIssue }">
|
||||
<div
|
||||
class="relative after:content-[''] after:h-5 after:bg-transparent after:top-5 after:w-full after:block after:absolute after:z-0"
|
||||
:class="{ group: linkedIssue }"
|
||||
>
|
||||
<Button
|
||||
v-on-clickaway="closeIssue"
|
||||
v-tooltip="tooltipText"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
class="!gap-1"
|
||||
class="!gap-1 group-hover:bg-n-alpha-2"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
@@ -124,7 +127,7 @@ onMounted(() => {
|
||||
v-if="linkedIssue"
|
||||
:issue="linkedIssue.issue"
|
||||
:link-id="linkedIssue.id"
|
||||
class="absolute right-0 top-[40px] invisible group-hover:visible"
|
||||
class="absolute right-0 top-[36px] invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
|
||||
@@ -6,6 +6,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
{ name: 'contact_attributes' },
|
||||
{ name: 'contact_notes' },
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'shopify_orders' },
|
||||
|
||||
@@ -545,6 +545,9 @@
|
||||
"WROTE": "wrote",
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
"EXPAND": "Expand",
|
||||
"COLLAPSE": "Collapse",
|
||||
"NO_NOTES": "No notes, you can add notes from the contact details page.",
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -295,6 +295,7 @@
|
||||
"CONVERSATION_ACTIONS": "Conversation Actions",
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
|
||||
@@ -696,7 +696,8 @@
|
||||
"SLUG": {
|
||||
"LABEL": "Slug",
|
||||
"PLACEHOLDER": "user-guide",
|
||||
"ERROR": "Slug is required"
|
||||
"ERROR": "Slug is required",
|
||||
"FORMAT_ERROR": "Please enter a valid slug, for eg: user-guide"
|
||||
}
|
||||
},
|
||||
"PORTAL_SETTINGS": {
|
||||
|
||||
@@ -333,6 +333,14 @@
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PLAYGROUND": {
|
||||
"USER": "You",
|
||||
"ASSISTANT": "Assistant",
|
||||
"MESSAGE_PLACEHOLDER": "Type your message...",
|
||||
"HEADER": "Playground",
|
||||
"DESCRIPTION": "Use this playground to send messages to your assistant and check if it responds accurately, quickly, and in the tone you expect.",
|
||||
"CREDIT_NOTE": "Messages sent here will count toward your Captain credits."
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
"AVAILABLE_ON": "Captain is not available on the free plan.",
|
||||
@@ -371,20 +379,41 @@
|
||||
"ERROR_MESSAGE": "There was an error creating the assistant, please try again."
|
||||
},
|
||||
"FORM": {
|
||||
"UPDATE": "Update",
|
||||
"SECTIONS": {
|
||||
"BASIC_INFO": "Basic Information",
|
||||
"SYSTEM_MESSAGES": "System Messages",
|
||||
"INSTRUCTIONS": "Instructions",
|
||||
"FEATURES": "Features",
|
||||
"TOOLS": "Tools "
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Assistant Name",
|
||||
"PLACEHOLDER": "Enter a name for the assistant",
|
||||
"ERROR": "Please provide a name for the assistant"
|
||||
"LABEL": "Name",
|
||||
"PLACEHOLDER": "Enter assistant name"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Assistant Description",
|
||||
"PLACEHOLDER": "Describe how and where this assistant will be used",
|
||||
"ERROR": "A description is required"
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Enter assistant description"
|
||||
},
|
||||
"PRODUCT_NAME": {
|
||||
"LABEL": "Product Name",
|
||||
"PLACEHOLDER": "Enter the name of the product this assistant is designed for",
|
||||
"ERROR": "The product name is required"
|
||||
"PLACEHOLDER": "Enter product name"
|
||||
},
|
||||
"WELCOME_MESSAGE": {
|
||||
"LABEL": "Welcome Message",
|
||||
"PLACEHOLDER": "Enter welcome message"
|
||||
},
|
||||
"HANDOFF_MESSAGE": {
|
||||
"LABEL": "Handoff Message",
|
||||
"PLACEHOLDER": "Enter handoff message"
|
||||
},
|
||||
"RESOLUTION_MESSAGE": {
|
||||
"LABEL": "Resolution Message",
|
||||
"PLACEHOLDER": "Enter resolution message"
|
||||
},
|
||||
"INSTRUCTIONS": {
|
||||
"LABEL": "Instructions",
|
||||
"PLACEHOLDER": "Enter instructions for the assistant"
|
||||
},
|
||||
"FEATURES": {
|
||||
"TITLE": "Features",
|
||||
@@ -395,7 +424,8 @@
|
||||
"EDIT": {
|
||||
"TITLE": "Update the assistant",
|
||||
"SUCCESS_MESSAGE": "The assistant has been successfully updated",
|
||||
"ERROR_MESSAGE": "There was an error updating the assistant, please try again."
|
||||
"ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
|
||||
"NOT_FOUND": "Could not find the assistant. Please try again."
|
||||
},
|
||||
"OPTIONS": {
|
||||
"EDIT_ASSISTANT": "Edit Assistant",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import EditAssistantForm from '../../../../components-next/captain/pageComponents/assistant/EditAssistantForm.vue';
|
||||
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const isAssistantAvailable = computed(() => !!assistant.value?.id);
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
...updatedAssistant,
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAssistantAvailable.value) {
|
||||
store.dispatch('captainAssistants/show', assistantId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="assistant?.name"
|
||||
:show-pagination-footer="false"
|
||||
:is-fetching="isFetching"
|
||||
:show-know-more="false"
|
||||
:back-url="{ name: 'captain_assistants_index' }"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="!isAssistantAvailable">
|
||||
{{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
|
||||
</div>
|
||||
<div v-else class="flex gap-4 h-full">
|
||||
<div class="flex-1 lg:overflow-auto pr-4 h-full md:h-auto">
|
||||
<EditAssistantForm
|
||||
:assistant="assistant"
|
||||
mode="edit"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[400px] hidden lg:block h-full">
|
||||
<AssistantPlayground :assistant-id="Number(assistantId)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -36,8 +36,10 @@ const handleCreate = () => {
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createAssistantDialog.value.dialogRef.open());
|
||||
router.push({
|
||||
name: 'captain_assistants_edit',
|
||||
params: { assistantId: selectedAssistant.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewConnectedInboxes = () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantEdit from './assistants/Edit.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
@@ -20,6 +21,19 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
|
||||
component: AssistantEdit,
|
||||
name: 'captain_assistants_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/inboxes'
|
||||
|
||||
@@ -13,6 +13,7 @@ import ConversationAction from './ConversationAction.vue';
|
||||
import ConversationParticipant from './ConversationParticipant.vue';
|
||||
|
||||
import ContactInfo from './contact/ContactInfo.vue';
|
||||
import ContactNotes from './contact/ContactNotes.vue';
|
||||
import ConversationInfo from './ConversationInfo.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
@@ -245,6 +246,18 @@ onMounted(() => {
|
||||
<ShopifyOrdersList :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_notes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_contact_notes_open', value)
|
||||
"
|
||||
>
|
||||
<ContactNotes :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'next/spinner/Spinner.vue';
|
||||
|
||||
const { contactId } = defineProps({
|
||||
contactId: { type: String, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
|
||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const notes = computed(() => notGetterFn.value(contactId));
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
return user?.id === currentUserId
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => contactId,
|
||||
() => store.dispatch('contactNotes/get', { contactId }),
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
|
||||
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
|
||||
</div>
|
||||
<div v-else class="max-h-[300px] overflow-scroll">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="p-4 last-of-type:border-b-0"
|
||||
:note="note"
|
||||
collapsible
|
||||
:written-by="getWrittenBy(note)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -80,7 +80,7 @@ export default {
|
||||
}, {});
|
||||
|
||||
this.formItems.forEach(item => {
|
||||
if (item.validation.includes('JSON')) {
|
||||
if (item.validation?.includes('JSON')) {
|
||||
hookPayload.settings[item.name] = JSON.parse(
|
||||
hookPayload.settings[item.name]
|
||||
);
|
||||
@@ -117,7 +117,7 @@ export default {
|
||||
<div class="flex flex-col h-auto overflow-auto integration-hooks">
|
||||
<woot-modal-header
|
||||
:header-title="integration.name"
|
||||
:header-content="integration.description"
|
||||
:header-content="integration.short_description || integration.description"
|
||||
/>
|
||||
<FormKit
|
||||
v-model="values"
|
||||
@@ -169,6 +169,10 @@ export default {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.formkit-form .formkit-help {
|
||||
@apply text-n-slate-10 text-sm font-normal mt-2 w-full;
|
||||
}
|
||||
|
||||
/* equivalent of .reset-base */
|
||||
.formkit-input {
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
} from './bubbleHelpers';
|
||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||
import { CHATWOOT_ERROR, CHATWOOT_READY } from '../widget/constants/sdkEvents';
|
||||
import {
|
||||
CHATWOOT_ERROR,
|
||||
CHATWOOT_POSTBACK,
|
||||
CHATWOOT_READY,
|
||||
} from '../widget/constants/sdkEvents';
|
||||
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
|
||||
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
|
||||
import {
|
||||
@@ -205,7 +209,7 @@ export const IFrameHelper = {
|
||||
|
||||
postback(data) {
|
||||
dispatchWindowEvent({
|
||||
eventName: 'chatwoot:postback',
|
||||
eventName: CHATWOOT_POSTBACK,
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { addClasses, removeClasses, toggleClass } from './DOMHelpers';
|
||||
import { IFrameHelper } from './IFrameHelper';
|
||||
import { isExpandedView } from './settingsHelper';
|
||||
import {
|
||||
CHATWOOT_CLOSED,
|
||||
CHATWOOT_OPENED,
|
||||
} from '../widget/constants/sdkEvents';
|
||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||
|
||||
export const bubbleSVG =
|
||||
'M240.808 240.808H122.123C56.6994 240.808 3.45695 187.562 3.45695 122.122C3.45695 56.7031 56.6994 3.45697 122.124 3.45697C187.566 3.45697 240.808 56.7031 240.808 122.122V240.808Z';
|
||||
@@ -65,22 +70,30 @@ export const createBubbleHolder = hideMessageBubble => {
|
||||
body.appendChild(bubbleHolder);
|
||||
};
|
||||
|
||||
const handleBubbleToggle = newIsOpen => {
|
||||
IFrameHelper.events.onBubbleToggle(newIsOpen);
|
||||
|
||||
if (newIsOpen) {
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_OPENED });
|
||||
} else {
|
||||
dispatchWindowEvent({ eventName: CHATWOOT_CLOSED });
|
||||
chatBubble.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const onBubbleClick = (props = {}) => {
|
||||
const { toggleValue } = props;
|
||||
const { isOpen } = window.$chatwoot;
|
||||
if (isOpen !== toggleValue) {
|
||||
const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue;
|
||||
window.$chatwoot.isOpen = newIsOpen;
|
||||
if (isOpen === toggleValue) return;
|
||||
|
||||
toggleClass(chatBubble, 'woot--hide');
|
||||
toggleClass(closeBubble, 'woot--hide');
|
||||
toggleClass(widgetHolder, 'woot--hide');
|
||||
IFrameHelper.events.onBubbleToggle(newIsOpen);
|
||||
const newIsOpen = toggleValue === undefined ? !isOpen : toggleValue;
|
||||
window.$chatwoot.isOpen = newIsOpen;
|
||||
|
||||
if (!newIsOpen) {
|
||||
chatBubble.focus();
|
||||
}
|
||||
}
|
||||
toggleClass(chatBubble, 'woot--hide');
|
||||
toggleClass(closeBubble, 'woot--hide');
|
||||
toggleClass(widgetHolder, 'woot--hide');
|
||||
|
||||
handleBubbleToggle(newIsOpen);
|
||||
};
|
||||
|
||||
export const onClickChatBubble = () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const SDK_CSS = `
|
||||
user-select: none;
|
||||
width: 64px;
|
||||
z-index: 2147483000 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.woot-widget-bubble.woot-widget-bubble--flat {
|
||||
@@ -135,6 +136,7 @@ export const SDK_CSS = `
|
||||
.woot-widget-bubble svg {
|
||||
all: revert;
|
||||
height: 24px;
|
||||
margin: 20px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +100,10 @@ export const getRegexp = regexPatternValue => {
|
||||
regexPatternValue.slice(lastSlash + 1)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
|
||||
* @param {string} value - The slug to validate.
|
||||
* @returns {boolean} True if the slug is valid, false otherwise.
|
||||
*/
|
||||
export const isValidSlug = value => /^[a-zA-Z0-9-]+$/.test(value);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isNumber,
|
||||
isDomain,
|
||||
getRegexp,
|
||||
isValidSlug,
|
||||
} from '../Validators';
|
||||
|
||||
describe('#shouldBeUrl', () => {
|
||||
@@ -153,3 +154,24 @@ describe('#getRegexp', () => {
|
||||
expect(regex.test('12-34-5678')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isValidSlug', () => {
|
||||
it('should return true for valid slugs', () => {
|
||||
expect(isValidSlug('abc')).toEqual(true);
|
||||
expect(isValidSlug('abc-123')).toEqual(true);
|
||||
expect(isValidSlug('a-b-c')).toEqual(true);
|
||||
expect(isValidSlug('123')).toEqual(true);
|
||||
expect(isValidSlug('abc123-def')).toEqual(true);
|
||||
});
|
||||
it('should return false for invalid slugs', () => {
|
||||
expect(isValidSlug('abc_def')).toEqual(false);
|
||||
expect(isValidSlug('abc def')).toEqual(false);
|
||||
expect(isValidSlug('abc@def')).toEqual(false);
|
||||
expect(isValidSlug('abc.def')).toEqual(false);
|
||||
expect(isValidSlug('abc/def')).toEqual(false);
|
||||
expect(isValidSlug('abc!def')).toEqual(false);
|
||||
expect(isValidSlug('abc--def!')).toEqual(false);
|
||||
expect(isValidSlug('abc-def ')).toEqual(false);
|
||||
expect(isValidSlug(' abc-def')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const CHATWOOT_ERROR = 'chatwoot:error';
|
||||
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
|
||||
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
|
||||
export const CHATWOOT_POSTBACK = 'chatwoot:postback';
|
||||
export const CHATWOOT_READY = 'chatwoot:ready';
|
||||
export const CHATWOOT_OPENED = 'chatwoot:opened';
|
||||
export const CHATWOOT_CLOSED = 'chatwoot:closed';
|
||||
|
||||
@@ -15,7 +15,7 @@ class Conversations::UpdateMessageStatusJob < ApplicationJob
|
||||
conversation.messages.where(status: %w[sent delivered])
|
||||
.where.not(message_type: 'incoming')
|
||||
.where('messages.created_at <= ?', timestamp).find_each do |message|
|
||||
message.update!(status: status)
|
||||
Messages::StatusUpdateService.new(message, status).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class Crm::SetupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(hook_id)
|
||||
hook = Integrations::Hook.find_by(id: hook_id)
|
||||
|
||||
return if hook.blank? || hook.disabled?
|
||||
|
||||
begin
|
||||
setup_service = create_setup_service(hook)
|
||||
return if setup_service.nil?
|
||||
|
||||
setup_service.setup
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
Rails.logger.error "Error in CRM setup for hook ##{hook_id} (#{hook.app_id}): #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_setup_service(hook)
|
||||
case hook.app_id
|
||||
when 'leadsquared'
|
||||
Crm::Leadsquared::SetupService.new(hook)
|
||||
# Add cases for future CRMs here
|
||||
# when 'hubspot'
|
||||
# Crm::Hubspot::SetupService.new(hook)
|
||||
# when 'zoho'
|
||||
# Crm::Zoho::SetupService.new(hook)
|
||||
else
|
||||
Rails.logger.error "Unsupported CRM app_id: #{hook.app_id}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
+40
-1
@@ -1,4 +1,6 @@
|
||||
class HookJob < ApplicationJob
|
||||
class HookJob < MutexApplicationJob
|
||||
retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3
|
||||
|
||||
queue_as :medium
|
||||
|
||||
def perform(hook, event_name, event_data = {})
|
||||
@@ -11,6 +13,8 @@ class HookJob < ApplicationJob
|
||||
process_dialogflow_integration(hook, event_name, event_data)
|
||||
when 'google_translate'
|
||||
google_translate_integration(hook, event_name, event_data)
|
||||
when 'leadsquared'
|
||||
process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
@@ -41,4 +45,39 @@ class HookJob < ApplicationJob
|
||||
message = event_data[:message]
|
||||
Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform
|
||||
end
|
||||
|
||||
def process_leadsquared_integration_with_lock(hook, event_name, event_data)
|
||||
# Why do we need a mutex here? glad you asked
|
||||
# When a new conversation is created. We get a contact created event, immediately followed by
|
||||
# a contact updated event, and then a conversation created event.
|
||||
# This all happens within milliseconds of each other.
|
||||
# Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID.
|
||||
# If the lead data is not present, we try to search the API and create a new lead if it doesn't exist.
|
||||
# This gives us a bad race condition that allows the API to create multiple leads for the same contact.
|
||||
#
|
||||
# This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared
|
||||
# But then this is configurable in the LeadSquared settings, and may or may not be unique.
|
||||
valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
return unless valid_event_names.include?(event_name)
|
||||
return unless hook.feature_allowed?
|
||||
|
||||
key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id)
|
||||
with_lock(key) do
|
||||
process_leadsquared_integration(hook, event_name, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
def process_leadsquared_integration(hook, event_name, event_data)
|
||||
# Process the event with the processor service
|
||||
processor = Crm::Leadsquared::ProcessorService.new(hook)
|
||||
|
||||
case event_name
|
||||
when 'contact.updated'
|
||||
processor.handle_contact(event_data[:contact])
|
||||
when 'conversation.created'
|
||||
processor.handle_conversation_created(event_data[:conversation])
|
||||
when 'conversation.resolved'
|
||||
processor.handle_conversation_resolved(event_data[:conversation])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,29 @@ class HookListener < BaseListener
|
||||
execute_hooks(event, message)
|
||||
end
|
||||
|
||||
def contact_created(event)
|
||||
contact = extract_contact_and_account(event)[0]
|
||||
execute_account_hooks(event, contact.account, contact: contact)
|
||||
end
|
||||
|
||||
def contact_updated(event)
|
||||
contact = extract_contact_and_account(event)[0]
|
||||
execute_account_hooks(event, contact.account, contact: contact)
|
||||
end
|
||||
|
||||
def conversation_created(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
execute_account_hooks(event, conversation.account, conversation: conversation)
|
||||
end
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
# Only trigger for status changes is resolved
|
||||
return unless conversation.status == 'resolved'
|
||||
|
||||
execute_account_hooks(event, conversation.account, conversation: conversation)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_hooks(event, message)
|
||||
@@ -22,4 +45,10 @@ class HookListener < BaseListener
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_account_hooks(event, account, event_data = {})
|
||||
account.hooks.account_hooks.find_each do |hook|
|
||||
HookJob.perform_later(hook, event.name, event_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,10 @@ class Integrations::App
|
||||
I18n.t("integration_apps.#{params[:i18n_key]}.description")
|
||||
end
|
||||
|
||||
def short_description
|
||||
I18n.t("integration_apps.#{params[:i18n_key]}.short_description")
|
||||
end
|
||||
|
||||
def logo
|
||||
params[:logo]
|
||||
end
|
||||
@@ -51,6 +55,8 @@ class Integrations::App
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
when 'leadsquared'
|
||||
account.feature_enabled?('crm_integration')
|
||||
else
|
||||
true
|
||||
end
|
||||
|
||||
@@ -19,11 +19,13 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
|
||||
before_validation :ensure_hook_type
|
||||
after_create :trigger_setup_if_crm
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :app_id, presence: true
|
||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||
validate :validate_settings_json_schema
|
||||
validate :ensure_feature_enabled
|
||||
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
|
||||
|
||||
# TODO: This seems to be only used for slack at the moment
|
||||
@@ -36,6 +38,9 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
enum hook_type: { account: 0, inbox: 1 }
|
||||
|
||||
scope :account_hooks, -> { where(hook_type: 'account') }
|
||||
scope :inbox_hooks, -> { where(hook_type: 'inbox') }
|
||||
|
||||
def app
|
||||
@app ||= Integrations::App.find(id: app_id)
|
||||
end
|
||||
@@ -61,8 +66,21 @@ class Integrations::Hook < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def feature_allowed?
|
||||
return true if app.blank?
|
||||
|
||||
flag = app.params[:feature_flag]
|
||||
return true unless flag
|
||||
|
||||
account.feature_enabled?(flag)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_feature_enabled
|
||||
errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed?
|
||||
end
|
||||
|
||||
def ensure_hook_type
|
||||
self.hook_type = app.params[:hook_type] if app.present?
|
||||
end
|
||||
@@ -72,4 +90,17 @@ class Integrations::Hook < ApplicationRecord
|
||||
|
||||
errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings)
|
||||
end
|
||||
|
||||
def trigger_setup_if_crm
|
||||
# we need setup services to create data prerequisite to functioning of the integration
|
||||
# in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts
|
||||
# https://apidocs.leadsquared.com/create-new-activity-type-api/
|
||||
return unless crm_integration?
|
||||
|
||||
::Crm::SetupJob.perform_later(id)
|
||||
end
|
||||
|
||||
def crm_integration?
|
||||
%w[leadsquared].include?(app_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -343,6 +343,7 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def execute_message_template_hooks
|
||||
Rails.logger.info("[Message][#{id}] Executing message template hooks")
|
||||
::MessageTemplates::HookExecutionService.new(message: self).perform
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
class Crm::BaseProcessorService
|
||||
def initialize(hook)
|
||||
@hook = hook
|
||||
@account = hook.account
|
||||
end
|
||||
|
||||
# Class method to be overridden by subclasses
|
||||
def self.crm_name
|
||||
raise NotImplementedError, 'Subclasses must define self.crm_name'
|
||||
end
|
||||
|
||||
# Instance method that calls the class method
|
||||
def crm_name
|
||||
self.class.crm_name
|
||||
end
|
||||
|
||||
def process_event(event_name, event_data)
|
||||
case event_name
|
||||
when 'contact.created'
|
||||
handle_contact_created(event_data)
|
||||
when 'contact.updated'
|
||||
handle_contact_updated(event_data)
|
||||
when 'conversation.created'
|
||||
handle_conversation_created(event_data)
|
||||
when 'conversation.updated'
|
||||
handle_conversation_updated(event_data)
|
||||
else
|
||||
{ success: false, error: "Unsupported event: #{event_name}" }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{crm_name} Processor Error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
# Abstract methods that subclasses must implement
|
||||
def handle_contact_created(contact)
|
||||
raise NotImplementedError, 'Subclasses must implement #handle_contact_created'
|
||||
end
|
||||
|
||||
def handle_contact_updated(contact)
|
||||
raise NotImplementedError, 'Subclasses must implement #handle_contact_updated'
|
||||
end
|
||||
|
||||
def handle_conversation_created(conversation)
|
||||
raise NotImplementedError, 'Subclasses must implement #handle_conversation_created'
|
||||
end
|
||||
|
||||
def handle_conversation_resolved(conversation)
|
||||
raise NotImplementedError, 'Subclasses must implement #handle_conversation_resolved'
|
||||
end
|
||||
|
||||
# Common helper methods for all CRM processors
|
||||
|
||||
protected
|
||||
|
||||
def identifiable_contact?(contact)
|
||||
has_social_profile = contact.additional_attributes['social_profiles'].present?
|
||||
contact.present? && (contact.email.present? || contact.phone_number.present? || has_social_profile)
|
||||
end
|
||||
|
||||
def get_external_id(contact)
|
||||
return nil if contact.additional_attributes.blank?
|
||||
return nil if contact.additional_attributes['external'].blank?
|
||||
|
||||
contact.additional_attributes.dig('external', "#{crm_name}_id")
|
||||
end
|
||||
|
||||
def store_external_id(contact, external_id)
|
||||
# Initialize additional_attributes if it's nil
|
||||
contact.additional_attributes = {} if contact.additional_attributes.nil?
|
||||
|
||||
# Initialize external hash if it doesn't exist
|
||||
contact.additional_attributes['external'] = {} if contact.additional_attributes['external'].blank?
|
||||
|
||||
# Store the external ID
|
||||
contact.additional_attributes['external']["#{crm_name}_id"] = external_id
|
||||
contact.save!
|
||||
end
|
||||
|
||||
def store_conversation_metadata(conversation, metadata)
|
||||
# Initialize additional_attributes if it's nil
|
||||
conversation.additional_attributes = {} if conversation.additional_attributes.nil?
|
||||
|
||||
# Initialize CRM-specific hash in additional_attributes
|
||||
conversation.additional_attributes[crm_name] = {} if conversation.additional_attributes[crm_name].blank?
|
||||
|
||||
# Store the metadata
|
||||
conversation.additional_attributes[crm_name].merge!(metadata)
|
||||
conversation.save!
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient
|
||||
# https://apidocs.leadsquared.com/post-an-activity-to-lead/#api
|
||||
def post_activity(prospect_id, activity_event, activity_note)
|
||||
raise ArgumentError, 'Prospect ID is required' if prospect_id.blank?
|
||||
raise ArgumentError, 'Activity event code is required' if activity_event.blank?
|
||||
|
||||
path = 'ProspectActivity.svc/Create'
|
||||
|
||||
body = {
|
||||
'RelatedProspectId' => prospect_id,
|
||||
'ActivityEvent' => activity_event,
|
||||
'ActivityNote' => activity_note
|
||||
}
|
||||
|
||||
response = post(path, {}, body)
|
||||
response['Message']['Id']
|
||||
end
|
||||
|
||||
def create_activity_type(name:, score:, direction: 0)
|
||||
raise ArgumentError, 'Activity name is required' if name.blank?
|
||||
|
||||
path = 'ProspectActivity.svc/CreateType'
|
||||
body = {
|
||||
'ActivityEventName' => name,
|
||||
'Score' => score.to_i,
|
||||
'Direction' => direction.to_i
|
||||
}
|
||||
|
||||
response = post(path, {}, body)
|
||||
response['Message']['Id']
|
||||
end
|
||||
|
||||
def fetch_activity_types
|
||||
get('ProspectActivity.svc/ActivityTypes.Get')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Crm::Leadsquared::Api::BaseClient
|
||||
include HTTParty
|
||||
|
||||
class ApiError < StandardError
|
||||
attr_reader :code, :response
|
||||
|
||||
def initialize(message = nil, code = nil, response = nil)
|
||||
@code = code
|
||||
@response = response
|
||||
super(message)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(access_key, secret_key, endpoint_url)
|
||||
@access_key = access_key
|
||||
@secret_key = secret_key
|
||||
@base_uri = endpoint_url
|
||||
end
|
||||
|
||||
def get(path, params = {})
|
||||
full_url = URI.join(@base_uri, path).to_s
|
||||
|
||||
options = {
|
||||
query: params,
|
||||
headers: headers
|
||||
}
|
||||
|
||||
response = self.class.get(full_url, options)
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
def post(path, params = {}, body = {})
|
||||
full_url = URI.join(@base_uri, path).to_s
|
||||
|
||||
options = {
|
||||
query: params,
|
||||
headers: headers
|
||||
}
|
||||
|
||||
options[:body] = body.to_json if body.present?
|
||||
|
||||
response = self.class.post(full_url, options)
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def headers
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'x-LSQ-AccessKey': @access_key,
|
||||
'x-LSQ-SecretKey': @secret_key
|
||||
}
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
case response.code
|
||||
when 200..299
|
||||
handle_success(response)
|
||||
else
|
||||
error_message = "LeadSquared API error: #{response.code} - #{response.body}"
|
||||
Rails.logger.error error_message
|
||||
raise ApiError.new(error_message, response.code, response)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_success(response)
|
||||
parse_response(response)
|
||||
rescue JSON::ParserError, TypeError => e
|
||||
error_message = "Failed to parse LeadSquared API response: #{e.message}"
|
||||
raise ApiError.new(error_message, response.code, response)
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
body = response.parsed_response
|
||||
|
||||
if body.is_a?(Hash) && body['Status'] == 'Error'
|
||||
error_message = body['ExceptionMessage'] || 'Unknown API error'
|
||||
raise ApiError.new(error_message, response.code, response)
|
||||
else
|
||||
body
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient
|
||||
# https://apidocs.leadsquared.com/quick-search/#api
|
||||
def search_lead(key)
|
||||
raise ArgumentError, 'Search key is required' if key.blank?
|
||||
|
||||
path = 'LeadManagement.svc/Leads.GetByQuickSearch'
|
||||
params = { key: key }
|
||||
|
||||
get(path, params)
|
||||
end
|
||||
|
||||
# https://apidocs.leadsquared.com/create-or-update/#api
|
||||
# The email address and phone fields are used as the default search criteria.
|
||||
# If none of these match with an existing lead, a new lead will be created.
|
||||
# We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however
|
||||
# we don't need this capability at the moment
|
||||
def create_or_update_lead(lead_data)
|
||||
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
||||
|
||||
path = 'LeadManagement.svc/Lead.CreateOrUpdate'
|
||||
|
||||
formatted_data = format_lead_data(lead_data)
|
||||
response = post(path, {}, formatted_data)
|
||||
|
||||
response['Message']['Id']
|
||||
end
|
||||
|
||||
def update_lead(lead_data, lead_id)
|
||||
raise ArgumentError, 'Lead ID is required' if lead_id.blank?
|
||||
raise ArgumentError, 'Lead data is required' if lead_data.blank?
|
||||
|
||||
path = "LeadManagement.svc/Lead.Update?leadId=#{lead_id}"
|
||||
formatted_data = format_lead_data(lead_data)
|
||||
|
||||
response = post(path, {}, formatted_data)
|
||||
|
||||
response['Message']['AffectedRows']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_lead_data(lead_data)
|
||||
lead_data.map do |key, value|
|
||||
{
|
||||
'Attribute' => key,
|
||||
'Value' => value
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
class Crm::Leadsquared::LeadFinderService
|
||||
def initialize(lead_client)
|
||||
@lead_client = lead_client
|
||||
end
|
||||
|
||||
def find_or_create(contact)
|
||||
lead_id = get_stored_id(contact)
|
||||
return lead_id if lead_id.present?
|
||||
|
||||
lead_id = find_by_contact(contact)
|
||||
return lead_id if lead_id.present?
|
||||
|
||||
create_lead(contact)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_by_contact(contact)
|
||||
lead_id = find_by_email(contact)
|
||||
lead_id = find_by_phone_number(contact) if lead_id.blank?
|
||||
|
||||
lead_id
|
||||
end
|
||||
|
||||
def find_by_email(contact)
|
||||
return if contact.email.blank?
|
||||
|
||||
search_by_field(contact.email)
|
||||
end
|
||||
|
||||
def find_by_phone_number(contact)
|
||||
return if contact.phone_number.blank?
|
||||
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
|
||||
return if lead_data.blank? || lead_data['Mobile'].nil?
|
||||
|
||||
search_by_field(lead_data['Mobile'])
|
||||
end
|
||||
|
||||
def search_by_field(value)
|
||||
leads = @lead_client.search_lead(value)
|
||||
return nil unless leads.is_a?(Array)
|
||||
|
||||
leads.first['ProspectID'] if leads.any?
|
||||
end
|
||||
|
||||
def create_lead(contact)
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
lead_id = @lead_client.create_or_update_lead(lead_data)
|
||||
|
||||
raise StandardError, 'Failed to create lead - no ID returned' if lead_id.blank?
|
||||
|
||||
lead_id
|
||||
end
|
||||
|
||||
def get_stored_id(contact)
|
||||
return nil if contact.additional_attributes.blank?
|
||||
return nil if contact.additional_attributes['external'].blank?
|
||||
|
||||
contact.additional_attributes.dig('external', 'leadsquared_id')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
class Crm::Leadsquared::Mappers::ContactMapper
|
||||
def self.map(contact)
|
||||
new(contact).map
|
||||
end
|
||||
|
||||
def initialize(contact)
|
||||
@contact = contact
|
||||
end
|
||||
|
||||
def map
|
||||
base_attributes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :contact
|
||||
|
||||
def base_attributes
|
||||
{
|
||||
'FirstName' => contact.name.presence,
|
||||
'LastName' => contact.last_name.presence,
|
||||
'EmailAddress' => contact.email.presence,
|
||||
'Mobile' => formatted_phone_number,
|
||||
'Source' => brand_name
|
||||
}.compact
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
# it seems like leadsquared needs a different phone number format
|
||||
# it's not documented anywhere, so don't bother trying to look up online
|
||||
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
|
||||
return nil if contact.phone_number.blank?
|
||||
|
||||
parsed = TelephoneNumber.parse(contact.phone_number)
|
||||
return contact.phone_number unless parsed.valid?
|
||||
|
||||
country_code = parsed.country.country_code
|
||||
e164 = parsed.e164_number
|
||||
e164 = e164.sub(/^\+/, '')
|
||||
|
||||
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
|
||||
|
||||
"+#{country_code}-#{national_number}"
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||
end
|
||||
|
||||
def brand_name_without_spaces
|
||||
brand_name.gsub(/\s+/, '')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
class Crm::Leadsquared::Mappers::ConversationMapper
|
||||
include ::Rails.application.routes.url_helpers
|
||||
|
||||
# https://help.leadsquared.com/what-is-the-maximum-character-length-supported-for-lead-and-activity-fields/
|
||||
# the rest of the body of the note is around 200 chars
|
||||
# so this limits it
|
||||
ACTIVITY_NOTE_MAX_SIZE = 1800
|
||||
|
||||
def self.map_conversation_activity(hook, conversation)
|
||||
new(hook, conversation).conversation_activity
|
||||
end
|
||||
|
||||
def self.map_transcript_activity(hook, conversation)
|
||||
new(hook, conversation).transcript_activity
|
||||
end
|
||||
|
||||
def initialize(hook, conversation)
|
||||
@hook = hook
|
||||
@timezone = Time.find_zone(hook.settings['timezone']) || Time.zone
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def conversation_activity
|
||||
I18n.t('crm.created_activity',
|
||||
brand_name: brand_name,
|
||||
channel_info: conversation.inbox.name,
|
||||
formatted_creation_time: formatted_creation_time,
|
||||
display_id: conversation.display_id,
|
||||
url: conversation_url)
|
||||
end
|
||||
|
||||
def transcript_activity
|
||||
return I18n.t('crm.no_message') if transcript_messages.empty?
|
||||
|
||||
I18n.t('crm.transcript_activity',
|
||||
brand_name: brand_name,
|
||||
channel_info: conversation.inbox.name,
|
||||
display_id: conversation.display_id,
|
||||
url: conversation_url,
|
||||
format_messages: format_messages)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation
|
||||
|
||||
def formatted_creation_time
|
||||
conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S')
|
||||
end
|
||||
|
||||
def transcript_messages
|
||||
@transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?)
|
||||
end
|
||||
|
||||
def format_messages
|
||||
selected_messages = []
|
||||
separator = "\n\n"
|
||||
current_length = 0
|
||||
|
||||
# Reverse the messages to have latest on top
|
||||
transcript_messages.reverse_each do |message|
|
||||
formatted_message = format_message(message)
|
||||
required_length = formatted_message.length + separator.length # the last one does not need to account for separator, but we add it anyway
|
||||
|
||||
break unless (current_length + required_length) <= ACTIVITY_NOTE_MAX_SIZE
|
||||
|
||||
selected_messages << formatted_message
|
||||
current_length += required_length
|
||||
end
|
||||
|
||||
selected_messages.join(separator)
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
<<~MESSAGE.strip
|
||||
[#{message_time(message)}] #{sender_name(message)}: #{message_content(message)}#{attachment_info(message)}
|
||||
MESSAGE
|
||||
end
|
||||
|
||||
def message_time(message)
|
||||
message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M')
|
||||
end
|
||||
|
||||
def sender_name(message)
|
||||
return 'System' if message.sender.nil?
|
||||
|
||||
message.sender.name.presence || "#{message.sender_type} #{message.sender_id}"
|
||||
end
|
||||
|
||||
def message_content(message)
|
||||
message.content.presence || I18n.t('crm.no_content')
|
||||
end
|
||||
|
||||
def attachment_info(message)
|
||||
return '' unless message.attachments.any?
|
||||
|
||||
attachments = message.attachments.map { |a| I18n.t('crm.attachment', type: a.file_type) }.join(', ')
|
||||
"\n#{attachments}"
|
||||
end
|
||||
|
||||
def conversation_url
|
||||
app_account_conversation_url(account_id: conversation.account.id, id: conversation.display_id)
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService
|
||||
def self.crm_name
|
||||
'leadsquared'
|
||||
end
|
||||
|
||||
def initialize(hook)
|
||||
super(hook)
|
||||
@access_key = hook.settings['access_key']
|
||||
@secret_key = hook.settings['secret_key']
|
||||
@endpoint_url = hook.settings['endpoint_url']
|
||||
|
||||
@allow_transcript = hook.settings['enable_transcript_activity']
|
||||
@allow_conversation = hook.settings['enable_conversation_activity']
|
||||
|
||||
# Initialize API clients
|
||||
@lead_client = Crm::Leadsquared::Api::LeadClient.new(@access_key, @secret_key, @endpoint_url)
|
||||
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, @endpoint_url)
|
||||
@lead_finder = Crm::Leadsquared::LeadFinderService.new(@lead_client)
|
||||
end
|
||||
|
||||
def handle_contact(contact)
|
||||
contact.reload
|
||||
unless identifiable_contact?(contact)
|
||||
Rails.logger.info("Contact not identifiable. Skipping handle_contact for ##{contact.id}")
|
||||
return
|
||||
end
|
||||
|
||||
stored_lead_id = get_external_id(contact)
|
||||
create_or_update_lead(contact, stored_lead_id)
|
||||
end
|
||||
|
||||
def handle_conversation_created(conversation)
|
||||
return unless @allow_conversation
|
||||
|
||||
create_conversation_activity(
|
||||
conversation: conversation,
|
||||
activity_type: 'conversation',
|
||||
activity_code_key: 'conversation_activity_code',
|
||||
metadata_key: 'created_activity_id',
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation)
|
||||
)
|
||||
end
|
||||
|
||||
def handle_conversation_resolved(conversation)
|
||||
return unless @allow_transcript
|
||||
return unless conversation.status == 'resolved'
|
||||
|
||||
create_conversation_activity(
|
||||
conversation: conversation,
|
||||
activity_type: 'transcript',
|
||||
activity_code_key: 'transcript_activity_code',
|
||||
metadata_key: 'transcript_activity_id',
|
||||
activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_or_update_lead(contact, lead_id)
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
|
||||
# Why can't we use create_or_update_lead here?
|
||||
# In LeadSquared, it's possible that the email field
|
||||
# may not be marked as unique, same with the phone number field
|
||||
# So we just use the update API if we already have a lead ID
|
||||
if lead_id.present?
|
||||
@lead_client.update_lead(lead_data, lead_id)
|
||||
else
|
||||
new_lead_id = @lead_client.create_or_update_lead(lead_data)
|
||||
store_external_id(contact, new_lead_id)
|
||||
end
|
||||
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "LeadSquared API error processing contact: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "Error processing contact in LeadSquared: #{e.message}"
|
||||
end
|
||||
|
||||
def create_conversation_activity(conversation:, activity_type:, activity_code_key:, metadata_key:, activity_note:)
|
||||
lead_id = get_lead_id(conversation.contact)
|
||||
return if lead_id.blank?
|
||||
|
||||
activity_code = get_activity_code(activity_code_key)
|
||||
activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note)
|
||||
return if activity_id.blank?
|
||||
|
||||
metadata = {}
|
||||
metadata[metadata_key] = activity_id
|
||||
store_conversation_metadata(conversation, metadata)
|
||||
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @account).capture_exception
|
||||
Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}"
|
||||
end
|
||||
|
||||
def get_activity_code(key)
|
||||
activity_code = @hook.settings[key]
|
||||
raise StandardError, "LeadSquared #{key} activity code not found for hook ##{@hook.id}." if activity_code.blank?
|
||||
|
||||
activity_code
|
||||
end
|
||||
|
||||
def get_lead_id(contact)
|
||||
contact.reload # reload to ensure all the attributes are up-to-date
|
||||
|
||||
unless identifiable_contact?(contact)
|
||||
Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}")
|
||||
nil
|
||||
end
|
||||
|
||||
lead_id = @lead_finder.find_or_create(contact)
|
||||
return nil if lead_id.blank?
|
||||
|
||||
store_external_id(contact, lead_id) unless get_external_id(contact)
|
||||
|
||||
lead_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,109 @@
|
||||
class Crm::Leadsquared::SetupService
|
||||
def initialize(hook)
|
||||
@hook = hook
|
||||
credentials = @hook.settings
|
||||
|
||||
@access_key = credentials['access_key']
|
||||
@secret_key = credentials['secret_key']
|
||||
|
||||
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
|
||||
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/')
|
||||
end
|
||||
|
||||
def setup
|
||||
setup_endpoint
|
||||
setup_activity
|
||||
rescue Crm::Leadsquared::Api::BaseClient::ApiError => e
|
||||
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
|
||||
Rails.logger.error "LeadSquared API error in setup: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception
|
||||
Rails.logger.error "Error during LeadSquared setup: #{e.message}"
|
||||
end
|
||||
|
||||
def setup_endpoint
|
||||
response = @client.get('Authentication.svc/UserByAccessKey.Get')
|
||||
endpoint_host = response['LSQCommonServiceURLs']['api']
|
||||
app_host = response['LSQCommonServiceURLs']['app']
|
||||
timezone = response['TimeZone']
|
||||
|
||||
endpoint_url = "https://#{endpoint_host}/v2/"
|
||||
app_url = "https://#{app_host}/"
|
||||
|
||||
update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone })
|
||||
|
||||
# replace the clients
|
||||
@client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url)
|
||||
@activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, endpoint_url)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_activity
|
||||
existing_types = @activity_client.fetch_activity_types
|
||||
return if existing_types.blank?
|
||||
|
||||
activity_codes = setup_activity_types(existing_types)
|
||||
return if activity_codes.blank?
|
||||
|
||||
update_hook_settings(activity_codes)
|
||||
|
||||
activity_codes
|
||||
end
|
||||
|
||||
def setup_activity_types(existing_types)
|
||||
activity_codes = {}
|
||||
|
||||
activity_types.each do |activity_type|
|
||||
activity_id = find_or_create_activity_type(activity_type, existing_types)
|
||||
|
||||
if activity_id.present?
|
||||
activity_codes[activity_type[:setting_key]] = activity_id.to_i
|
||||
else
|
||||
Rails.logger.error "Failed to find or create activity type: #{activity_type[:name]}"
|
||||
end
|
||||
end
|
||||
|
||||
activity_codes
|
||||
end
|
||||
|
||||
def find_or_create_activity_type(activity_type, existing_types)
|
||||
existing = existing_types.find { |t| t['ActivityEventName'] == activity_type[:name] }
|
||||
|
||||
if existing
|
||||
existing['ActivityEvent'].to_i
|
||||
else
|
||||
@activity_client.create_activity_type(
|
||||
name: activity_type[:name],
|
||||
score: activity_type[:score],
|
||||
direction: activity_type[:direction]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def update_hook_settings(params)
|
||||
@hook.settings = @hook.settings.merge(params)
|
||||
@hook.save!
|
||||
end
|
||||
|
||||
def activity_types
|
||||
[
|
||||
{
|
||||
name: "#{brand_name} Conversation Started",
|
||||
score: @hook.settings['conversation_activity_score'].to_i || 0,
|
||||
direction: 0,
|
||||
setting_key: 'conversation_activity_code'
|
||||
},
|
||||
{
|
||||
name: "#{brand_name} Conversation Transcript",
|
||||
score: @hook.settings['transcript_activity_score'].to_i || 0,
|
||||
direction: 0,
|
||||
setting_key: 'transcript_activity_code'
|
||||
}
|
||||
].freeze
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'].presence || 'Chatwoot'
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
rescue Facebook::Messenger::FacebookError => e
|
||||
# TODO : handle specific errors or else page will get disconnected
|
||||
handle_facebook_error(e)
|
||||
message.update!(status: :failed, external_error: e.message)
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
|
||||
def send_message_to_facebook(delivery_params)
|
||||
@@ -24,7 +24,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
return if parsed_result.nil?
|
||||
|
||||
if parsed_result['error'].present?
|
||||
message.update!(status: :failed, external_error: external_error(parsed_result))
|
||||
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_result)).perform
|
||||
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{parsed_result}"
|
||||
end
|
||||
|
||||
@@ -35,11 +35,11 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
|
||||
JSON.parse(result)
|
||||
rescue JSON::ParserError
|
||||
message.update!(status: :failed, external_error: 'Facebook was unable to process this request')
|
||||
Messages::StatusUpdateService.new(message, 'failed', 'Facebook was unable to process this request').perform
|
||||
Rails.logger.error "Facebook::SendOnFacebookService: Error parsing JSON response from Facebook : Page - #{channel.page_id} : #{result}"
|
||||
nil
|
||||
rescue Net::OpenTimeout
|
||||
message.update!(status: :failed, external_error: 'Request timed out, please try again later')
|
||||
Messages::StatusUpdateService.new(message, 'failed', 'Request timed out, please try again later').perform
|
||||
Rails.logger.error "Facebook::SendOnFacebookService: Timeout error sending message to Facebook : Page - #{channel.page_id}"
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -61,7 +61,7 @@ class Instagram::BaseSendService < Base::SendOnChannelService
|
||||
else
|
||||
external_error = external_error(parsed_response)
|
||||
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
|
||||
message.update!(status: :failed, external_error: external_error)
|
||||
Messages::StatusUpdateService.new(message, 'failed', external_error).perform
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,10 +14,10 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
||||
|
||||
if response.code == '200'
|
||||
# If the request is successful, update the message status to delivered
|
||||
message.update!(status: :delivered)
|
||||
Messages::StatusUpdateService.new(message, 'delivered').perform
|
||||
else
|
||||
# If the request is not successful, update the message status to failed and save the external error
|
||||
message.update!(status: :failed, external_error: external_error(parsed_json))
|
||||
Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,17 @@ class MessageTemplates::HookExecutionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return if conversation.campaign.present?
|
||||
return if conversation.last_incoming_message.blank?
|
||||
if conversation.campaign.present?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not triggering templates because conversation has a campaign" }
|
||||
return
|
||||
end
|
||||
|
||||
if conversation.last_incoming_message.blank?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not triggering templates because there is no incoming message" }
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "[OutOfOffice][#{conversation.id}] Triggering templates for conversation ##{conversation.id}"
|
||||
trigger_templates
|
||||
end
|
||||
|
||||
@@ -22,14 +30,41 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
if conversation.tweet?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because it's a tweet conversation" }
|
||||
return false
|
||||
end
|
||||
|
||||
# should not send for outbound messages
|
||||
return false unless message.incoming?
|
||||
unless message.incoming?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because the message is outgoing" }
|
||||
return false
|
||||
end
|
||||
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
# ensures better UX by not interrupting active conversations at the end of business hours
|
||||
return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
|
||||
if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because an agent responded in the last 5 minutes" }
|
||||
return false
|
||||
end
|
||||
|
||||
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
|
||||
can_send = inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
|
||||
|
||||
if can_send
|
||||
Rails.logger.info "[OutOfOffice][#{conversation.id}] Sending out-of-office message for conversation ##{conversation.id}"
|
||||
else
|
||||
reasons = []
|
||||
reasons << 'inbox not in out-of-office mode' unless inbox.out_of_office?
|
||||
reasons << 'conversation already has a template message today' unless conversation.messages.today.template.empty?
|
||||
reasons << 'inbox has no out-of-office message configured' unless inbox.out_of_office_message.present?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because: #{reasons.join(', ')}" }
|
||||
end
|
||||
|
||||
can_send
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[OutOfOffice][#{conversation.id}] Error triggering out of office message: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
false
|
||||
end
|
||||
|
||||
def first_message_from_contact?
|
||||
|
||||
@@ -2,10 +2,12 @@ class MessageTemplates::Template::OutOfOffice
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def perform
|
||||
Rails.logger.info("[OutOfOffice][#{conversation.id}] Triggering out of office message")
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[OutOfOffice][#{conversation.id}] Error triggering out of office message: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
class Messages::StatusUpdateService
|
||||
attr_reader :message, :status, :external_error
|
||||
|
||||
def initialize(message, status, external_error = nil)
|
||||
@message = message
|
||||
@status = status
|
||||
@external_error = external_error
|
||||
end
|
||||
|
||||
def perform
|
||||
return false unless valid_status_transition?
|
||||
|
||||
update_message_status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_message_status
|
||||
# Update status and set external_error only when failed
|
||||
message.update!(
|
||||
status: status,
|
||||
external_error: (status == 'failed' ? external_error : nil)
|
||||
)
|
||||
end
|
||||
|
||||
def valid_status_transition?
|
||||
return false unless Message.statuses.key?(status)
|
||||
|
||||
# Don't allow changing from 'read' to 'delivered'
|
||||
return false if message.read? && status == 'delivered'
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
||||
begin
|
||||
twilio_message = channel.send_message(**message_params)
|
||||
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||
message.update!(status: :failed, external_error: e.message)
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
message.update!(source_id: twilio_message.sid) if twilio_message
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/message', message: @message
|
||||
@@ -1,6 +1,7 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.description resource.description
|
||||
json.short_description resource.short_description.presence
|
||||
json.enabled resource.enabled?(@current_account)
|
||||
|
||||
if Current.account_user&.administrator?
|
||||
|
||||
@@ -66,6 +66,13 @@ window.addEventListener('chatwoot:postback', function(e) {
|
||||
console.log('chatwoot:postback', e.detail)
|
||||
})
|
||||
|
||||
window.addEventListener('chatwoot:opened', function() {
|
||||
console.log('chatwoot:opened')
|
||||
})
|
||||
|
||||
window.addEventListener('chatwoot:closed', function() {
|
||||
console.log('chatwoot:closed')
|
||||
})
|
||||
|
||||
window.addEventListener('chatwoot:on-start-conversation', function(e) {
|
||||
console.log('chatwoot:on-start-conversation', e.detail)
|
||||
|
||||
@@ -11,6 +11,6 @@ class EmailReplyWorker
|
||||
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
message.update!(status: :failed, external_error: e.message)
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,6 +44,8 @@ module Chatwoot
|
||||
# rubocop:disable Rails/FilePath
|
||||
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
|
||||
# rubocop:enable Rails/FilePath
|
||||
# Add enterprise views to the view paths
|
||||
config.paths['app/views'].unshift('enterprise/app/views')
|
||||
|
||||
# Settings in config/environments/* take precedence over those specified here.
|
||||
# Application configuration can go into files in config/initializers
|
||||
|
||||
@@ -166,3 +166,6 @@
|
||||
- name: channel_instagram
|
||||
display_name: Instagram Channel
|
||||
enabled: true
|
||||
- name: crm_integration
|
||||
display_name: CRM Integration
|
||||
enabled: false
|
||||
@@ -4,6 +4,7 @@
|
||||
# i18n_key: the key under which translations for the integration is placed in en.yml
|
||||
# action: if integration requires external redirect url
|
||||
# hook_type: ( account / inbox )
|
||||
# feature_flag: (string) feature flag to enable/disable the integration
|
||||
# allow_multiple_hooks: whether multiple hooks can be created for the integration
|
||||
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
|
||||
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
|
||||
@@ -186,3 +187,80 @@ shopify:
|
||||
i18n_key: shopify
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
|
||||
leadsquared:
|
||||
id: leadsquared
|
||||
feature_flag: crm_integration
|
||||
logo: leadsquared.png
|
||||
i18n_key: leadsquared
|
||||
action: /leadsquared
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
settings_json_schema:
|
||||
{
|
||||
'type': 'object',
|
||||
'properties':
|
||||
{
|
||||
'access_key': { 'type': 'string' },
|
||||
'secret_key': { 'type': 'string' },
|
||||
'endpoint_url': { 'type': 'string' },
|
||||
'app_url': { 'type': 'string' },
|
||||
'timezone': { 'type': 'string' },
|
||||
'enable_conversation_activity': { 'type': 'boolean' },
|
||||
'enable_transcript_activity': { 'type': 'boolean' },
|
||||
'conversation_activity_score': { 'type': 'string' },
|
||||
'transcript_activity_score': { 'type': 'string' },
|
||||
'conversation_activity_code': { 'type': 'integer' },
|
||||
'transcript_activity_code': { 'type': 'integer' },
|
||||
},
|
||||
'required': ['access_key', 'secret_key'],
|
||||
'additionalProperties': false,
|
||||
}
|
||||
settings_form_schema:
|
||||
[
|
||||
{
|
||||
'label': 'Access Key',
|
||||
'type': 'text',
|
||||
'name': 'access_key',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'Secret Key',
|
||||
'type': 'text',
|
||||
'name': 'secret_key',
|
||||
'validation': 'required',
|
||||
},
|
||||
{
|
||||
'label': 'Push Conversation Activity',
|
||||
'type': 'checkbox',
|
||||
'name': 'enable_conversation_activity',
|
||||
'help': 'Enable this option to push an activity when a conversation is created',
|
||||
},
|
||||
{
|
||||
'label': 'Conversation Activity Score',
|
||||
'type': 'number',
|
||||
'name': 'conversation_activity_score',
|
||||
'help': 'Score to assign to the conversation created activity, default is 0',
|
||||
},
|
||||
{
|
||||
'label': 'Push Transcript Activity',
|
||||
'type': 'checkbox',
|
||||
'name': 'enable_transcript_activity',
|
||||
'help': 'Enable this option to push an activity when a transcript is created',
|
||||
},
|
||||
{
|
||||
'label': 'Transcript Activity Score',
|
||||
'type': 'number',
|
||||
'name': 'transcript_activity_score',
|
||||
'help': 'Score to assign to the conversation transcript activity, default is 0',
|
||||
},
|
||||
]
|
||||
visible_properties:
|
||||
[
|
||||
'access_key',
|
||||
'endpoint_url',
|
||||
'enable_conversation_activity',
|
||||
'enable_transcript_activity',
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
@@ -234,6 +234,10 @@ en:
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.'
|
||||
leadsquared:
|
||||
name: 'LeadSquared'
|
||||
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
|
||||
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
|
||||
captain:
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||
@@ -296,3 +300,23 @@ en:
|
||||
other: '%{count} seconds'
|
||||
automation:
|
||||
system_name: 'Automation System'
|
||||
crm:
|
||||
no_message: 'No messages in conversation'
|
||||
attachment: '[Attachment: %{type}]'
|
||||
no_content: '[No content]'
|
||||
created_activity: |
|
||||
New conversation started on %{brand_name}
|
||||
|
||||
Channel: %{channel_info}
|
||||
Created: %{formatted_creation_time}
|
||||
Conversation ID: %{display_id}
|
||||
View in %{brand_name}: %{url}
|
||||
transcript_activity: |
|
||||
Conversation Transcript from %{brand_name}
|
||||
|
||||
Channel: %{channel_info}
|
||||
Conversation ID: %{display_id}
|
||||
View in %{brand_name}: %{url}
|
||||
|
||||
Transcript:
|
||||
%{format_messages}
|
||||
|
||||
+4
-1
@@ -53,6 +53,9 @@ Rails.application.routes.draw do
|
||||
end
|
||||
namespace :captain do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
end
|
||||
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
@@ -98,7 +101,7 @@ Rails.application.routes.draw do
|
||||
post :filter
|
||||
end
|
||||
scope module: :conversations do
|
||||
resources :messages, only: [:index, :create, :destroy] do
|
||||
resources :messages, only: [:index, :create, :destroy, :update] do
|
||||
member do
|
||||
post :translate
|
||||
post :retry
|
||||
|
||||
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -23,6 +23,15 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
params[:message_content],
|
||||
message_history
|
||||
)
|
||||
|
||||
render json: response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_assistant
|
||||
@@ -34,6 +43,19 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def assistant_params
|
||||
params.require(:assistant).permit(:name, :description, config: [:product_name, :feature_faq, :feature_memory])
|
||||
params.require(:assistant).permit(:name, :description,
|
||||
config: [
|
||||
:product_name, :feature_faq, :feature_memory,
|
||||
:welcome_message, :handoff_message, :resolution_message,
|
||||
:instructions
|
||||
])
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
|
||||
end
|
||||
|
||||
def message_history
|
||||
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,8 +58,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def check_cloud_env
|
||||
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
|
||||
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
|
||||
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
def default_limits
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
module Enterprise::Concerns::ApplicationControllerConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :prepend_view_paths
|
||||
end
|
||||
|
||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
||||
def prepend_view_paths
|
||||
prepend_view_path 'enterprise/app/views/'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::SuperAdmin::AccountsController
|
||||
def update
|
||||
# Handle manually managed features from form submission
|
||||
if params[:account] && params[:account][:manually_managed_features].present?
|
||||
# Update using the service - it will handle array conversion and validation
|
||||
service = ::Internal::Accounts::InternalAttributesService.new(requested_resource)
|
||||
service.manually_managed_features = params[:account][:manually_managed_features]
|
||||
|
||||
# Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError
|
||||
params[:account].delete(:manually_managed_features)
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,2 @@
|
||||
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
|
||||
before_action :prepend_view_paths
|
||||
|
||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
||||
def prepend_view_paths
|
||||
prepend_view_path 'enterprise/app/views/'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class AccountFeaturesField < Administrate::Field::Base
|
||||
def to_s
|
||||
data
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class Enterprise::AccountLimitsField < Administrate::Field::Base
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class ManuallyManagedFeaturesField < Administrate::Field::Base
|
||||
def data
|
||||
Internal::Accounts::InternalAttributesService.new(resource).manually_managed_features
|
||||
end
|
||||
|
||||
def to_s
|
||||
data.is_a?(Array) ? data.join(', ') : '[]'
|
||||
end
|
||||
|
||||
def all_features
|
||||
# Business and Enterprise plan features only
|
||||
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||
end
|
||||
|
||||
def selected_features
|
||||
# If we have direct array data, use it (for rendering after form submission)
|
||||
return data if data.is_a?(Array)
|
||||
|
||||
# Otherwise, use the service to retrieve the data from internal_attributes
|
||||
if resource.respond_to?(:internal_attributes)
|
||||
service = Internal::Accounts::InternalAttributesService.new(resource)
|
||||
return service.manually_managed_features
|
||||
end
|
||||
|
||||
# Fallback to empty array if no data available
|
||||
[]
|
||||
end
|
||||
end
|
||||
@@ -36,6 +36,7 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
@@ -46,20 +47,26 @@ module Captain::ChatHelper
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
process_tool_call(tool_calls.first)
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
return unless tool_call['function']['name'] == 'search_documentation'
|
||||
|
||||
tool_call_id = tool_call['id']
|
||||
query = JSON.parse(tool_call['function']['arguments'])['search_query']
|
||||
sections = fetch_documentation(query)
|
||||
append_tool_response(sections, tool_call_id)
|
||||
tool_calls.each do |tool_call|
|
||||
process_tool_call(tool_call)
|
||||
end
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if tool_call['function']['name'] == 'search_documentation'
|
||||
query = JSON.parse(tool_call['function']['arguments'])['search_query']
|
||||
sections = fetch_documentation(query)
|
||||
append_tool_response(sections, tool_call_id)
|
||||
else
|
||||
append_tool_response('', tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_documentation(query)
|
||||
Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
|
||||
@assistant
|
||||
.responses
|
||||
.approved
|
||||
|
||||
@@ -60,7 +60,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
create_outgoing_message('Transferring to another agent for further assistance.')
|
||||
create_outgoing_message(@assistant.config['handoff_message'] || 'Transferring to another agent for further assistance.')
|
||||
end
|
||||
|
||||
def create_messages
|
||||
|
||||
@@ -5,12 +5,13 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
# limiting the number of conversations to be resolved to avoid any performance issues
|
||||
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
resolvable_conversations.each do |conversation|
|
||||
resolution_message = conversation.inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: I18n.t('conversations.activity.auto_resolution_message')
|
||||
content: resolution_message || I18n.t('conversations.activity.auto_resolution_message')
|
||||
}
|
||||
)
|
||||
conversation.resolved!
|
||||
|
||||
@@ -2,7 +2,7 @@ class Internal::AccountAnalysisJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account)
|
||||
return if GlobalConfig.get_value('DEPLOYMENT_ENV') != 'cloud'
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
module Enterprise::Account
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
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
|
||||
|
||||
|
||||
@@ -18,4 +18,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def playground?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,7 +22,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'], @assistant.config)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,7 +103,7 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def assistant_response_generator(product_name)
|
||||
def assistant_response_generator(product_name, config = {})
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
[Identity]
|
||||
You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
|
||||
@@ -111,6 +111,7 @@ class Captain::Llm::SystemPromptsService
|
||||
[Response Guideline]
|
||||
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
|
||||
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
|
||||
- Always detect the language from input and reply in the same language. Do not use any other language.
|
||||
- Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation.
|
||||
- Use discourse markers to ease comprehension. Never use the list format.
|
||||
- Do not generate a response more than three sentences.
|
||||
@@ -136,6 +137,7 @@ class Captain::Llm::SystemPromptsService
|
||||
- Do not share anything outside of the context provided.
|
||||
- Add the reasoning why you arrived at the answer
|
||||
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
|
||||
#{config['instructions'] || ''}
|
||||
```json
|
||||
{
|
||||
reasoning: '',
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
class Enterprise::Billing::HandleStripeEventService
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startups plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding].freeze
|
||||
|
||||
def perform(event:)
|
||||
ensure_event_context(event)
|
||||
@event = event
|
||||
|
||||
case @event.type
|
||||
when 'customer.subscription.updated'
|
||||
process_subscription_updated
|
||||
@@ -20,14 +45,12 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
return if plan.blank? || account.blank?
|
||||
|
||||
update_account_attributes(subscription, plan)
|
||||
|
||||
change_plan_features
|
||||
update_plan_features
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def update_account_attributes(subscription, plan)
|
||||
# https://stripe.com/docs/api/subscriptions/object
|
||||
|
||||
account.update(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: subscription.customer,
|
||||
@@ -48,25 +71,57 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
|
||||
def change_plan_features
|
||||
def update_plan_features
|
||||
if default_plan?
|
||||
account.disable_features(*features_to_update)
|
||||
disable_all_premium_features
|
||||
else
|
||||
account.enable_features(*features_to_update)
|
||||
enable_features_for_current_plan
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage
|
||||
end
|
||||
|
||||
def ensure_event_context(event)
|
||||
@event = event
|
||||
end
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
def features_to_update
|
||||
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startups'
|
||||
# Startups plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startups features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def subscription
|
||||
@@ -78,13 +133,22 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
installation_config.value.find { |config| config['product_id'].include?(plan_id) }
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
default_plan = installation_config.value.first
|
||||
@account.custom_attributes['plan_name'] == default_plan['name']
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
default_plan = cloud_plans.first || {}
|
||||
account.custom_attributes['plan_name'] == default_plan['name']
|
||||
end
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class Internal::Accounts::InternalAttributesService
|
||||
attr_reader :account
|
||||
|
||||
# List of keys that can be managed through this service
|
||||
# TODO: Add account_notes field in future
|
||||
# This field can be used to store notes about account on Chatwoot cloud
|
||||
VALID_KEYS = %w[manually_managed_features].freeze
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
# Get a value from internal_attributes
|
||||
def get(key)
|
||||
validate_key!(key)
|
||||
account.internal_attributes[key]
|
||||
end
|
||||
|
||||
# Set a value in internal_attributes
|
||||
def set(key, value)
|
||||
validate_key!(key)
|
||||
|
||||
# Create a new hash to avoid modifying the original
|
||||
new_attrs = account.internal_attributes.dup || {}
|
||||
new_attrs[key] = value
|
||||
|
||||
# Update the account
|
||||
account.internal_attributes = new_attrs
|
||||
account.save
|
||||
end
|
||||
|
||||
# Get manually managed features
|
||||
def manually_managed_features
|
||||
get('manually_managed_features') || []
|
||||
end
|
||||
|
||||
# Set manually managed features
|
||||
def manually_managed_features=(features)
|
||||
features = [] if features.nil?
|
||||
features = [features] unless features.is_a?(Array)
|
||||
|
||||
# Clean up the array: remove empty strings, whitespace, and validate against valid features
|
||||
valid_features = valid_feature_list
|
||||
features = features.compact
|
||||
.map(&:strip)
|
||||
.reject(&:empty?)
|
||||
.select { |f| valid_features.include?(f) }
|
||||
.uniq
|
||||
|
||||
set('manually_managed_features', features)
|
||||
end
|
||||
|
||||
# Get list of valid features that can be manually managed
|
||||
def valid_feature_list
|
||||
# Business and Enterprise plan features only
|
||||
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||
end
|
||||
|
||||
# Account notes functionality removed for now
|
||||
# Will be re-implemented when UI is ready
|
||||
|
||||
private
|
||||
|
||||
def validate_key!(key)
|
||||
raise ArgumentError, "Invalid internal attribute key: #{key}" unless VALID_KEYS.include?(key)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
<%
|
||||
# Get all feature names and their display names
|
||||
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
|
||||
|
||||
# Business and Enterprise plan features only
|
||||
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||
|
||||
# Get only premium features with display names
|
||||
premium_features_with_display = premium_features.map do |feature|
|
||||
[feature, all_feature_display_names[feature] || feature.humanize]
|
||||
end.sort_by { |_, display_name| display_name }
|
||||
|
||||
# Get already selected features
|
||||
selected_features = field.selected_features
|
||||
%>
|
||||
|
||||
<div class="field-unit__label">
|
||||
<%= f.label :manually_managed_features %>
|
||||
</div>
|
||||
<div class="field-unit__field feature-container">
|
||||
<p class="text-gray-400 text-xs italic mb-4">Features that remain enabled even when account plan is downgraded</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% premium_features_with_display.each do |feature_key, display_name| %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container">
|
||||
<span class="text-sm text-slate-700"><%= display_name %></span>
|
||||
<span>
|
||||
<%= check_box_tag "account[manually_managed_features][]",
|
||||
feature_key,
|
||||
selected_features.include?(feature_key),
|
||||
class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 border-t border-n-weak">
|
||||
|
||||
<%= hidden_field_tag "account[manually_managed_features][]", "", id: nil %>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<%
|
||||
selected_features = field.selected_features
|
||||
|
||||
# Get all feature names and their display names
|
||||
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
|
||||
|
||||
# Business and Enterprise plan features only
|
||||
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||
%>
|
||||
|
||||
<% if selected_features.present? %>
|
||||
<div class="w-full">
|
||||
<p class="text-gray-400 text-xs italic mb-2">Features that remain enabled even when account plan is downgraded</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% selected_features.each do |feature| %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
|
||||
<span class="text-sm text-n-slate-12"><%= all_feature_display_names[feature] || feature.humanize %></span>
|
||||
<span class="bg-green-400 text-white rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="12" height="12"><use xlink:href="#icon-tick-line" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 border-t border-n-weak">
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-400 text-xs italic">No manually managed features configured</p>
|
||||
<% end %>
|
||||
@@ -17,6 +17,10 @@ module ChatwootApp
|
||||
@enterprise ||= root.join('enterprise').exist?
|
||||
end
|
||||
|
||||
def self.chatwoot_cloud?
|
||||
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||
end
|
||||
|
||||
def self.custom?
|
||||
@custom ||= root.join('custom').exist?
|
||||
end
|
||||
|
||||
@@ -41,4 +41,5 @@ module Redis::RedisKeys
|
||||
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
|
||||
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
|
||||
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
end
|
||||
|
||||
@@ -42,7 +42,7 @@ class Webhooks::Trigger
|
||||
end
|
||||
|
||||
def update_message_status(error)
|
||||
message.update!(status: :failed, external_error: error.message)
|
||||
Messages::StatusUpdateService.new(message, 'failed', error.message).perform
|
||||
end
|
||||
|
||||
def message
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||
"@chatwoot/utils": "^0.0.42",
|
||||
"@chatwoot/utils": "^0.0.43",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
|
||||
Generated
+5
-5
@@ -23,8 +23,8 @@ importers:
|
||||
specifier: 1.1.1-next
|
||||
version: 1.1.1-next
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.42
|
||||
version: 0.0.42
|
||||
specifier: ^0.0.43
|
||||
version: 0.0.43
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -406,8 +406,8 @@ packages:
|
||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||
|
||||
'@chatwoot/utils@0.0.42':
|
||||
resolution: {integrity: sha512-TrEywcG1zjgBScVrQla7GMJwXsbLyc5u/verm/LbLrGxizU2NcNoJecRvJOUgL65kYVEtcO9//+gIDswwqnt6g==}
|
||||
'@chatwoot/utils@0.0.43':
|
||||
resolution: {integrity: sha512-kMIXAGebCak9qOi68QnGer+rQLLo/z2N9cR+7tvGdZCW0ThDiVCF7JbHYHVDlYsdDFIx0FLlyIdCfEbooVT2Dw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -5255,7 +5255,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.42':
|
||||
'@chatwoot/utils@0.0.43':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user