Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29c89d5634 | ||
|
|
76bec9a3ff | ||
|
|
e3bacd27d8 | ||
|
|
9c711bab74 | ||
|
|
5ccb131a67 | ||
|
|
cce1b874c1 | ||
|
|
0a394e16ca | ||
|
|
1531772365 | ||
|
|
c24a6dc74c | ||
|
|
aeef091084 | ||
|
|
97895db85e | ||
|
|
ce55e80bb4 | ||
|
|
ea14f99ae1 | ||
|
|
59e0499616 | ||
|
|
de8fdb1377 | ||
|
|
fe20b61bc4 | ||
|
|
49715ebf2b | ||
|
|
0b156f6d4e | ||
|
|
630826baed | ||
|
|
72509f9e38 | ||
|
|
c6d4bc5632 | ||
|
|
5c00880857 | ||
|
|
4da92bbc12 | ||
|
|
ad17887898 | ||
|
|
458bf1803e | ||
|
|
ebef021e94 | ||
|
|
3da26ee025 |
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
|
||||
@contact_inbox.lock!
|
||||
|
||||
# We won't send campaigns if a conversation is already present
|
||||
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
|
||||
raise 'Conversation already present' if @contact_inbox.reload.conversations.present?
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
|
||||
|
||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV',
|
||||
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
|
||||
'INSTALLATION_PRICING_PLAN'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
@@ -65,6 +65,7 @@ class DashboardController < ActionController::Base
|
||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AgentBotsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('agent_bots', { accountScoped: true });
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
deleteAgentBotAvatar(botId) {
|
||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -125,7 +125,10 @@ defineExpose({ open, close });
|
||||
<slot />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div
|
||||
v-if="showCancelButton || showConfirmButton"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAInstagramChannel,
|
||||
isAnInstagramChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
@@ -60,7 +60,7 @@ const isSent = computed(() => {
|
||||
isAFacebookInbox.value ||
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value ||
|
||||
isAInstagramChannel.value
|
||||
isAnInstagramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ const isRead = computed(() => {
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAInstagramChannel.value
|
||||
isAnInstagramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
{{
|
||||
showingOriginal
|
||||
? $t('CONVERSATION.VIEW_TRANSLATED')
|
||||
: $t('CONVERSATION.VIEW_ORIGINAL')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
@@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const renderOriginal = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||
});
|
||||
|
||||
const isOutgoing = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isIncoming = computed(() => !isOutgoing.value);
|
||||
|
||||
const textToShow = computed(() => {
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const originalEmailText = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
// Use TextContent as the default to fullHTML
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original content
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const textToShow = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original text
|
||||
return originalEmailText.value;
|
||||
});
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original HTML
|
||||
return originalEmailHtml.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() => {
|
||||
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||
// This forces Vue to re-render the component and update content correctly.
|
||||
const translationKeySuffix = computed(() => {
|
||||
if (renderOriginal.value) return 'original';
|
||||
if (hasTranslations.value) return 'translated';
|
||||
return 'original';
|
||||
});
|
||||
|
||||
const hasQuotedMessage = computed(() => {
|
||||
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
|
||||
});
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,7 +119,7 @@ const hasQuotedMessage = computed(() => {
|
||||
>
|
||||
<div
|
||||
v-if="isExpandable && !isExpanded"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
|
||||
>
|
||||
<button
|
||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||
@@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
class="text-n-slate-12"
|
||||
:content="content"
|
||||
:content="messageContent"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
:key="`letter-quoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
@@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
:key="`letter-unquoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
@@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="py-2 px-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<section
|
||||
v-if="Array.isArray(attachments) && attachments.length"
|
||||
class="px-4 pb-4 space-y-2"
|
||||
|
||||
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const renderOriginal = ref(false);
|
||||
|
||||
@@ -22,8 +22,7 @@ const renderContent = computed(() => {
|
||||
}
|
||||
|
||||
if (hasTranslations.value) {
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return translationContent.value;
|
||||
}
|
||||
|
||||
return content.value;
|
||||
@@ -37,12 +36,6 @@ const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const viewToggleKey = computed(() => {
|
||||
return renderOriginal.value
|
||||
? 'CONVERSATION.VIEW_TRANSLATED'
|
||||
: 'CONVERSATION.VIEW_ORIGINAL';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
@@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
<span class="-mt-3">
|
||||
<span
|
||||
v-if="hasTranslations"
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
||||
@click="handleSeeOriginal"
|
||||
>
|
||||
{{ $t(viewToggleKey) }}
|
||||
</span>
|
||||
</span>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="-mt-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||
<template v-if="isTemplate">
|
||||
<div
|
||||
|
||||
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
|
||||
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
json: 'text-n-slate-12',
|
||||
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
pdf: 'text-n-ruby-12',
|
||||
pdf: 'text-n-slate-12',
|
||||
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -37,18 +36,6 @@ const toggleShortcutModalFn = show => {
|
||||
}
|
||||
};
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showV4Routes = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.REPORT_V4
|
||||
);
|
||||
});
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// We're using localStorage to store the expanded item in the sidebar
|
||||
@@ -116,32 +103,7 @@ const newReportRoutes = () => [
|
||||
},
|
||||
];
|
||||
|
||||
const oldReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports'),
|
||||
},
|
||||
];
|
||||
|
||||
const reportRoutes = computed(() =>
|
||||
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
|
||||
);
|
||||
const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
|
||||
@@ -127,7 +127,6 @@ const settings = accountId => ({
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
globalConfigFlag: 'csmlEditorHost',
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||
toStateName: 'agent_bots',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
|
||||
@@ -49,13 +49,6 @@ export default {
|
||||
return !!this.menuItem.children;
|
||||
},
|
||||
isMenuItemVisible() {
|
||||
if (this.menuItem.globalConfigFlag) {
|
||||
// this checks for the `csmlEditorHost` flag in the global config
|
||||
// if this is present, we toggle the CSML editor menu item
|
||||
// TODO: This is very specific, and can be handled better, fix it
|
||||
return !!this.globalConfig[this.menuItem.globalConfigFlag];
|
||||
}
|
||||
|
||||
let isFeatureEnabled = true;
|
||||
if (this.menuItem.featureFlag) {
|
||||
isFeatureEnabled = this.isFeatureEnabledonAccount(
|
||||
|
||||
@@ -17,6 +17,9 @@ export default {
|
||||
hasFbConfigured() {
|
||||
return window.chatwootConfig?.fbAppId;
|
||||
},
|
||||
hasInstagramConfigured() {
|
||||
return window.chatwootConfig?.instagramAppId;
|
||||
},
|
||||
isActive() {
|
||||
const { key } = this.channel;
|
||||
if (Object.keys(this.enabledFeatures).length === 0) {
|
||||
@@ -33,7 +36,9 @@ export default {
|
||||
}
|
||||
|
||||
if (key === 'instagram') {
|
||||
return this.enabledFeatures.channel_instagram;
|
||||
return (
|
||||
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -202,7 +202,7 @@ export default {
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
if (this.isAInstagramChannel || this.isInstagramDM) {
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -209,6 +210,22 @@ export default {
|
||||
return contactLastSeenAt;
|
||||
},
|
||||
|
||||
// Check there is a instagram inbox exists with the same instagram_id
|
||||
hasDuplicateInstagramInbox() {
|
||||
const instagramId = this.inbox.instagram_id;
|
||||
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
||||
const instagramInbox =
|
||||
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
||||
instagramId
|
||||
);
|
||||
|
||||
return (
|
||||
this.inbox.channel_type === INBOX_TYPES.FB &&
|
||||
additionalAttributes.type === 'instagram_direct_message' &&
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -232,7 +249,7 @@ export default {
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAFacebookInbox || this.isAInstagramChannel) {
|
||||
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
}
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
@@ -247,7 +264,7 @@ export default {
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAInstagramChannel
|
||||
this.isAnInstagramChannel
|
||||
) {
|
||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
@@ -503,6 +520,12 @@ export default {
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<NextButton
|
||||
faded
|
||||
|
||||
@@ -241,15 +241,27 @@ export default {
|
||||
if (this.isAFacebookInbox) {
|
||||
return MESSAGE_MAX_LENGTH.FACEBOOK;
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (this.isASmsInbox) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAnEmailChannel) {
|
||||
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||
}
|
||||
if (this.isATwilioSMSChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return MESSAGE_MAX_LENGTH.WHATSAPP_CLOUD;
|
||||
}
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
@@ -262,7 +274,7 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAInstagramChannel
|
||||
this.isAnInstagramChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -388,9 +400,14 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
currentChat(conversation, oldConversation) {
|
||||
const { can_reply: canReply } = conversation;
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
if (oldConversation && oldConversation.id !== conversation.id) {
|
||||
// Only update email fields when switching to a completely different conversation (by ID)
|
||||
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||
// like self-assign or other updates that do not actually change the conversation context
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
}
|
||||
|
||||
if (this.isOnPrivateNote) {
|
||||
return;
|
||||
@@ -406,13 +423,12 @@ export default {
|
||||
},
|
||||
// When moving from one conversation to another, the store may not have the
|
||||
// list of all the messages. A fetch is subsequently made to get the messages.
|
||||
// However, this update does not trigger the `currentChat` watcher.
|
||||
// We can add a deep watcher to it, but then, that would be too broad of a net to cast
|
||||
// And would impact performance too. So we watch the messages directly.
|
||||
// The watcher here is `deep` too, because the messages array is mutated and
|
||||
// not replaced. So, a shallow watcher would not catch the change.
|
||||
'currentChat.messages': {
|
||||
handler() {
|
||||
// This watcher handles two main cases:
|
||||
// 1. When switching conversations and messages are fetched/updated, ensures CC/BCC fields are set from the latest OUTGOING/INCOMING email (not activity/private messages).
|
||||
// 2. Fixes and issue where CC/BCC fields could be reset/lost after assignment/activity actions or message mutations that did not represent a true email context change.
|
||||
lastEmail: {
|
||||
handler(lastEmail) {
|
||||
if (!lastEmail) return;
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
},
|
||||
deep: true,
|
||||
@@ -686,7 +702,11 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
if (isOnWhatsApp && !this.isPrivate) {
|
||||
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
|
||||
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
|
||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
@@ -703,7 +723,7 @@ export default {
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message) {
|
||||
const messages = this.getMessagePayloadForWhatsapp(message);
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
messages.forEach(messagePayload => {
|
||||
this.sendMessage(messagePayload);
|
||||
});
|
||||
@@ -935,11 +955,11 @@ export default {
|
||||
|
||||
return payload;
|
||||
},
|
||||
getMessagePayloadForWhatsapp(message) {
|
||||
getMultipleMessagesPayload(message) {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = message;
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -954,9 +974,19 @@ export default {
|
||||
|
||||
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
||||
multipleMessagePayload.push(attachmentPayload);
|
||||
caption = '';
|
||||
// For WhatsApp, only the first attachment gets a caption
|
||||
if (!this.isAnInstagramChannel) caption = '';
|
||||
});
|
||||
} else {
|
||||
}
|
||||
|
||||
const hasNoAttachments =
|
||||
!this.attachedFiles || !this.attachedFiles.length;
|
||||
// For Instagram, we need a separate text message
|
||||
// For WhatsApp, we only need a text message if there are no attachments
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
message,
|
||||
@@ -1136,7 +1166,7 @@ export default {
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="input"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
|
||||
@@ -124,7 +124,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-[32px] invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '../useTranslations';
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns false and null when contentAttributes is null', () => {
|
||||
const contentAttributes = ref(null);
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations are missing', () => {
|
||||
const contentAttributes = ref({});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations is an empty object', () => {
|
||||
const contentAttributes = ref({ translations: {} });
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
});
|
||||
|
||||
it('returns true and correct translation content when translations exist', () => {
|
||||
const contentAttributes = ref({
|
||||
translations: { en: 'Hello' },
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(true);
|
||||
// Should return the first translation (en: 'Hello')
|
||||
expect(translationContent.value).toBe('Hello');
|
||||
});
|
||||
});
|
||||
@@ -121,7 +121,7 @@ export const useInbox = () => {
|
||||
);
|
||||
});
|
||||
|
||||
const isAInstagramChannel = computed(() => {
|
||||
const isAnInstagramChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.INSTAGRAM;
|
||||
});
|
||||
|
||||
@@ -141,6 +141,6 @@ export const useInbox = () => {
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAInstagramChannel,
|
||||
isAnInstagramChannel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable to extract translation state/content from contentAttributes.
|
||||
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
|
||||
* @returns {Object} { hasTranslations, translationContent }
|
||||
*/
|
||||
export function useTranslations(contentAttributes) {
|
||||
const hasTranslations = computed(() => {
|
||||
if (!contentAttributes.value) return false;
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
|
||||
const translationContent = computed(() => {
|
||||
if (!hasTranslations.value) return null;
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
});
|
||||
|
||||
return { hasTranslations, translationContent };
|
||||
}
|
||||
@@ -1,6 +1,44 @@
|
||||
export const buildPortalURL = portalSlug => {
|
||||
const { hostURL, helpCenterURL } = window.chatwootConfig;
|
||||
/**
|
||||
* Formats a custom domain with https protocol if needed
|
||||
* @param {string} customDomain - The custom domain to format
|
||||
* @returns {string} Formatted domain with https protocol
|
||||
*/
|
||||
const formatCustomDomain = customDomain =>
|
||||
customDomain.startsWith('https') ? customDomain : `https://${customDomain}`;
|
||||
|
||||
/**
|
||||
* Gets the default base URL from configuration
|
||||
* @returns {string} The default base URL
|
||||
* @throws {Error} If no valid base URL is found
|
||||
*/
|
||||
const getDefaultBaseURL = () => {
|
||||
const { hostURL, helpCenterURL } = window.chatwootConfig || {};
|
||||
const baseURL = helpCenterURL || hostURL || '';
|
||||
|
||||
if (!baseURL) {
|
||||
throw new Error('No valid base URL found in configuration');
|
||||
}
|
||||
|
||||
return baseURL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the base URL from configuration or custom domain
|
||||
* @param {string} [customDomain] - Optional custom domain for the portal
|
||||
* @returns {string} The base URL for the portal
|
||||
*/
|
||||
const getPortalBaseURL = customDomain =>
|
||||
customDomain ? formatCustomDomain(customDomain) : getDefaultBaseURL();
|
||||
|
||||
/**
|
||||
* Builds a portal URL using the provided portal slug and optional custom domain
|
||||
* @param {string} portalSlug - The slug identifier for the portal
|
||||
* @param {string} [customDomain] - Optional custom domain for the portal
|
||||
* @returns {string} The complete portal URL
|
||||
* @throws {Error} If portalSlug is not provided or invalid
|
||||
*/
|
||||
export const buildPortalURL = (portalSlug, customDomain) => {
|
||||
const baseURL = getPortalBaseURL(customDomain);
|
||||
return `${baseURL}/hc/${portalSlug}`;
|
||||
};
|
||||
|
||||
@@ -8,9 +46,10 @@ export const buildPortalArticleURL = (
|
||||
portalSlug,
|
||||
categorySlug,
|
||||
locale,
|
||||
articleSlug
|
||||
articleSlug,
|
||||
customDomain
|
||||
) => {
|
||||
const portalURL = buildPortalURL(portalSlug);
|
||||
const portalURL = buildPortalURL(portalSlug, customDomain);
|
||||
return `${portalURL}/articles/${articleSlug}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,5 +25,47 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
window.chatwootConfig = {};
|
||||
});
|
||||
|
||||
it('returns the correct url with custom domain', () => {
|
||||
window.chatwootConfig = {
|
||||
hostURL: 'https://app.chatwoot.com',
|
||||
helpCenterURL: 'https://help.chatwoot.com',
|
||||
};
|
||||
expect(
|
||||
buildPortalArticleURL(
|
||||
'handbook',
|
||||
'culture',
|
||||
'fr',
|
||||
'article-slug',
|
||||
'custom-domain.dev'
|
||||
)
|
||||
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
|
||||
});
|
||||
|
||||
it('handles https in custom domain correctly', () => {
|
||||
window.chatwootConfig = {
|
||||
hostURL: 'https://app.chatwoot.com',
|
||||
helpCenterURL: 'https://help.chatwoot.com',
|
||||
};
|
||||
expect(
|
||||
buildPortalArticleURL(
|
||||
'handbook',
|
||||
'culture',
|
||||
'fr',
|
||||
'article-slug',
|
||||
'https://custom-domain.dev'
|
||||
)
|
||||
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
|
||||
});
|
||||
|
||||
it('uses hostURL when helpCenterURL is not available', () => {
|
||||
window.chatwootConfig = {
|
||||
hostURL: 'https://app.chatwoot.com',
|
||||
helpCenterURL: '',
|
||||
};
|
||||
expect(
|
||||
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
"AGENT_BOTS": {
|
||||
"HEADER": "Bots",
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try.You can manage your bots from this page or create new ones using the 'Configure new bot' button.",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"CSML_BOT_EDITOR": {
|
||||
"NAME": {
|
||||
"LABEL": "Bot name",
|
||||
"PLACEHOLDER": "Name your bot.",
|
||||
"ERROR": "Bot name is required."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Bot description",
|
||||
"PLACEHOLDER": "What does this bot do?"
|
||||
},
|
||||
"BOT_CONFIG": {
|
||||
"ERROR": "Please enter your CSML bot configuration above.",
|
||||
"API_ERROR": "Your CSML configuration is invalid. Please fix it and try again."
|
||||
},
|
||||
"SUBMIT": "Validate and save"
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
"AVATAR": {
|
||||
"SUCCESS_DELETE": "Bot avatar deleted successfully",
|
||||
"ERROR_DELETE": "Error deleting bot avatar, please try again"
|
||||
},
|
||||
"BOT_CONFIGURATION": {
|
||||
"TITLE": "Select an agent bot",
|
||||
@@ -32,7 +22,7 @@
|
||||
"SELECT_PLACEHOLDER": "Select bot"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Configure new bot",
|
||||
"TITLE": "Add Bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot added successfully.",
|
||||
@@ -40,16 +30,22 @@
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"404": "No bots found. You can create a bot by clicking the 'Configure new bot' button ↗",
|
||||
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||
"LOADING": "Fetching bots...",
|
||||
"TYPE": "Bot type"
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "Webhook URL"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"TITLE": "Delete bot",
|
||||
"SUBMIT": "Delete",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"DESCRIPTION": "Are you sure you want to delete this bot? This action is irreversible.",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Deletion",
|
||||
"MESSAGE": "Are you sure you want to delete {name}?",
|
||||
"YES": "Yes, Delete",
|
||||
"NO": "No, Keep"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot deleted successfully.",
|
||||
"ERROR_MESSAGE": "Could not delete bot. Please try again."
|
||||
@@ -57,17 +53,44 @@
|
||||
},
|
||||
"EDIT": {
|
||||
"BUTTON_TEXT": "Edit",
|
||||
"LOADING": "Fetching bots...",
|
||||
"TITLE": "Edit bot",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Bot updated successfully.",
|
||||
"ERROR_MESSAGE": "Could not update bot. Please try again."
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Bot avatar"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Bot name",
|
||||
"PLACEHOLDER": "Enter bot name",
|
||||
"REQUIRED": "Bot name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "What does this bot do?"
|
||||
},
|
||||
"WEBHOOK_URL": {
|
||||
"LABEL": "Webhook URL",
|
||||
"PLACEHOLDER": "https://example.com/webhook",
|
||||
"REQUIRED": "Webhook URL is required"
|
||||
},
|
||||
"ERRORS": {
|
||||
"NAME": "Bot name is required",
|
||||
"URL": "Webhook URL is required",
|
||||
"VALID_URL": "Please enter a valid URL starting with http:// or https://"
|
||||
},
|
||||
"CANCEL": "Cancel",
|
||||
"CREATE": "Create Bot",
|
||||
"UPDATE": "Update Bot"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"DESCRIPTION": "Configure a webhook bot to integrate with your custom services. The bot will receive and process events from conversations and can respond to them."
|
||||
},
|
||||
"TYPES": {
|
||||
"WEBHOOK": "Webhook bot",
|
||||
"CSML": "CSML bot"
|
||||
"WEBHOOK": "Webhook bot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
"CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile",
|
||||
"HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again"
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
|
||||
|
||||
@@ -120,6 +120,7 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('portals/index');
|
||||
this.initialize();
|
||||
this.$watch('$store.state.route', () => this.initialize());
|
||||
this.$watch('chatList.length', () => {
|
||||
|
||||
+13
-2
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { mapGetters } from 'vuex';
|
||||
import allLocales from 'shared/constants/locales.js';
|
||||
|
||||
import SearchHeader from './Header.vue';
|
||||
@@ -33,6 +34,15 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
portalBySlug: 'portals/portalBySlug',
|
||||
}),
|
||||
portal() {
|
||||
return this.portalBySlug(this.selectedPortalSlug);
|
||||
},
|
||||
portalCustomDomain() {
|
||||
return this.portal?.custom_domain;
|
||||
},
|
||||
articleViewerUrl() {
|
||||
const article = this.activeArticle(this.activeId);
|
||||
if (!article) return '';
|
||||
@@ -47,6 +57,7 @@ export default {
|
||||
|
||||
return `${url}`;
|
||||
},
|
||||
|
||||
searchResultsWithUrl() {
|
||||
return this.searchResults.map(article => ({
|
||||
...article,
|
||||
@@ -65,7 +76,8 @@ export default {
|
||||
this.selectedPortalSlug,
|
||||
'',
|
||||
'',
|
||||
article.slug
|
||||
article.slug,
|
||||
this.portalCustomDomain
|
||||
);
|
||||
},
|
||||
localeName(code) {
|
||||
@@ -111,7 +123,6 @@ export default {
|
||||
},
|
||||
onInsert(id) {
|
||||
const article = this.activeArticle(id || this.activeId);
|
||||
|
||||
this.$emit('insert', article);
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
|
||||
this.onClose();
|
||||
|
||||
+9
-3
@@ -20,17 +20,23 @@ const articleById = useMapGetter('articles/articleById');
|
||||
|
||||
const article = computed(() => articleById.value(articleSlug));
|
||||
|
||||
const portalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
const portal = computed(() => portalBySlug.value(portalSlug));
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const isSaved = ref(false);
|
||||
|
||||
const portalLink = computed(() => {
|
||||
const articleLink = computed(() => {
|
||||
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
|
||||
const { slug: articleSlugValue } = article.value;
|
||||
const portalCustomDomain = portal.value?.custom_domain;
|
||||
return buildPortalArticleURL(
|
||||
portalSlug,
|
||||
categorySlug,
|
||||
categoryLocale,
|
||||
articleSlugValue
|
||||
articleSlugValue,
|
||||
portalCustomDomain
|
||||
);
|
||||
});
|
||||
|
||||
@@ -91,7 +97,7 @@ const fetchArticleDetails = () => {
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
window.open(articleLink.value, '_blank');
|
||||
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
|
||||
status: article.value?.status,
|
||||
});
|
||||
|
||||
@@ -1,102 +1,191 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import AgentBotRow from './components/AgentBotRow.vue';
|
||||
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import AgentBotModal from './components/AgentBotModal.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const MODAL_TYPES = {
|
||||
CREATE: 'create',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const agentBots = useMapGetter('agentBots/getBots');
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const confirmDialog = ref(null);
|
||||
const selectedBot = ref({});
|
||||
const loading = ref({});
|
||||
const modalType = ref(MODAL_TYPES.CREATE);
|
||||
const agentBotModalRef = ref(null);
|
||||
const agentBotDeleteDialogRef = ref(null);
|
||||
|
||||
const onConfigureNewBot = () => {
|
||||
router.push({
|
||||
name: 'agent_bots_csml_new',
|
||||
});
|
||||
const tableHeaders = computed(() => {
|
||||
return [
|
||||
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
|
||||
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedBotName = computed(() => selectedBot.value?.name || '');
|
||||
|
||||
const openAddModal = () => {
|
||||
modalType.value = MODAL_TYPES.CREATE;
|
||||
selectedBot.value = {};
|
||||
agentBotModalRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const openEditModal = bot => {
|
||||
modalType.value = MODAL_TYPES.EDIT;
|
||||
selectedBot.value = bot;
|
||||
agentBotModalRef.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const openDeletePopup = bot => {
|
||||
selectedBot.value = bot;
|
||||
agentBotDeleteDialogRef.value.open();
|
||||
};
|
||||
|
||||
const deleteAgentBot = async id => {
|
||||
try {
|
||||
await store.dispatch('agentBots/delete', id);
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
loading.value[id] = false;
|
||||
selectedBot.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeletion = () => {
|
||||
loading.value[selectedBot.value.id] = true;
|
||||
deleteAgentBot(selectedBot.value.id);
|
||||
agentBotDeleteDialogRef.value.close();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('agentBots/get');
|
||||
});
|
||||
|
||||
const onDeleteAgentBot = async bot => {
|
||||
const ok = await confirmDialog.value.showConfirmation();
|
||||
|
||||
if (ok) {
|
||||
try {
|
||||
await store.dispatch('agentBots/delete', bot.id);
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEditAgentBot = bot => {
|
||||
router.push(
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/agent-bots/csml/${bot.id}`
|
||||
)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="$t('AGENT_BOTS.LIST.LOADING')"
|
||||
:loading-message="t('AGENT_BOTS.LIST.LOADING')"
|
||||
:no-records-found="!agentBots.length"
|
||||
:no-records-message="$t('AGENT_BOTS.LIST.404')"
|
||||
:no-records-message="t('AGENT_BOTS.LIST.404')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('AGENT_BOTS.HEADER')"
|
||||
:description="$t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="$t('AGENT_BOTS.LEARN_MORE')"
|
||||
:title="t('AGENT_BOTS.HEADER')"
|
||||
:description="t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="t('AGENT_BOTS.LEARN_MORE')"
|
||||
feature-name="agent_bots"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
||||
@click="onConfigureNewBot"
|
||||
@click="openAddModal"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="divide-y divide-slate-75 dark:divide-slate-700">
|
||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||
<AgentBotRow
|
||||
v-for="(agentBot, index) in agentBots"
|
||||
:key="agentBot.id"
|
||||
:agent-bot="agentBot"
|
||||
:index="index"
|
||||
@delete="onDeleteAgentBot"
|
||||
@edit="onEditAgentBot"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
||||
/>
|
||||
</div>
|
||||
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in tableHeaders"
|
||||
:key="thHeader"
|
||||
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
|
||||
<tr v-for="bot in agentBots" :key="bot.id">
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
<div>
|
||||
<span class="block font-medium break-words">
|
||||
{{ bot.name }}
|
||||
<span
|
||||
v-if="bot.system_bot"
|
||||
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ bot.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
|
||||
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openEditModal(bot)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeletePopup(bot)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<AgentBotModal
|
||||
ref="agentBotModalRef"
|
||||
:type="modalType"
|
||||
:selected-bot="selectedBot"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref="agentBotDeleteDialogRef"
|
||||
type="alert"
|
||||
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
|
||||
:description="
|
||||
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
|
||||
"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
|
||||
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
|
||||
@confirm="confirmDeletion"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import Bot from './Index.vue';
|
||||
import CsmlEditBot from './csml/Edit.vue';
|
||||
import CsmlNewBot from './csml/New.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
export default {
|
||||
@@ -26,36 +23,5 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/agent-bots'),
|
||||
component: SettingsContent,
|
||||
props: () => {
|
||||
return {
|
||||
headerTitle: 'AGENT_BOTS.HEADER',
|
||||
icon: 'bot',
|
||||
showBackButton: true,
|
||||
};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'csml/new',
|
||||
name: 'agent_bots_csml_new',
|
||||
component: CsmlNewBot,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'csml/:botId',
|
||||
name: 'agent_bots_csml_edit',
|
||||
component: CsmlEditBot,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import { ref, computed, reactive, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, helpers, url } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
selectedBot: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MODAL_TYPES = {
|
||||
CREATE: 'create',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const formState = reactive({
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
botName: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
|
||||
required
|
||||
),
|
||||
},
|
||||
botUrl: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
|
||||
required
|
||||
),
|
||||
url: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
|
||||
url
|
||||
),
|
||||
},
|
||||
},
|
||||
formState
|
||||
);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? uiFlags.value.isCreating
|
||||
: uiFlags.value.isUpdating
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.ADD.TITLE')
|
||||
: t('AGENT_BOTS.EDIT.TITLE')
|
||||
);
|
||||
|
||||
const confirmButtonLabel = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.FORM.CREATE')
|
||||
: t('AGENT_BOTS.FORM.UPDATE')
|
||||
);
|
||||
|
||||
const botNameError = computed(() =>
|
||||
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const botUrlError = computed(() =>
|
||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleImageUpload = ({ file, url: avatarUrl }) => {
|
||||
formState.botAvatar = file;
|
||||
formState.botAvatarUrl = avatarUrl;
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
if (props.selectedBot?.id) {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'agentBots/deleteAgentBotAvatar',
|
||||
props.selectedBot.id
|
||||
);
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
|
||||
}
|
||||
} else {
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const botData = {
|
||||
name: formState.botName,
|
||||
description: formState.botDescription,
|
||||
outgoing_url: formState.botUrl,
|
||||
bot_type: 'webhook',
|
||||
avatar: formState.botAvatar,
|
||||
};
|
||||
|
||||
const isCreate = props.type === MODAL_TYPES.CREATE;
|
||||
|
||||
try {
|
||||
const actionPayload = isCreate
|
||||
? botData
|
||||
: { id: props.selectedBot.id, data: botData };
|
||||
|
||||
await store.dispatch(
|
||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||
actionPayload
|
||||
);
|
||||
|
||||
const alertKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||
useAlert(alertKey);
|
||||
|
||||
dialogRef.value.close();
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const errorKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(errorKey);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeForm = () => {
|
||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||
const { name, description, outgoing_url, thumbnail, bot_config } =
|
||||
props.selectedBot;
|
||||
formState.botName = name || '';
|
||||
formState.botDescription = description || '';
|
||||
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
||||
formState.botAvatarUrl = thumbnail || '';
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="v$.$reset()"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="formState.botName"
|
||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="botNameError"
|
||||
:message-type="botNameError ? 'error' : 'info'"
|
||||
@blur="v$.botName.$touch()"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
v-model="formState.botDescription"
|
||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="formState.botUrl"
|
||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||
:message="botUrlError"
|
||||
:message-type="botUrlError ? 'error' : 'info'"
|
||||
@blur="v$.botUrl.$touch()"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||
@click="dialogRef.close()"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
data-testid="label-submit"
|
||||
:label="confirmButtonLabel"
|
||||
:is-loading="isLoading"
|
||||
:disabled="v$.$invalid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import AgentBotType from './AgentBotType.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
agentBot: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const isACSMLTypeBot = computed(() => {
|
||||
const { bot_type: botType } = props.agentBot;
|
||||
return botType === 'csml';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="space-x-2">
|
||||
<td class="py-4 ltr:pl-0 ltr:pr-4 rtl:pl-4 rtl:pr-0">
|
||||
<div class="flex items-center break-words font-medium">
|
||||
{{ agentBot.name }}
|
||||
(<AgentBotType :bot-type="agentBot.bot_type" />)
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<ShowMore :text="agentBot.description || ''" :limit="120" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="flex justify-end gap-1 h-full items-center">
|
||||
<Button
|
||||
v-if="isACSMLTypeBot"
|
||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="emit('edit', agentBot)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
@click="emit('delete', agentBot, index)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
botType: {
|
||||
type: String,
|
||||
default: 'webhook',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const botTypeConfig = computed(() => ({
|
||||
csml: {
|
||||
label: t('AGENT_BOTS.TYPES.CSML'),
|
||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
||||
},
|
||||
webhook: {
|
||||
label: t('AGENT_BOTS.TYPES.WEBHOOK'),
|
||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<img
|
||||
v-tooltip="botTypeConfig[botType].label"
|
||||
class="h-3 w-auto"
|
||||
:src="botTypeConfig[botType].thumbnail"
|
||||
:alt="botTypeConfig[botType].label"
|
||||
/>
|
||||
<span>{{ botTypeConfig[botType].label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlMonacoEditor, NextButton },
|
||||
props: {
|
||||
agentBot: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
validations: {
|
||||
bot: {
|
||||
name: { required },
|
||||
csmlContent: { required },
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bot: {
|
||||
name: this.agentBot.name || '',
|
||||
description: this.agentBot.description || '',
|
||||
csmlContent: this.agentBot.bot_config.csml_content || '',
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.$emit('submit', {
|
||||
id: this.agentBot.id || '',
|
||||
...this.bot,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-row">
|
||||
<div class="w-[68%]">
|
||||
<div class="h-[calc(100vh-56px)] relative">
|
||||
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
|
||||
<div
|
||||
v-if="v$.bot.csmlContent.$error"
|
||||
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
|
||||
>
|
||||
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
|
||||
<form
|
||||
class="flex flex-col justify-between h-full"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div>
|
||||
<label :class="{ error: v$.bot.name.$error }">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="bot.name"
|
||||
type="text"
|
||||
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<span v-if="v$.bot.name.$error" class="message">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="bot.description"
|
||||
rows="4"
|
||||
:placeholder="
|
||||
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script>
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: { LoadingState },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
window.onmessage = e => {
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
!e.data.startsWith('chatwoot-csml-editor:update')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
|
||||
this.$emit('update:modelValue', csmlContent);
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onEditorLoad() {
|
||||
const frameElement = document.getElementById(`csml-editor--frame`);
|
||||
const eventData = {
|
||||
event: 'editorContext',
|
||||
data: this.modelValue || '',
|
||||
};
|
||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
||||
this.iframeLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="csml-editor--container">
|
||||
<LoadingState
|
||||
v-if="iframeLoading"
|
||||
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
|
||||
class="dashboard-app_loading-container"
|
||||
/>
|
||||
<iframe
|
||||
id="csml-editor--frame"
|
||||
:src="globalConfig.csmlEditorHost"
|
||||
@load="onEditorLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#csml-editor--frame {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { Spinner, CsmlBotEditor },
|
||||
computed: {
|
||||
agentBot() {
|
||||
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/show', this.$route.params.botId);
|
||||
},
|
||||
methods: {
|
||||
async updateBot(bot) {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/update', {
|
||||
id: bot.id,
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
|
||||
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
|
||||
<Spinner />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL } from '../../../../../helper/URLHelper';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlBotEditor },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async saveBot(bot) {
|
||||
try {
|
||||
const agentBot = await this.$store.dispatch('agentBots/create', {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
if (agentBot) {
|
||||
this.$router.replace(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
|
||||
</template>
|
||||
@@ -35,8 +35,7 @@ export default {
|
||||
},
|
||||
{ key: 'telegram', name: 'Telegram' },
|
||||
{ key: 'line', name: 'Line' },
|
||||
// TODO: Add Instagram to the channel list after the feature is ready to use.
|
||||
// { key: 'instagram', name: 'Instagram' },
|
||||
{ key: 'instagram', name: 'Instagram' },
|
||||
];
|
||||
},
|
||||
...mapGetters({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script>
|
||||
import EmptyState from '../../../../components/widgets/EmptyState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
export default {
|
||||
components: {
|
||||
EmptyState,
|
||||
NextButton,
|
||||
DuplicateInboxBanner,
|
||||
},
|
||||
computed: {
|
||||
currentInbox() {
|
||||
@@ -16,6 +18,20 @@ export default {
|
||||
isATwilioInbox() {
|
||||
return this.currentInbox.channel_type === 'Channel::TwilioSms';
|
||||
},
|
||||
// Check if a facebook inbox exists with the same instagram_id
|
||||
hasDuplicateInstagramInbox() {
|
||||
const instagramId = this.currentInbox.instagram_id;
|
||||
const facebookInbox =
|
||||
this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
|
||||
instagramId
|
||||
);
|
||||
|
||||
return (
|
||||
this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
|
||||
facebookInbox
|
||||
);
|
||||
},
|
||||
|
||||
isAEmailInbox() {
|
||||
return this.currentInbox.channel_type === 'Channel::Email';
|
||||
},
|
||||
@@ -72,8 +88,12 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
||||
class="w-full h-full col-span-6 p-6 overflow-auto border border-b-0 rounded-t-lg border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.NEW_INBOX_SUGGESTION')"
|
||||
/>
|
||||
<EmptyState
|
||||
:title="$t('INBOX_MGMT.FINISH.TITLE')"
|
||||
:message="message"
|
||||
|
||||
@@ -8,6 +8,7 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import FacebookReauthorize from './facebook/Reauthorize.vue';
|
||||
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
|
||||
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
|
||||
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
|
||||
import GoogleReauthorize from './channels/google/Reauthorize.vue';
|
||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
@@ -20,6 +21,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -38,6 +40,7 @@ export default {
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
InstagramReauthorize,
|
||||
DuplicateInboxBanner,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -138,11 +141,7 @@ export default {
|
||||
}
|
||||
|
||||
if (
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.AGENT_BOTS
|
||||
) &&
|
||||
!(this.isAnEmailChannel || this.isATwitterInbox)
|
||||
this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.AGENT_BOTS)
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
@@ -205,7 +204,17 @@ export default {
|
||||
return false;
|
||||
},
|
||||
instagramUnauthorized() {
|
||||
return this.isAInstagramChannel && this.inbox.reauthorization_required;
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
// Check if a instagram inbox exists with the same instagram_id
|
||||
hasDuplicateInstagramInbox() {
|
||||
const instagramId = this.inbox.instagram_id;
|
||||
const instagramInbox =
|
||||
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
||||
instagramId
|
||||
);
|
||||
|
||||
return this.inbox.channel_type === INBOX_TYPES.FB && instagramInbox;
|
||||
},
|
||||
microsoftUnauthorized() {
|
||||
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
|
||||
@@ -393,6 +402,11 @@ export default {
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
|
||||
<DuplicateInboxBanner
|
||||
v-if="hasDuplicateInstagramInbox"
|
||||
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
|
||||
class="mx-8 mt-5"
|
||||
/>
|
||||
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner color="amber">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="i-lucide-info" class="flex-shrink-0 size-4" />
|
||||
<span>{{ content }}</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ export const state = {
|
||||
isCreating: false,
|
||||
isDeleting: false,
|
||||
isUpdating: false,
|
||||
isUpdatingAvatar: false,
|
||||
isFetchingAgentBot: false,
|
||||
isSettingAgentBot: false,
|
||||
isDisconnecting: false,
|
||||
@@ -48,10 +49,23 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
create: async ({ commit }, agentBotObj) => {
|
||||
|
||||
create: async ({ commit }, botData) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
|
||||
try {
|
||||
const response = await AgentBotsAPI.create(agentBotObj);
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append('name', botData.name);
|
||||
formData.append('description', botData.description);
|
||||
formData.append('bot_type', botData.bot_type || 'webhook');
|
||||
formData.append('outgoing_url', botData.outgoing_url);
|
||||
|
||||
// Add avatar file if available
|
||||
if (botData.avatar) {
|
||||
formData.append('avatar', botData.avatar);
|
||||
}
|
||||
|
||||
const response = await AgentBotsAPI.create(formData);
|
||||
commit(types.ADD_AGENT_BOT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -61,10 +75,22 @@ export const actions = {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
update: async ({ commit }, { id, ...agentBotObj }) => {
|
||||
|
||||
update: async ({ commit }, { id, data }) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await AgentBotsAPI.update(id, agentBotObj);
|
||||
// Create FormData for file upload
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
formData.append('description', data.description);
|
||||
formData.append('bot_type', data.bot_type || 'webhook');
|
||||
formData.append('outgoing_url', data.outgoing_url);
|
||||
|
||||
if (data.avatar) {
|
||||
formData.append('avatar', data.avatar);
|
||||
}
|
||||
|
||||
const response = await AgentBotsAPI.update(id, formData);
|
||||
commit(types.EDIT_AGENT_BOT, response.data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
@@ -72,6 +98,7 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
|
||||
try {
|
||||
@@ -83,6 +110,20 @@ export const actions = {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteAgentBotAvatar: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
|
||||
try {
|
||||
await AgentBotsAPI.deleteAgentBotAvatar(id);
|
||||
// Update the thumbnail to empty string after deletion
|
||||
commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, id) => {
|
||||
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
|
||||
try {
|
||||
@@ -150,6 +191,12 @@ export const mutations = {
|
||||
[inboxId]: agentBotId,
|
||||
};
|
||||
},
|
||||
[types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
|
||||
const botIndex = $state.records.findIndex(bot => bot.id === id);
|
||||
if (botIndex !== -1) {
|
||||
$state.records[botIndex].thumbnail = thumbnail || '';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -122,6 +122,20 @@ export const getters = {
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
);
|
||||
},
|
||||
getFacebookInboxByInstagramId: $state => instagramId => {
|
||||
return $state.records.find(
|
||||
item =>
|
||||
item.instagram_id === instagramId &&
|
||||
item.channel_type === INBOX_TYPES.FB
|
||||
);
|
||||
},
|
||||
getInstagramInboxByInstagramId: $state => instagramId => {
|
||||
return $state.records.find(
|
||||
item =>
|
||||
item.instagram_id === instagramId &&
|
||||
item.channel_type === INBOX_TYPES.INSTAGRAM
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const sendAnalyticsEvent = channelType => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { actions } from '../../agentBots';
|
||||
import types from '../../../mutation-types';
|
||||
import { agentBotRecords } from './fixtures';
|
||||
import { agentBotRecords, agentBotData } from './fixtures';
|
||||
|
||||
const commit = vi.fn();
|
||||
global.axios = axios;
|
||||
@@ -30,16 +30,22 @@ describe('#actions', () => {
|
||||
describe('#create', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
await actions.create({ commit }, agentBotRecords[0]);
|
||||
await actions.create({ commit }, agentBotData);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||
[types.ADD_AGENT_BOT, agentBotRecords[0]],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||
]);
|
||||
|
||||
expect(axios.post.mock.calls.length).toBe(1);
|
||||
const formDataArg = axios.post.mock.calls[0][1];
|
||||
expect(formDataArg instanceof FormData).toBe(true);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(actions.create({ commit })).rejects.toThrow(Error);
|
||||
await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
|
||||
@@ -50,17 +56,29 @@ describe('#actions', () => {
|
||||
describe('#update', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
await actions.update({ commit }, agentBotRecords[0]);
|
||||
await actions.update(
|
||||
{ commit },
|
||||
{
|
||||
id: agentBotRecords[0].id,
|
||||
data: agentBotData,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
|
||||
expect(axios.patch.mock.calls.length).toBe(1);
|
||||
const formDataArg = axios.patch.mock.calls[0][1];
|
||||
expect(formDataArg instanceof FormData).toBe(true);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(
|
||||
actions.update({ commit }, agentBotRecords[0])
|
||||
actions.update({ commit }, { id: 1, data: {} })
|
||||
).rejects.toThrow(Error);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
|
||||
@@ -68,7 +86,6 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
export const agentBotRecords = [
|
||||
{
|
||||
account_id: 1,
|
||||
id: 11,
|
||||
name: 'Agent Bot 11',
|
||||
description: 'Agent Bot Description',
|
||||
type: 'csml',
|
||||
bot_type: 'webhook',
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
bot_config: {},
|
||||
outgoing_url: 'https://example.com/outgoing',
|
||||
access_token: 'hN8QwG769RqBXmme',
|
||||
system_bot: false,
|
||||
},
|
||||
|
||||
{
|
||||
account_id: 1,
|
||||
id: 12,
|
||||
name: 'Agent Bot 12',
|
||||
description: 'Agent Bot Description 12',
|
||||
type: 'csml',
|
||||
bot_type: 'webhook',
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
bot_config: {},
|
||||
outgoing_url: 'https://example.com/outgoing',
|
||||
access_token: 'hN8QwG769RqBXmme',
|
||||
system_bot: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const agentBotData = {
|
||||
name: 'Test Bot',
|
||||
description: 'Test Description',
|
||||
outgoing_url: 'https://test.com',
|
||||
bot_type: 'webhook',
|
||||
avatar: new File([''], 'filename'),
|
||||
};
|
||||
|
||||
@@ -51,4 +51,16 @@ describe('#mutations', () => {
|
||||
expect(state.agentBotInbox).toEqual({ 3: 2 });
|
||||
});
|
||||
});
|
||||
describe('#UPDATE_AGENT_BOT_AVATAR', () => {
|
||||
it('update agent bot avatar', () => {
|
||||
const state = { records: [agentBotRecords[0]] };
|
||||
mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
|
||||
id: 11,
|
||||
thumbnail: 'https://example.com/thumbnail.jpg',
|
||||
});
|
||||
expect(state.records[0].thumbnail).toEqual(
|
||||
'https://example.com/thumbnail.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export default [
|
||||
widget_color: null,
|
||||
website_token: null,
|
||||
enable_auto_assignment: true,
|
||||
instagram_id: 123456789,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -62,4 +63,12 @@ export default [
|
||||
channel_type: 'Channel::Sms',
|
||||
provider: 'default',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
channel_id: 7,
|
||||
name: 'Test Instagram 1',
|
||||
channel_type: 'Channel::Instagram',
|
||||
instagram_id: 123456789,
|
||||
provider: 'default',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('#getters', () => {
|
||||
|
||||
it('dialogFlowEnabledInboxes', () => {
|
||||
const state = { records: inboxList };
|
||||
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
|
||||
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
|
||||
});
|
||||
|
||||
it('getInbox', () => {
|
||||
@@ -43,6 +43,7 @@ describe('#getters', () => {
|
||||
widget_color: null,
|
||||
website_token: null,
|
||||
enable_auto_assignment: true,
|
||||
instagram_id: 123456789,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,4 +65,32 @@ describe('#getters', () => {
|
||||
isDeleting: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('getFacebookInboxByInstagramId', () => {
|
||||
const state = { records: inboxList };
|
||||
expect(getters.getFacebookInboxByInstagramId(state)(123456789)).toEqual({
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
name: 'Test FacebookPage 1',
|
||||
channel_type: 'Channel::FacebookPage',
|
||||
avatar_url: 'random_image.png',
|
||||
page_id: '12345',
|
||||
widget_color: null,
|
||||
website_token: null,
|
||||
enable_auto_assignment: true,
|
||||
instagram_id: 123456789,
|
||||
});
|
||||
});
|
||||
|
||||
it('getInstagramInboxByInstagramId', () => {
|
||||
const state = { records: inboxList };
|
||||
expect(getters.getInstagramInboxByInstagramId(state)(123456789)).toEqual({
|
||||
id: 7,
|
||||
channel_id: 7,
|
||||
name: 'Test Instagram 1',
|
||||
channel_type: 'Channel::Instagram',
|
||||
instagram_id: 123456789,
|
||||
provider: 'default',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,7 @@ export default {
|
||||
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
|
||||
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
|
||||
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
|
||||
UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
|
||||
|
||||
// MACROS
|
||||
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
|
||||
|
||||
@@ -135,7 +135,6 @@ export const SDK_CSS = `
|
||||
.woot-widget-bubble svg {
|
||||
all: revert;
|
||||
height: 24px;
|
||||
margin: 20px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export const ALLOWED_FILE_TYPES =
|
||||
'video/*,' +
|
||||
'.3gpp,' +
|
||||
'text/csv, text/plain, application/json, application/pdf, text/rtf,' +
|
||||
'application/xml, text/xml,' +
|
||||
'application/zip, application/x-7z-compressed application/vnd.rar application/x-tar,' +
|
||||
'application/msword, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/vnd.oasis.opendocument.text,' +
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
|
||||
|
||||
@@ -4,8 +4,20 @@ export const isASubmittedFormMessage = (message = {}) =>
|
||||
|
||||
export const MESSAGE_MAX_LENGTH = {
|
||||
GENERAL: 10000,
|
||||
FACEBOOK: 1000,
|
||||
// https://developers.facebook.com/docs/messenger-platform/reference/send-api#request
|
||||
FACEBOOK: 2000,
|
||||
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
|
||||
INSTAGRAM: 1000,
|
||||
// https://www.twilio.com/docs/glossary/what-sms-character-limit
|
||||
TWILIO_SMS: 320,
|
||||
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
|
||||
TWILIO_WHATSAPP: 1600,
|
||||
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-object
|
||||
WHATSAPP_CLOUD: 4096,
|
||||
// https://support.bandwidth.com/hc/en-us/articles/360010235373-What-are-Bandwidth-s-SMS-character-limits-and-concatenation-practices
|
||||
BANDWIDTH_SMS: 160,
|
||||
// https://core.telegram.org/bots/api#sendmessage
|
||||
TELEGRAM: 4096,
|
||||
LINE: 2000,
|
||||
EMAIL: 25000,
|
||||
};
|
||||
|
||||
@@ -121,7 +121,7 @@ export default {
|
||||
this.isATwilioWhatsAppChannel
|
||||
);
|
||||
},
|
||||
isAInstagramChannel() {
|
||||
isAnInstagramChannel() {
|
||||
return this.channelType === INBOX_TYPES.INSTAGRAM;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ const {
|
||||
AZURE_APP_ID: azureAppId,
|
||||
BRAND_NAME: brandName,
|
||||
CHATWOOT_INBOX_TOKEN: chatwootInboxToken,
|
||||
CSML_EDITOR_HOST: csmlEditorHost,
|
||||
CREATE_NEW_ACCOUNT_FROM_DASHBOARD: createNewAccountFromDashboard,
|
||||
DIRECT_UPLOADS_ENABLED: directUploadsEnabled,
|
||||
DISPLAY_MANIFEST: displayManifest,
|
||||
@@ -29,7 +28,6 @@ const state = {
|
||||
azureAppId,
|
||||
brandName,
|
||||
chatwootInboxToken,
|
||||
csmlEditorHost,
|
||||
deploymentEnv,
|
||||
createNewAccountFromDashboard,
|
||||
directUploadsEnabled: directUploadsEnabled === 'true',
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
class AgentBots::CsmlJob < ApplicationJob
|
||||
queue_as :high
|
||||
|
||||
def perform(event, agent_bot, message)
|
||||
event_data = { message: message }
|
||||
Integrations::Csml::ProcessorService.new(
|
||||
event_name: event, agent_bot: agent_bot, event_data: event_data
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -59,14 +59,10 @@ class AgentBotListener < BaseListener
|
||||
true
|
||||
end
|
||||
|
||||
def process_message_event(method_name, agent_bot, message, event)
|
||||
case agent_bot.bot_type
|
||||
when 'webhook'
|
||||
payload = message.webhook_data.merge(event: method_name)
|
||||
process_webhook_bot_event(agent_bot, payload)
|
||||
when 'csml'
|
||||
process_csml_bot_event(event.name, agent_bot, message)
|
||||
end
|
||||
def process_message_event(method_name, agent_bot, message, _event)
|
||||
# Only webhook bots are supported
|
||||
payload = message.webhook_data.merge(event: method_name)
|
||||
process_webhook_bot_event(agent_bot, payload)
|
||||
end
|
||||
|
||||
def process_webhook_bot_event(agent_bot, payload)
|
||||
@@ -74,8 +70,4 @@ class AgentBotListener < BaseListener
|
||||
|
||||
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
|
||||
end
|
||||
|
||||
def process_csml_bot_event(event, agent_bot, message)
|
||||
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
class ConversationReplyMailer < ApplicationMailer
|
||||
# We needs to expose large attachments to the view as links
|
||||
# Small attachments are linked as mail attachments directly
|
||||
attr_reader :large_attachments
|
||||
|
||||
include ConversationReplyMailerHelper
|
||||
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
|
||||
layout :choose_layout
|
||||
|
||||
@@ -3,7 +3,6 @@ module ConversationReplyMailerHelper
|
||||
@options = {
|
||||
to: to_emails,
|
||||
from: email_from,
|
||||
sender: @channel.smtp_login,
|
||||
reply_to: email_reply_to,
|
||||
subject: mail_subject,
|
||||
message_id: custom_message_id,
|
||||
@@ -18,11 +17,44 @@ module ConversationReplyMailerHelper
|
||||
google_smtp_settings
|
||||
set_delivery_method
|
||||
|
||||
Rails.logger.info("Email sent from #{email_from} to #{to_emails} with subject #{mail_subject}")
|
||||
|
||||
# Email type detection logic:
|
||||
# - email_reply: Sets @message with a single message
|
||||
# - Other actions: Set @messages with a collection of messages
|
||||
#
|
||||
# So this check implicitly determines we're handling an email_reply
|
||||
# and not one of the other email types (summary, transcript, etc.)
|
||||
process_attachments_as_files_for_email_reply if @message&.attachments.present?
|
||||
mail(@options)
|
||||
end
|
||||
|
||||
def process_attachments_as_files_for_email_reply
|
||||
# Attachment processing for direct email replies (when replying to a single message)
|
||||
#
|
||||
# How attachments are handled:
|
||||
# 1. Total file size (<20MB): Added directly to the email as proper attachments
|
||||
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
|
||||
|
||||
@options[:attachments] = []
|
||||
@large_attachments = []
|
||||
current_total_size = 0
|
||||
|
||||
@message.attachments.each do |attachment|
|
||||
raw_data = attachment.file.download
|
||||
attachment_name = attachment.file.filename.to_s
|
||||
file_size = raw_data.bytesize
|
||||
|
||||
# Attach files directly until we hit 20MB total
|
||||
# After reaching 20MB, send remaining files as links
|
||||
if current_total_size + file_size <= 20.megabytes
|
||||
mail.attachments[attachment_name] = raw_data
|
||||
@options[:attachments] << { name: attachment_name }
|
||||
current_total_size += file_size
|
||||
else
|
||||
@large_attachments << attachment
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def google_smtp_settings
|
||||
@@ -43,9 +75,24 @@ module ConversationReplyMailerHelper
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def base_smtp_settings(domain)
|
||||
{
|
||||
address: domain,
|
||||
port: 587,
|
||||
user_name: @channel.imap_login,
|
||||
password: @channel.provider_config['access_token'],
|
||||
domain: domain,
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
open_timeout: 15,
|
||||
read_timeout: 15,
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
end
|
||||
|
||||
def set_delivery_method
|
||||
return unless @inbox.email? && @channel.smtp_enabled
|
||||
return if @channel.imap_enabled && (@inbox.channel.microsoft? || @inbox.channel.google?)
|
||||
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
|
||||
smtp_settings = {
|
||||
address: @channel.smtp_address,
|
||||
@@ -63,20 +110,6 @@ module ConversationReplyMailerHelper
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def base_smtp_settings(domain)
|
||||
{
|
||||
address: domain,
|
||||
port: 587,
|
||||
user_name: @channel.imap_login,
|
||||
password: @channel.provider_config['access_token'],
|
||||
domain: domain,
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
end
|
||||
|
||||
def email_smtp_enabled
|
||||
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
end
|
||||
|
||||
@@ -25,9 +25,8 @@ class AgentBot < ApplicationRecord
|
||||
has_many :inboxes, through: :agent_bot_inboxes
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
belongs_to :account, optional: true
|
||||
enum bot_type: { webhook: 0, csml: 1 }
|
||||
enum bot_type: { webhook: 0 }
|
||||
|
||||
validate :validate_agent_bot_config
|
||||
validates :outgoing_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||
|
||||
def available_name
|
||||
@@ -51,9 +50,7 @@ class AgentBot < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_agent_bot_config
|
||||
errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
|
||||
def system_bot?
|
||||
account.nil?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class AgentBots::ValidateBotService
|
||||
pattr_initialize [:agent_bot]
|
||||
def perform
|
||||
return true unless agent_bot.bot_type == 'csml'
|
||||
|
||||
validate_csml_bot
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csml_client
|
||||
@csml_client ||= CsmlEngine.new
|
||||
end
|
||||
|
||||
def csml_bot_payload
|
||||
{
|
||||
id: agent_bot[:name],
|
||||
name: agent_bot[:name],
|
||||
default_flow: 'Default',
|
||||
flows: [
|
||||
{
|
||||
id: SecureRandom.uuid,
|
||||
name: 'Default',
|
||||
content: agent_bot.bot_config['csml_content'],
|
||||
commands: []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def validate_csml_bot
|
||||
response = csml_client.validate(csml_bot_payload)
|
||||
response.blank? || response['valid']
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: agent_bot&.account).capture_exception
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,10 @@
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.description resource.description
|
||||
json.outgoing_url resource.outgoing_url
|
||||
json.thumbnail resource.avatar_url
|
||||
json.outgoing_url resource.outgoing_url unless resource.system_bot?
|
||||
json.bot_type resource.bot_type
|
||||
json.bot_config resource.bot_config
|
||||
json.account_id resource.account_id
|
||||
json.access_token resource.access_token if resource.access_token.present?
|
||||
json.system_bot resource.system_bot?
|
||||
|
||||
@@ -56,6 +56,7 @@ end
|
||||
|
||||
## Instagram Attributes
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
|
||||
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||
|
||||
## Twilio Attributes
|
||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
|
||||
helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>',
|
||||
fbAppId: '<%= @global_config['FB_APP_ID'] %>',
|
||||
instagramAppId: '<%= @global_config['INSTAGRAM_APP_ID'] %>',
|
||||
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
||||
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
||||
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
<% if message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
||||
<% end %>
|
||||
<% if message.attachments %>
|
||||
<% if message.attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
<% message.attachments.each do |attachment| %>
|
||||
Attachment [<a href="<%= attachment.file_url %>" _target="blank">Click here to view</a>]
|
||||
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<p style="font-size: 90%; font-size: 90%;color: #899096;margin-top: -8px; margin-bottom: 0px;">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<% if @message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
||||
<% end %>
|
||||
<% if @message.attachments %>
|
||||
<% @message.attachments.each do |attachment| %>
|
||||
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
<% @large_attachments.each do |attachment| %>
|
||||
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
<% if message.content.present? %>
|
||||
<hr style="border: 0; border-bottom: 1px solid #AEC3D5;"/>
|
||||
<% end %>
|
||||
This message contains <%= message.attachments.count > 1 ? 'attachments' : 'an attachment' %>.
|
||||
<% message.attachments.each do |attachment| %>
|
||||
<br />- View the attachment <a href="<%= attachment.file_url %>" _target="blank">here</a>.
|
||||
Attachments:
|
||||
<% message.attachments.each_with_index do |attachment, index| %>
|
||||
<% if index > 0 %><br /><% end %>
|
||||
<a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
<% if message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(message.content).render_message %>
|
||||
<% end %>
|
||||
<% if message.attachments %>
|
||||
<% if message.attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
<% message.attachments.each do |attachment| %>
|
||||
attachment [<a href="<%= attachment.file_url %>" _target="blank">click here to view</a>]
|
||||
<p><a href="<%= attachment.file_url %>" target="_blank"><%= attachment.file.filename.to_s %></a></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.0.4'
|
||||
version: '4.1.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
+5
-4
@@ -37,7 +37,7 @@
|
||||
help_url: https://chwt.app/hc/help-center
|
||||
- name: agent_bots
|
||||
display_name: Agent Bots
|
||||
enabled: false
|
||||
enabled: true
|
||||
help_url: https://chwt.app/hc/agent-bots
|
||||
- name: macros
|
||||
display_name: Macros
|
||||
@@ -149,7 +149,8 @@
|
||||
enabled: false
|
||||
- name: report_v4
|
||||
display_name: Report V4
|
||||
enabled: false
|
||||
enabled: true
|
||||
deprecated: true
|
||||
- name: contact_chatwoot_support_team
|
||||
display_name: Contact Chatwoot Support Team
|
||||
enabled: true
|
||||
@@ -161,7 +162,7 @@
|
||||
- name: search_with_gin
|
||||
display_name: Search messages with GIN
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: channel_instagram
|
||||
display_name: Instagram Channel
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
enabled: true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class ConvertCsmlBotsToWebhookBots < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Find all CSML bots (bot_type = 1) and convert them to webhook (bot_type = 0)
|
||||
AgentBot.where(bot_type: 1).find_each do |bot|
|
||||
bot.update(bot_type: 0, bot_config: {})
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# This migration is not reversible - we've removed CSML support
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
class FlipChatwootV4DefaultFeatureFlagInstallationConfig < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
# Update the default feature flag config to enable chatwoot_v4
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
if config && config.value.present?
|
||||
features = config.value.map do |f|
|
||||
if f['name'] == 'chatwoot_v4'
|
||||
f.merge('enabled' => true)
|
||||
else
|
||||
f
|
||||
end
|
||||
end
|
||||
config.value = features
|
||||
config.save!
|
||||
end
|
||||
|
||||
# Enable chatwoot_v4 for all accounts in batches of 100
|
||||
Account.find_in_batches(batch_size: 100) do |accounts|
|
||||
accounts.each { |account| account.enable_features!('chatwoot_v4') }
|
||||
end
|
||||
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_02_233933) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
The Chatwoot Enterprise license (the “Enterprise License”)
|
||||
Copyright (c) 2017-2021 Chatwoot Inc
|
||||
Copyright (c) 2017-2025 Chatwoot Inc
|
||||
|
||||
With regard to the Chatwoot Software:
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
class CsmlEngine
|
||||
API_KEY_HEADER = 'X-Api-Key'.freeze
|
||||
|
||||
def initialize
|
||||
@host_url = GlobalConfigService.load('CSML_BOT_HOST', '')
|
||||
@api_key = GlobalConfigService.load('CSML_BOT_API_KEY', '')
|
||||
|
||||
raise ArgumentError, 'Missing Credentials' if @host_url.blank? || @api_key.blank?
|
||||
end
|
||||
|
||||
def status
|
||||
response = HTTParty.get("#{@host_url}/status")
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def run(bot, params)
|
||||
payload = {
|
||||
bot: bot,
|
||||
event: {
|
||||
request_id: SecureRandom.uuid,
|
||||
client: params[:client],
|
||||
payload: params[:payload],
|
||||
metadata: params[:metadata],
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
response = post('run', payload)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
def validate(bot)
|
||||
response = post('validate', bot)
|
||||
process_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
{ error: response.parsed_response, status: response.code }
|
||||
end
|
||||
|
||||
def post(path, payload)
|
||||
HTTParty.post(
|
||||
"#{@host_url}/#{path}", {
|
||||
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
|
||||
body: payload.to_json
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
class Integrations::BotProcessorService
|
||||
# TODO: In CSML processor service, the argument is agent bot, update initializers accordingly.
|
||||
pattr_initialize [:event_name!, :hook!, :event_data!]
|
||||
|
||||
def perform
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
class Integrations::Csml::ProcessorService < Integrations::BotProcessorService
|
||||
pattr_initialize [:event_name!, :event_data!, :agent_bot!]
|
||||
|
||||
private
|
||||
|
||||
def csml_client
|
||||
@csml_client ||= CsmlEngine.new
|
||||
end
|
||||
|
||||
def get_response(session_id, content)
|
||||
csml_client.run(
|
||||
bot_payload,
|
||||
{
|
||||
client: client_params(session_id),
|
||||
payload: message_payload(content),
|
||||
metadata: metadata_params
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def client_params(session_id)
|
||||
{
|
||||
bot_id: "chatwoot-bot-#{conversation.inbox.id}",
|
||||
channel_id: "chatwoot-bot-inbox-#{conversation.inbox.id}",
|
||||
user_id: session_id
|
||||
}
|
||||
end
|
||||
|
||||
def message_payload(content)
|
||||
{
|
||||
content_type: 'text',
|
||||
content: { text: content }
|
||||
}
|
||||
end
|
||||
|
||||
def metadata_params
|
||||
{
|
||||
conversation: conversation,
|
||||
contact: conversation.contact
|
||||
}
|
||||
end
|
||||
|
||||
def bot_payload
|
||||
{
|
||||
id: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
name: "chatwoot-csml-bot-#{agent_bot.id}",
|
||||
default_flow: 'chatwoot_bot_flow',
|
||||
flows: [
|
||||
{
|
||||
id: "chatwoot-csml-bot-flow-#{agent_bot.id}-inbox-#{conversation.inbox.id}",
|
||||
name: 'chatwoot_bot_flow',
|
||||
content: agent_bot.bot_config['csml_content'],
|
||||
commands: []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
csml_messages = response['messages']
|
||||
has_conversation_ended = response['conversation_end']
|
||||
|
||||
process_action(message, 'handoff') if has_conversation_ended.present?
|
||||
|
||||
return if csml_messages.blank?
|
||||
|
||||
# We do not support wait, typing now.
|
||||
csml_messages.each do |csml_message|
|
||||
create_messages(csml_message, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def create_messages(message, conversation)
|
||||
message_payload = message['payload']
|
||||
|
||||
case message_payload['content_type']
|
||||
when 'text'
|
||||
process_text_messages(message_payload, conversation)
|
||||
when 'question'
|
||||
process_question_messages(message_payload, conversation)
|
||||
when 'image'
|
||||
process_image_messages(message_payload, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def process_text_messages(message_payload, conversation)
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['text'],
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def process_question_messages(message_payload, conversation)
|
||||
buttons = message_payload['content']['buttons'].map do |button|
|
||||
{ title: button['content']['title'], value: button['content']['payload'] }
|
||||
end
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: message_payload['content']['title'],
|
||||
content_type: 'input_select',
|
||||
content_attributes: { items: buttons },
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def prepare_attachment(message_payload, message, account_id)
|
||||
attachment_params = { file_type: :image, account_id: account_id }
|
||||
attachment_url = message_payload['content']['url']
|
||||
attachment = message.attachments.new(attachment_params)
|
||||
attachment_file = Down.download(attachment_url)
|
||||
attachment.file.attach(
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def process_image_messages(message_payload, conversation)
|
||||
message = conversation.messages.new(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: '',
|
||||
content_type: 'text',
|
||||
sender: agent_bot
|
||||
}
|
||||
)
|
||||
|
||||
prepare_attachment(message_payload, message, conversation.account_id)
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,19 @@
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
class Integrations::GoogleTranslate::ProcessorService
|
||||
pattr_initialize [:message!, :target_language!]
|
||||
|
||||
def perform
|
||||
return if message.content.blank?
|
||||
return if hook.blank?
|
||||
|
||||
content = translation_content
|
||||
return if content.blank?
|
||||
|
||||
response = client.translate_text(
|
||||
contents: [message.content],
|
||||
contents: [content],
|
||||
target_language_code: target_language,
|
||||
parent: "projects/#{hook.settings['project_id']}"
|
||||
parent: "projects/#{hook.settings['project_id']}",
|
||||
mime_type: mime_type
|
||||
)
|
||||
|
||||
return if response.translations.first.blank?
|
||||
@@ -19,6 +23,43 @@ class Integrations::GoogleTranslate::ProcessorService
|
||||
|
||||
private
|
||||
|
||||
def email_channel?
|
||||
message&.inbox&.email?
|
||||
end
|
||||
|
||||
def email_content
|
||||
@email_content ||= {
|
||||
html: message.content_attributes.dig('email', 'html_content', 'full'),
|
||||
text: message.content_attributes.dig('email', 'text_content', 'full'),
|
||||
content_type: message.content_attributes.dig('email', 'content_type')
|
||||
}
|
||||
end
|
||||
|
||||
def html_content_available?
|
||||
email_content[:html].present?
|
||||
end
|
||||
|
||||
def plain_text_content_available?
|
||||
email_content[:content_type]&.include?('text/plain') &&
|
||||
email_content[:text].present?
|
||||
end
|
||||
|
||||
def translation_content
|
||||
return message.content unless email_channel?
|
||||
return email_content[:html] if html_content_available?
|
||||
return email_content[:text] if plain_text_content_available?
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def mime_type
|
||||
if email_channel? && html_content_available?
|
||||
'text/html'
|
||||
else
|
||||
'text/plain'
|
||||
end
|
||||
end
|
||||
|
||||
def hook
|
||||
@hook ||= message.account.hooks.find_by(app_id: 'google_translate')
|
||||
end
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.0.4",
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -138,7 +138,7 @@
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.17",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
@@ -154,7 +154,7 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite-node": "2.0.1",
|
||||
"vite": "5.4.17",
|
||||
"vite": "5.4.18",
|
||||
"vitest": "3.0.5"
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+124
-124
@@ -6,7 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
vite-node: 2.0.1
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
vitest: 3.0.5
|
||||
|
||||
importers:
|
||||
@@ -72,7 +72,7 @@ importers:
|
||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
version: 5.1.4(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
'@vue/compiler-sfc':
|
||||
specifier: ^3.5.8
|
||||
version: 3.5.8
|
||||
@@ -238,7 +238,7 @@ importers:
|
||||
version: 1.8.1(tailwindcss@3.4.13)
|
||||
'@histoire/plugin-vue':
|
||||
specifier: 0.17.15
|
||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
'@iconify-json/logos':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
@@ -301,7 +301,7 @@ importers:
|
||||
version: 6.0.0
|
||||
histoire:
|
||||
specifier: 0.17.15
|
||||
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
version: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
husky:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.4
|
||||
@@ -330,11 +330,11 @@ importers:
|
||||
specifier: ^3.4.13
|
||||
version: 3.4.13
|
||||
vite:
|
||||
specifier: 5.4.17
|
||||
version: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
specifier: 5.4.18
|
||||
version: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite-plugin-ruby:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
version: 5.0.0(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
vitest:
|
||||
specifier: 3.0.5
|
||||
version: 3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0)
|
||||
@@ -860,7 +860,7 @@ packages:
|
||||
'@histoire/shared@0.17.17':
|
||||
resolution: {integrity: sha512-ueGtURysonT0MujCObPCR57+mgZluMEXCrbc2FBgKAD/DoAt38tNwSGsmLldk2O6nTr7lr6ClbVSgWrLwgY6Xw==}
|
||||
peerDependencies:
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
|
||||
'@histoire/vendors@0.17.17':
|
||||
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
|
||||
@@ -1039,103 +1039,103 @@ packages:
|
||||
'@rails/ujs@7.1.400':
|
||||
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -1800,7 +1800,7 @@ packages:
|
||||
resolution: {integrity: sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
peerDependencies:
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/coverage-v8@3.0.5':
|
||||
@@ -1819,7 +1819,7 @@ packages:
|
||||
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
@@ -3100,7 +3100,7 @@ packages:
|
||||
resolution: {integrity: sha512-DiRMSIgj340z+zikqf0f3Pj0CTv2/xtdBMBIAO1EARat+QXxMwumbfK41Gi7f9IIBr+UVmomNcwFxVY2EM/vrw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
|
||||
hotkeys-js@3.8.7:
|
||||
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
|
||||
@@ -4344,8 +4344,8 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rollup@4.39.0:
|
||||
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
|
||||
rollup@4.40.0:
|
||||
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4863,10 +4863,10 @@ packages:
|
||||
vite-plugin-ruby@5.0.0:
|
||||
resolution: {integrity: sha512-c8PjTp21Ah/ttgnNUyu0qvCXZI08Jr9I24oUKg3TRIRhF5GcOZ++6wtlTCrNFd9COEQbpXHxlRIXd/MEg0iZJw==}
|
||||
peerDependencies:
|
||||
vite: 5.4.17
|
||||
vite: 5.4.18
|
||||
|
||||
vite@5.4.17:
|
||||
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
|
||||
vite@5.4.18:
|
||||
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5672,10 +5672,10 @@ snapshots:
|
||||
highlight.js: 11.10.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
'@histoire/app@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
'@histoire/app@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
dependencies:
|
||||
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/vendors': 0.17.17
|
||||
'@types/flexsearch': 0.7.6
|
||||
flexsearch: 0.7.21
|
||||
@@ -5683,7 +5683,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@histoire/controls@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
'@histoire/controls@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.7.0
|
||||
'@codemirror/lang-json': 6.0.1
|
||||
@@ -5692,26 +5692,26 @@ snapshots:
|
||||
'@codemirror/state': 6.4.1
|
||||
'@codemirror/theme-one-dark': 6.1.2
|
||||
'@codemirror/view': 6.34.1
|
||||
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/vendors': 0.17.17
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||
'@histoire/plugin-vue@0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/vendors': 0.17.17
|
||||
change-case: 4.1.2
|
||||
globby: 13.2.2
|
||||
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
histoire: 0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
launch-editor: 2.9.1
|
||||
pathe: 1.1.2
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
'@histoire/shared@0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
'@histoire/shared@0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
dependencies:
|
||||
'@histoire/vendors': 0.17.17
|
||||
'@types/fs-extra': 9.0.13
|
||||
@@ -5719,7 +5719,7 @@ snapshots:
|
||||
chokidar: 3.6.0
|
||||
pathe: 1.1.2
|
||||
picocolors: 1.1.0
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
|
||||
'@histoire/vendors@0.17.17': {}
|
||||
|
||||
@@ -5944,64 +5944,64 @@ snapshots:
|
||||
|
||||
'@rails/ujs@7.1.400': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.39.0':
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.39.0':
|
||||
'@rollup/rollup-android-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.39.0':
|
||||
'@rollup/rollup-darwin-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.39.0':
|
||||
'@rollup/rollup-darwin-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.39.0':
|
||||
'@rollup/rollup-freebsd-arm64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.39.0':
|
||||
'@rollup/rollup-freebsd-x64@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.39.0':
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.39.0':
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.39.0':
|
||||
'@rollup/rollup-win32-x64-msvc@4.40.0':
|
||||
optional: true
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
@@ -6802,9 +6802,9 @@ snapshots:
|
||||
global: 4.4.0
|
||||
is-function: 1.0.2
|
||||
|
||||
'@vitejs/plugin-vue@5.1.4(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||
'@vitejs/plugin-vue@5.1.4(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
'@vitest/coverage-v8@3.0.5(vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0))':
|
||||
@@ -6832,13 +6832,13 @@ snapshots:
|
||||
chai: 5.1.2
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
'@vitest/mocker@3.0.5(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.0.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
optionalDependencies:
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
|
||||
'@vitest/pretty-format@3.0.5':
|
||||
dependencies:
|
||||
@@ -8393,12 +8393,12 @@ snapshots:
|
||||
|
||||
highlight.js@11.10.0: {}
|
||||
|
||||
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
dependencies:
|
||||
'@akryum/tinypool': 0.3.1
|
||||
'@histoire/app': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/controls': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/app': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/controls': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/shared': 0.17.17(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@histoire/vendors': 0.17.17
|
||||
'@types/flexsearch': 0.7.6
|
||||
'@types/markdown-it': 12.2.3
|
||||
@@ -8425,7 +8425,7 @@ snapshots:
|
||||
sade: 1.8.1
|
||||
shiki-es: 0.2.0
|
||||
sirv: 2.0.4
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
@@ -9766,30 +9766,30 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
rollup@4.39.0:
|
||||
rollup@4.40.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.7
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.39.0
|
||||
'@rollup/rollup-android-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-arm64': 4.39.0
|
||||
'@rollup/rollup-darwin-x64': 4.39.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.39.0
|
||||
'@rollup/rollup-freebsd-x64': 4.39.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.39.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.39.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.39.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.39.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.39.0
|
||||
'@rollup/rollup-android-arm-eabi': 4.40.0
|
||||
'@rollup/rollup-android-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-arm64': 4.40.0
|
||||
'@rollup/rollup-darwin-x64': 4.40.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.40.0
|
||||
'@rollup/rollup-freebsd-x64': 4.40.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.40.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.40.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.40.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.40.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.40.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.2: {}
|
||||
@@ -10378,7 +10378,7 @@ snapshots:
|
||||
debug: 4.4.0
|
||||
pathe: 1.1.2
|
||||
picocolors: 1.1.1
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
@@ -10390,19 +10390,19 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-plugin-ruby@5.0.0(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
vite-plugin-ruby@5.0.0(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)):
|
||||
dependencies:
|
||||
debug: 4.3.5
|
||||
fast-glob: 3.3.2
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||
vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
rollup: 4.39.0
|
||||
rollup: 4.40.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.7.0
|
||||
fsevents: 2.3.3
|
||||
@@ -10412,7 +10412,7 @@ snapshots:
|
||||
vitest@3.0.5(@types/node@22.7.0)(jsdom@24.1.3)(sass@1.79.3)(terser@5.33.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 3.0.5
|
||||
'@vitest/mocker': 3.0.5(vite@5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@vitest/mocker': 3.0.5(vite@5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))
|
||||
'@vitest/pretty-format': 3.0.5
|
||||
'@vitest/runner': 3.0.5
|
||||
'@vitest/snapshot': 3.0.5
|
||||
@@ -10428,7 +10428,7 @@ snapshots:
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 5.4.17(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite: 5.4.18(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
vite-node: 2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
|
||||
Binary file not shown.
@@ -28,6 +28,31 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'properly differentiates between system bots and account bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
response_data = response.parsed_body
|
||||
# Find the global bot in the response
|
||||
global_bot_response = response_data.find { |bot| bot['id'] == global_bot.id }
|
||||
# Find the account bot in the response
|
||||
account_bot_response = response_data.find { |bot| bot['id'] == agent_bot.id }
|
||||
|
||||
# Verify system_bot attribute and outgoing_url for global bot
|
||||
expect(global_bot_response['system_bot']).to be(true)
|
||||
expect(global_bot_response).not_to include('outgoing_url')
|
||||
|
||||
# Verify account bot has system_bot attribute false and includes outgoing_url
|
||||
expect(account_bot_response['system_bot']).to be(false)
|
||||
expect(account_bot_response).to include('outgoing_url')
|
||||
|
||||
# Verify both bots have thumbnail field
|
||||
expect(global_bot_response).to include('thumbnail')
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +85,10 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
|
||||
# Test for system_bot attribute and webhook URL not being exposed
|
||||
expect(response.parsed_body['system_bot']).to be(true)
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -142,7 +171,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'updates avatar' do
|
||||
it 'updates avatar and includes thumbnail in response' do
|
||||
# no avatar before upload
|
||||
expect(agent_bot.avatar.attached?).to be(false)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
@@ -153,6 +182,9 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
agent_bot.reload
|
||||
expect(agent_bot.avatar.attached?).to be(true)
|
||||
|
||||
# Verify thumbnail is included in the response
|
||||
expect(response.parsed_body).to include('thumbnail')
|
||||
end
|
||||
|
||||
it 'updated avatar with avatar_url' do
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentBots::CsmlJob do
|
||||
it 'runs csml processor service' do
|
||||
event = 'message.created'
|
||||
message = create(:message)
|
||||
agent_bot = create(:agent_bot)
|
||||
processor = double
|
||||
|
||||
allow(Integrations::Csml::ProcessorService).to receive(:new).and_return(processor)
|
||||
allow(processor).to receive(:perform)
|
||||
|
||||
described_class.perform_now(event, agent_bot, message)
|
||||
|
||||
expect(Integrations::Csml::ProcessorService)
|
||||
.to have_received(:new)
|
||||
.with(event_name: event, agent_bot: agent_bot, event_data: { message: message })
|
||||
end
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CsmlEngine do
|
||||
it 'raises an exception if host and api is absent' do
|
||||
expect { described_class.new }.to raise_error(StandardError)
|
||||
end
|
||||
|
||||
context 'when CSML_BOT_HOST & CSML_BOT_API_KEY is present' do
|
||||
before do
|
||||
create(:installation_config, { name: 'CSML_BOT_HOST', value: 'https://csml.chatwoot.dev' })
|
||||
create(:installation_config, { name: 'CSML_BOT_API_KEY', value: 'random_api_key' })
|
||||
end
|
||||
|
||||
let(:csml_request) { double }
|
||||
|
||||
context 'when status is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'engine_version': '1.11.1' })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'engine_version': '1.11.1' })
|
||||
end
|
||||
|
||||
it 'returns error if client response is invalid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(false)
|
||||
allow(csml_request).to receive(:code).and_return(401)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'error': true })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(response).to eq({ error: { 'error': true }, status: 401 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when run is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
response = described_class.new.run({ flow: 'default' }, { client: 'client', payload: { id: 1 }, metadata: {} })
|
||||
|
||||
payload = {
|
||||
bot: { flow: 'default' },
|
||||
event: {
|
||||
request_id: 'xxxx-yyyy-wwww-cccc',
|
||||
client: 'client',
|
||||
payload: { id: 1 },
|
||||
metadata: {},
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/run', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when validate is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
payload = { flow: 'default' }
|
||||
response = described_class.new.validate(payload)
|
||||
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/validate', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,108 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Csml::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot, :skip_validate, bot_type: 'csml', account: account) }
|
||||
let(:agent_bot_inbox) { create(:agent_bot_inbox, agent_bot: agent_bot, inbox: inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:csml_client) { double }
|
||||
let(:processor) { described_class.new(event_name: event_name, agent_bot: agent_bot, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
end
|
||||
|
||||
context 'when a conversation is completed from CSML' do
|
||||
it 'open the conversation and handsoff it to an agent' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(conversation_end: true)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new message is returned from CSML' do
|
||||
it 'creates a text message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [
|
||||
{ payload: { content_type: 'text', content: { text: 'hello payload' } } }
|
||||
]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
|
||||
it 'creates a question message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [{
|
||||
payload: {
|
||||
content_type: 'question',
|
||||
content: { title: 'Question Payload', buttons: [{ content: { title: 'Q1', payload: 'q1' } }] }
|
||||
}
|
||||
}]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('Question Payload')
|
||||
expect(conversation.messages.last.content_type).to eql('input_select')
|
||||
expect(conversation.messages.last.content_attributes).to eql({ items: [{ title: 'Q1', value: 'q1' }] }.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation status is not pending' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is private' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message type is template (not outgoing or incoming)' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :template) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message updated' do
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
context 'when content_type is input_select' do
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, private: true,
|
||||
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
||||
end
|
||||
|
||||
it 'returns submitted value for message content' do
|
||||
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when content_type is not input_select' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content_type: :text) }
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,15 +37,6 @@ describe AgentBotListener do
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot csml type is configured' do
|
||||
it 'sends message to agent bot' do
|
||||
agent_bot_csml = create(:agent_bot, :skip_validate, bot_type: 'csml')
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot_csml)
|
||||
expect(AgentBots::CsmlJob).to receive(:perform_later).with('message.created', agent_bot_csml, message).once
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webwidget_triggered' do
|
||||
|
||||
@@ -153,6 +153,74 @@ RSpec.describe ConversationReplyMailer do
|
||||
it 'updates the source_id' do
|
||||
expect(mail.message_id).to eq message.source_id
|
||||
end
|
||||
|
||||
context 'with email attachments' do
|
||||
it 'includes small attachments as email attachments' do
|
||||
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
|
||||
content: 'Message with small attachment')
|
||||
attachment = message_with_attachment.attachments.new(account_id: account.id, file_type: :file)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.save!
|
||||
|
||||
mail = described_class.email_reply(message_with_attachment).deliver_now
|
||||
|
||||
# Should be attached to the email
|
||||
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
|
||||
# Should not be in large_attachments
|
||||
expect(mail.body.encoded).not_to include('Attachments:')
|
||||
end
|
||||
|
||||
it 'renders large attachments as links in the email body' do
|
||||
message_with_large_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
|
||||
content: 'Message with large attachment')
|
||||
attachment = message_with_large_attachment.attachments.new(account_id: account.id, file_type: :file)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
mail = described_class.email_reply(message_with_large_attachment).deliver_now
|
||||
|
||||
# Should NOT be attached to the email
|
||||
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
|
||||
# Should be rendered as a link in the body
|
||||
expect(mail.body.encoded).to include('Attachments:')
|
||||
expect(mail.body.encoded).to include('large_file.pdf')
|
||||
# Should render a link with large_file.pdf as the link text
|
||||
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
|
||||
# Small file should not be rendered as a link in the body
|
||||
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
|
||||
end
|
||||
|
||||
it 'handles both small and large attachments correctly' do
|
||||
message_with_mixed_attachments = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
|
||||
content: 'Message with mixed attachments')
|
||||
|
||||
# Small attachment
|
||||
small_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
|
||||
small_attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
small_attachment.save!
|
||||
|
||||
# Large attachment
|
||||
large_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
|
||||
large_attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf',
|
||||
content_type: 'application/pdf')
|
||||
large_attachment.save!
|
||||
|
||||
mail = described_class.email_reply(message_with_mixed_attachments).deliver_now
|
||||
|
||||
# Small file should be attached
|
||||
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
|
||||
# Large file should NOT be attached
|
||||
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
|
||||
|
||||
# Large file should be rendered as a link in the body
|
||||
expect(mail.body.encoded).to include('Attachments:')
|
||||
expect(mail.body.encoded).to include('large_file.pdf')
|
||||
# Should render a link with large_file.pdf as the link text
|
||||
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
|
||||
# Small file should not be rendered as a link in the body
|
||||
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when smtp enabled for email channel' do
|
||||
|
||||
@@ -39,4 +39,23 @@ RSpec.describe AgentBot do
|
||||
expect(message.reload.sender).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#system_bot?' do
|
||||
context 'when account_id is nil' do
|
||||
let(:agent_bot) { create(:agent_bot, account_id: nil) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(agent_bot.system_bot?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account_id is present' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(agent_bot.system_bot?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe AgentBots::ValidateBotService do
|
||||
describe '#perform' do
|
||||
it 'returns true if bot_type is not csml' do
|
||||
agent_bot = create(:agent_bot)
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
end
|
||||
|
||||
it 'returns true if validate csml returns true' do
|
||||
agent_bot = create(:agent_bot, :skip_validate, bot_type: 'csml', bot_config: {})
|
||||
csml_client = double
|
||||
csml_response = double
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
allow(csml_client).to receive(:validate).and_return(csml_response)
|
||||
allow(csml_response).to receive(:blank?).and_return(false)
|
||||
allow(csml_response).to receive(:[]).with('valid').and_return(true)
|
||||
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
expect(CsmlEngine).to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user