Merge branch 'develop' into perf/conversation_count
This commit is contained in:
@@ -192,7 +192,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents'
|
||||
gem 'ai-agents', '>= 0.9.1'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.9.0)
|
||||
ai-agents (0.9.1)
|
||||
ruby_llm (~> 1.9.1)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
@@ -1027,7 +1027,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents
|
||||
ai-agents (>= 0.9.1)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
before_action :validate_captain_enabled, only: [:analyze]
|
||||
|
||||
def show
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def analyze
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
result = CsatTemplateUtilityAnalysisService.new(
|
||||
account: Current.account,
|
||||
inbox: @inbox,
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text],
|
||||
language: template_params[:language]
|
||||
).perform
|
||||
|
||||
render json: result
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_captain_enabled
|
||||
return if Current.account.feature_enabled?('captain_integration')
|
||||
|
||||
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
|
||||
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
|
||||
end
|
||||
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.open_timeout = 10
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
smtp.finish
|
||||
rescue Net::SMTPAuthenticationError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
|
||||
rescue OpenSSL::SSL::SSLError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
|
||||
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
|
||||
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
|
||||
rescue StandardError => e
|
||||
raise StandardError, e.message
|
||||
end
|
||||
|
||||
def set_smtp_encryption(channel_data, smtp)
|
||||
if channel_data[:smtp_enable_ssl_tls]
|
||||
set_enable_tls(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
|
||||
elsif channel_data[:smtp_enable_starttls_auto]
|
||||
set_enable_starttls_auto(channel_data, smtp)
|
||||
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_starttls_auto(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_starttls_auto)
|
||||
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
|
||||
return unless smtp.respond_to?(method)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_starttls_auto(context)
|
||||
else
|
||||
smtp.enable_starttls_auto
|
||||
end
|
||||
end
|
||||
|
||||
def set_enable_tls(channel_data, smtp)
|
||||
return unless smtp.respond_to?(:enable_tls)
|
||||
|
||||
if channel_data[:smtp_openssl_verify_mode]
|
||||
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
|
||||
smtp.enable_tls(context)
|
||||
else
|
||||
smtp.enable_tls
|
||||
end
|
||||
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
|
||||
context ? smtp.send(method, context) : smtp.send(method)
|
||||
end
|
||||
|
||||
def enable_openssl_mode(smtp_openssl_verify_mode)
|
||||
|
||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TELEGRAM: 'i-ri-telegram-fill',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
@@ -66,6 +67,7 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
telegram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode = '',
|
||||
country = '',
|
||||
city = '',
|
||||
socialTelegramUserName = '',
|
||||
socialProfiles = {},
|
||||
} = additionalAttributes || {};
|
||||
|
||||
const telegramUsername =
|
||||
socialProfiles?.telegram || socialTelegramUserName || '';
|
||||
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles,
|
||||
socialProfiles: {
|
||||
...socialProfiles,
|
||||
telegram: telegramUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
import RadioCard from './RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -46,7 +43,6 @@ import {
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -91,9 +85,9 @@ const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -161,12 +155,6 @@ const {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
@@ -384,18 +372,6 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
@@ -607,16 +583,13 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
@@ -822,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -977,61 +948,42 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
:overscan="10"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<ConversationItem
|
||||
:key="item.id"
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
if (this.isALineChannel || this.isATiktokChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
|
||||
@@ -279,7 +279,8 @@ export default {
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAnInstagramChannel
|
||||
this.isAnInstagramChannel ||
|
||||
this.isATiktokChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -751,11 +752,13 @@ export default {
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
// 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.
|
||||
// Instagram and TikTok do not support sending text and attachments in the same message.
|
||||
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
|
||||
// For TikTok, the API rejects messages that mix text and media.
|
||||
// To handle both cases, text and attachments are always sent as separate messages.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
const isOnTiktok = this.isATiktokChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
@@ -1069,7 +1072,8 @@ export default {
|
||||
const multipleMessagePayload = [];
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
let caption = this.isAnInstagramChannel ? '' : message;
|
||||
let caption =
|
||||
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
const attachedFile = this.globalConfig.directUploadsEnabled
|
||||
? attachment.blobSignedId
|
||||
@@ -1091,11 +1095,13 @@ export default {
|
||||
|
||||
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
|
||||
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
|
||||
// For WhatsApp, we only need a text message if there are no attachments.
|
||||
if (
|
||||
(this.isAnInstagramChannel && this.message) ||
|
||||
(!this.isAnInstagramChannel && hasNoAttachments)
|
||||
((this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
this.message) ||
|
||||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
|
||||
hasNoAttachments)
|
||||
) {
|
||||
let messagePayload = {
|
||||
conversationId: this.currentChat.id,
|
||||
|
||||
@@ -458,6 +458,9 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
},
|
||||
|
||||
@@ -592,8 +592,10 @@
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
@@ -713,8 +715,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||
@@ -890,6 +892,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -901,7 +917,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -58,12 +58,14 @@ export default {
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
@@ -175,12 +177,14 @@ export default {
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
|
||||
@@ -81,10 +81,14 @@ export default {
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
|
||||
return {
|
||||
twitter: twitterScreenName,
|
||||
telegram: telegramUsername,
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
|
||||
@@ -27,6 +27,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -53,6 +54,7 @@ export default {
|
||||
SettingsAccordion,
|
||||
WeeklyAvailability,
|
||||
SenderNameExamplePreview,
|
||||
LockToSingleConversationPreview,
|
||||
MicrosoftReauthorize,
|
||||
GoogleReauthorize,
|
||||
NextButton,
|
||||
@@ -246,6 +248,9 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAPIInbox ||
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -536,6 +541,9 @@ export default {
|
||||
hideBusinessNameInput() {
|
||||
this.showBusinessNameInput = false;
|
||||
},
|
||||
toggleLockToSingleConversation(value) {
|
||||
this.locktoSingleConversation = value;
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
webhookUrl: {
|
||||
@@ -731,6 +739,21 @@ export default {
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
class="[&>div>div]:justify-end [&>div>div]:flex lg:[&>div:first-child]:h-12 [&>div:first-child]:h-16"
|
||||
>
|
||||
<template #extra>
|
||||
<LockToSingleConversationPreview
|
||||
:lock-to-single-conversation="locktoSingleConversation"
|
||||
@update="toggleLockToSingleConversation"
|
||||
/>
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="isAWebWidgetInbox || isAnEmailChannel"
|
||||
:label="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
|
||||
@@ -1074,19 +1097,6 @@ export default {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="canLocktoSingleConversation"
|
||||
v-model="locktoSingleConversation"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
|
||||
@@ -147,7 +147,9 @@ export default {
|
||||
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
defineProps({
|
||||
lockToSingleConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
>
|
||||
<RadioCard
|
||||
id="disabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="!lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', false)"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
id="enabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
|
||||
+151
-1
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
@@ -26,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -46,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
|
||||
@@ -360,6 +360,13 @@ export const actions = {
|
||||
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
|
||||
return response.data;
|
||||
},
|
||||
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
|
||||
const response = await InboxesAPI.analyzeCSATTemplateUtility(
|
||||
inboxId,
|
||||
template
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module ActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include AssigneeActivityMessageHandler
|
||||
include PriorityActivityMessageHandler
|
||||
include LabelActivityMessageHandler
|
||||
include SlaActivityMessageHandler
|
||||
@@ -104,27 +105,6 @@ module ActivityMessageHandler
|
||||
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
|
||||
user_name
|
||||
end
|
||||
end
|
||||
|
||||
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
module AssigneeActivityMessageHandler
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def create_assignee_change_activity(user_name)
|
||||
user_name = activity_message_owner(user_name)
|
||||
|
||||
return unless user_name
|
||||
|
||||
content = generate_assignee_change_activity_content(user_name)
|
||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||
end
|
||||
|
||||
def generate_assignee_change_activity_content(user_name)
|
||||
params = { assignee_name: assignee&.name || '', user_name: user_name }
|
||||
key = assignee_id ? 'assigned' : 'removed'
|
||||
key = 'self_assigned' if self_assign? assignee_id
|
||||
I18n.t("conversations.activity.assignee.#{key}", **params)
|
||||
end
|
||||
|
||||
def activity_message_owner(user_name)
|
||||
if !user_name && Current.executed_by.present?
|
||||
user_name = case Current.executed_by
|
||||
when AssignmentPolicy
|
||||
I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name)
|
||||
when Inbox
|
||||
I18n.t('auto_assignment.default_policy_name')
|
||||
else
|
||||
I18n.t('automation.system_name')
|
||||
end
|
||||
end
|
||||
user_name
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ module MessageFilterHelpers
|
||||
end
|
||||
|
||||
def notifiable?
|
||||
incoming? || outgoing?
|
||||
(incoming? || outgoing?) && !private?
|
||||
end
|
||||
|
||||
def conversation_transcriptable?
|
||||
|
||||
@@ -72,13 +72,17 @@ class AutoAssignment::AssignmentService
|
||||
end
|
||||
|
||||
def assign_conversation(conversation, agent)
|
||||
Current.executed_by = inbox.assignment_policy || inbox
|
||||
conversation.update!(assignee: agent)
|
||||
Current.executed_by = nil
|
||||
|
||||
rate_limiter = build_rate_limiter(agent)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
|
||||
dispatch_assignment_event(conversation, agent)
|
||||
true
|
||||
ensure
|
||||
Current.executed_by = nil
|
||||
end
|
||||
|
||||
def dispatch_assignment_event(conversation, agent)
|
||||
|
||||
@@ -2,8 +2,6 @@ class AutoAssignment::RateLimiter
|
||||
pattr_initialize [:inbox!, :agent!]
|
||||
|
||||
def within_limit?
|
||||
return true unless enabled?
|
||||
|
||||
current_count < limit
|
||||
end
|
||||
|
||||
@@ -13,24 +11,18 @@ class AutoAssignment::RateLimiter
|
||||
end
|
||||
|
||||
def current_count
|
||||
return 0 unless enabled?
|
||||
|
||||
pattern = assignment_key_pattern
|
||||
Redis::Alfred.keys_count(pattern)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enabled?
|
||||
config.present? && limit.positive?
|
||||
end
|
||||
|
||||
def limit
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY
|
||||
config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : 5
|
||||
end
|
||||
|
||||
def window
|
||||
config&.fair_distribution_window&.to_i || 24.hours.to_i
|
||||
config&.fair_distribution_window&.to_i || 5.minutes.to_i
|
||||
end
|
||||
|
||||
def config
|
||||
|
||||
@@ -110,6 +110,7 @@ class CsatTemplateManagementService
|
||||
|
||||
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
|
||||
status_result = template_service.get_template_status(content_sid)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
@@ -130,6 +131,7 @@ class CsatTemplateManagementService
|
||||
def get_whatsapp_template_status(template)
|
||||
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
|
||||
return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash)
|
||||
|
||||
if status_result[:success]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
class CsatTemplateUtilityAnalysisService
|
||||
include CsatTemplateUtilityRubric
|
||||
|
||||
pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }]
|
||||
|
||||
def perform
|
||||
baseline = rule_based_result
|
||||
return baseline if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
llm_result = llm_result_or_nil(baseline)
|
||||
llm_result || baseline
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def llm_result_or_nil(baseline)
|
||||
llm_output = Captain::CsatUtilityAnalysisService.new(
|
||||
account: account,
|
||||
message: message,
|
||||
button_text: button_text,
|
||||
language: language,
|
||||
baseline: baseline
|
||||
).perform
|
||||
|
||||
return nil if llm_output[:error]
|
||||
|
||||
normalize_llm_result(llm_output, baseline: baseline)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_llm_result(result, baseline:)
|
||||
classification = normalized_classification(result[:classification], baseline: baseline)
|
||||
optimized_message = result[:optimized_message].presence || baseline[:optimized_message]
|
||||
optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
{
|
||||
classification: classification,
|
||||
optimized_message: optimized_message
|
||||
}
|
||||
end
|
||||
|
||||
def normalized_classification(value, baseline:)
|
||||
raw = value.to_s
|
||||
return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING'
|
||||
|
||||
raw
|
||||
end
|
||||
|
||||
def rule_based_result
|
||||
text = sanitized_message
|
||||
marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) }
|
||||
criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count)
|
||||
classification = classify(criteria: criteria, utility_hits_count: utility_hits_count)
|
||||
build_rule_payload(
|
||||
classification: classification
|
||||
)
|
||||
end
|
||||
|
||||
def build_rule_payload(payload)
|
||||
{
|
||||
classification: payload[:classification],
|
||||
optimized_message: optimized_message_for(payload[:classification])
|
||||
}
|
||||
end
|
||||
|
||||
def sanitized_message
|
||||
message.to_s.squish
|
||||
end
|
||||
|
||||
def classify(criteria:, utility_hits_count:)
|
||||
return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition]
|
||||
return 'LIKELY_MARKETING' unless criteria[:prohibited_content]
|
||||
return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2
|
||||
|
||||
'UNCLEAR'
|
||||
end
|
||||
|
||||
def optimized_message_for(classification)
|
||||
return sanitized_message if classification == 'LIKELY_UTILITY'
|
||||
|
||||
build_input_aware_utility_message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
# rubocop:disable Metrics/ModuleLength
|
||||
module CsatTemplateUtilityRubric
|
||||
LANGUAGE_FALLBACKS = {
|
||||
'en' => {
|
||||
support_request: 'support request',
|
||||
support_ticket: 'support ticket',
|
||||
support_conversation: 'support conversation',
|
||||
status_closed: 'closed',
|
||||
status_resolved: 'resolved',
|
||||
status_completed: 'completed',
|
||||
line_status: 'Your %<subject>s has been %<status>s.',
|
||||
line_help: 'If you still need help, simply reply to this message.',
|
||||
line_rate: 'To rate this support interaction, please use the button below.'
|
||||
}
|
||||
}.freeze
|
||||
|
||||
MARKETING_PATTERNS = [
|
||||
/\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i,
|
||||
/\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i,
|
||||
/\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i,
|
||||
/\b(new\s*(plans?|products?|services?))\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTION_TRIGGER_PATTERNS = [
|
||||
/\b(closed|closing|resolved|completed)\b/i,
|
||||
/\b(ticket|request|case|conversation|support)\b/i
|
||||
].freeze
|
||||
|
||||
TRANSACTIONAL_CONTENT_PATTERNS = [
|
||||
/\b(ticket|request|case|conversation)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate|califica|calificar)\b/i
|
||||
].freeze
|
||||
|
||||
PROHIBITED_CONTENT_PATTERNS = [
|
||||
/\b(contest|sweepstake|lottery|quiz)\b/i,
|
||||
/\b(password|otp|pin|cvv|credit\s*card)\b/i,
|
||||
/\b(weapon|drugs|gambling)\b/i
|
||||
].freeze
|
||||
|
||||
STATUS_PATTERNS = {
|
||||
'closed' => /\b(closed|closing)\b/i,
|
||||
'resolved' => /\b(resolved|resolve[sd]?)\b/i,
|
||||
'completed' => /\b(completed|complete[sd]?)\b/i
|
||||
}.freeze
|
||||
|
||||
SUBJECT_PATTERNS = {
|
||||
'support ticket' => /\b(ticket)\b/i,
|
||||
'support conversation' => /\b(conversation|chat)\b/i,
|
||||
'support request' => /\b(request|case|support)\b/i
|
||||
}.freeze
|
||||
|
||||
UTILITY_PATTERNS = [
|
||||
/\b(support|ticket|request|conversation|case)\b/i,
|
||||
/\b(closed|resolved|completed)\b/i,
|
||||
/\b(reply\s+to\s+this\s+message\b)/i,
|
||||
/\b(if\s+you\s+still\s+need\s+help)\b/i,
|
||||
/\b(rate\s+this\s+(support|interaction|conversation))\b/i
|
||||
].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_input_aware_utility_message
|
||||
text = translation_pack
|
||||
subject = detected_subject
|
||||
status = detected_status
|
||||
intro = extracted_intro_sentence
|
||||
|
||||
parts = []
|
||||
parts << intro if intro.present?
|
||||
parts << format(text[:line_status], subject: subject, status: status)
|
||||
parts << text[:line_help]
|
||||
parts << text[:line_rate]
|
||||
parts.join(' ')
|
||||
end
|
||||
|
||||
def detected_status
|
||||
matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
status_key = matched&.first || 'closed'
|
||||
translation_pack[:"status_#{status_key}"]
|
||||
end
|
||||
|
||||
def detected_subject
|
||||
matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) }
|
||||
subject_key = matched&.first&.tr(' ', '_') || 'support_request'
|
||||
translation_pack[subject_key.to_sym]
|
||||
end
|
||||
|
||||
def extracted_intro_sentence
|
||||
first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s
|
||||
return nil if first_sentence.blank?
|
||||
return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) }
|
||||
return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i)
|
||||
|
||||
normalized = first_sentence.gsub(/\s+/, ' ').strip
|
||||
normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}."
|
||||
end
|
||||
|
||||
def translation_pack
|
||||
LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en'])
|
||||
end
|
||||
|
||||
def primary_language_code
|
||||
language.to_s.downcase.split(/[-_]/).first
|
||||
end
|
||||
|
||||
def evaluate_criteria(text:, marketing_hits_count:)
|
||||
{
|
||||
trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) },
|
||||
transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2,
|
||||
marketing_prohibition: marketing_hits_count.zero?,
|
||||
prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) },
|
||||
clarity_and_utility: clear_utility_intent?(text)
|
||||
}
|
||||
end
|
||||
|
||||
def clear_utility_intent?(text)
|
||||
has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i)
|
||||
has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) ||
|
||||
text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) ||
|
||||
text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i)
|
||||
has_support_context && has_actionable_next_step
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ModuleLength
|
||||
@@ -145,7 +145,12 @@ class Line::IncomingMessageService
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = @contact_inbox.conversations.first
|
||||
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
@conversation = if @inbox.lock_to_single_conversation
|
||||
@contact_inbox.conversations.last
|
||||
else
|
||||
@contact_inbox.conversations.where.not(status: :resolved).last
|
||||
end
|
||||
return if @conversation
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
|
||||
@@ -23,7 +23,12 @@ class Tiktok::MessageService
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
@conversation ||= if channel.inbox.lock_to_single_conversation
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
else
|
||||
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first
|
||||
end
|
||||
@conversation ||= create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
end
|
||||
|
||||
def create_message
|
||||
|
||||
@@ -27,7 +27,15 @@ module Tiktok::MessagingHelpers
|
||||
end
|
||||
|
||||
def find_conversation(channel, tt_conversation_id)
|
||||
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)&.conversations&.first
|
||||
contact_inbox = channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id)
|
||||
return if contact_inbox.blank?
|
||||
|
||||
if channel.inbox.lock_to_single_conversation
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
else
|
||||
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first ||
|
||||
contact_inbox.conversations.order(created_at: :desc).first
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation(channel, contact_inbox, tt_conversation_id)
|
||||
|
||||
@@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_CATEGORY = 'UTILITY'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
|
||||
@@ -108,6 +108,11 @@ en:
|
||||
host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again.
|
||||
connection_timed_out_error: Connection timed out for %{address}:%{port}
|
||||
connection_closed_error: Connection closed.
|
||||
smtp:
|
||||
authentication_error: SMTP authentication failed. Please verify your login credentials.
|
||||
connection_error: Could not connect to SMTP server. Please check the server address and port.
|
||||
ssl_error: SSL/TLS error. Please verify your encryption settings.
|
||||
smtp_error: SMTP server error. Please check your configuration and try again.
|
||||
validations:
|
||||
name: should not start or end with symbols, and it should not have < > / \ @ characters.
|
||||
custom_filters:
|
||||
@@ -433,6 +438,9 @@ en:
|
||||
seconds:
|
||||
one: '%{count} second'
|
||||
other: '%{count} seconds'
|
||||
auto_assignment:
|
||||
default_policy_name: 'Default Policy'
|
||||
policy_actor: 'Automation System via %{policy_name}'
|
||||
automation:
|
||||
system_name: 'Automation System'
|
||||
crm:
|
||||
|
||||
+3
-1
@@ -224,7 +224,9 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
|
||||
post :analyze, on: :collection
|
||||
end
|
||||
end
|
||||
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
|
||||
@@ -3,6 +3,8 @@ require 'agents/instrumentation'
|
||||
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
@@ -21,13 +23,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
agents = build_and_wire_agents
|
||||
context = build_context(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
runner = Agents::Runner.with_agents(*agents)
|
||||
runner = add_usage_metadata_callback(runner)
|
||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||
install_instrumentation(runner)
|
||||
message_to_process, context = run_payload(message_history)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 100)
|
||||
|
||||
process_agent_result(result)
|
||||
@@ -45,7 +41,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = extract_text_from_content(msg[:content])
|
||||
content = msg[:content]
|
||||
# Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
|
||||
# Only extract text from non-array formats (hashes from agent structured output, plain strings).
|
||||
content = extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
{
|
||||
role: msg[:role].to_sym,
|
||||
@@ -63,8 +62,22 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
return '' if last_user_msg.blank?
|
||||
|
||||
extract_text_from_content(last_user_msg[:content])
|
||||
content = last_user_msg[:content]
|
||||
return extract_text_from_content(content) unless content.is_a?(Array)
|
||||
|
||||
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
|
||||
return text if attachments.blank?
|
||||
|
||||
RubyLLM::Content.new(text, attachments)
|
||||
end
|
||||
|
||||
def message_history_without_last_user_message(message_history)
|
||||
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
|
||||
return message_history if last_user_index.nil?
|
||||
|
||||
message_history.reject.with_index { |_msg, index| index == last_user_index }
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
@@ -143,28 +156,25 @@ class Captain::Assistant::AgentRunnerService
|
||||
},
|
||||
attribute_provider: ->(context_wrapper) { dynamic_trace_attributes(context_wrapper) }
|
||||
)
|
||||
register_trace_input_callback(runner)
|
||||
end
|
||||
|
||||
def dynamic_trace_attributes(context_wrapper)
|
||||
state = context_wrapper&.context&.dig(:state) || {}
|
||||
conversation = state[:conversation] || {}
|
||||
trace_input = context_wrapper&.context&.dig(:captain_v2_trace_input)
|
||||
|
||||
{
|
||||
ATTR_LANGFUSE_USER_ID => state[:account_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'assistant_id') => state[:assistant_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type]
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
end
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||
runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
|
||||
runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
|
||||
runner
|
||||
end
|
||||
|
||||
def add_usage_metadata_callback(runner)
|
||||
return runner unless ChatwootApp.otel_enabled?
|
||||
|
||||
@@ -195,35 +205,20 @@ class Captain::Assistant::AgentRunnerService
|
||||
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), credit_used.to_s)
|
||||
end
|
||||
|
||||
def add_agent_thinking_callback(runner)
|
||||
runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
def runner
|
||||
@runner ||= begin
|
||||
configured_runner = Agents::Runner.with_agents(*build_and_wire_agents)
|
||||
configured_runner = add_usage_metadata_callback(configured_runner)
|
||||
configured_runner = add_callbacks_to_runner(configured_runner) if @callbacks.any?
|
||||
install_instrumentation(configured_runner)
|
||||
configured_runner
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_start_callback(runner)
|
||||
runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_complete_callback(runner)
|
||||
runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_handoff_callback(runner)
|
||||
runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
def run_payload(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
context = build_context(message_history_without_last_user_message(message_history))
|
||||
enrich_context_with_trace_payload!(context, message_history, message_to_process)
|
||||
[message_to_process, context]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
module Captain::Assistant::RunnerCallbacksHelper
|
||||
private
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking]
|
||||
runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start]
|
||||
runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete]
|
||||
runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff]
|
||||
runner
|
||||
end
|
||||
|
||||
def register_trace_input_callback(runner)
|
||||
runner.on_agent_thinking do |_agent_name, _input, context_wrapper|
|
||||
tracing = context_wrapper&.context&.dig(:__otel_tracing)
|
||||
next unless tracing
|
||||
|
||||
trace_input = context_wrapper.context[:captain_v2_trace_current_input]
|
||||
tracing[:pending_llm_input] = trace_input if trace_input.present?
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_thinking_callback(runner)
|
||||
runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_start_callback(runner)
|
||||
runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_tool_complete_callback(runner)
|
||||
runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def add_agent_handoff_callback(runner)
|
||||
runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
module Captain::Assistant::TracePayloadHelper
|
||||
private
|
||||
|
||||
def enrich_context_with_trace_payload!(context, message_history, message_to_process)
|
||||
context[:captain_v2_trace_input] = serialize_trace_messages(message_history)
|
||||
context[:captain_v2_trace_current_input] = serialize_trace_content(message_to_process)
|
||||
end
|
||||
|
||||
def serialize_trace_messages(message_history)
|
||||
message_history.map do |message|
|
||||
{
|
||||
role: message[:role].to_s,
|
||||
content: trace_content_payload(message[:content])
|
||||
}
|
||||
end.to_json
|
||||
end
|
||||
|
||||
def serialize_trace_content(content)
|
||||
payload = trace_content_payload(content)
|
||||
return '' if payload.blank?
|
||||
|
||||
payload.is_a?(String) ? payload : payload.to_json
|
||||
end
|
||||
|
||||
def trace_content_payload(content)
|
||||
case content
|
||||
when RubyLLM::Content
|
||||
trace_parts_from_ruby_llm_content(content)
|
||||
when Array, Hash
|
||||
content
|
||||
when NilClass
|
||||
''
|
||||
else
|
||||
content.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def trace_parts_from_ruby_llm_content(content)
|
||||
parts = []
|
||||
parts << { type: 'text', text: content.text } if content.text.present?
|
||||
|
||||
content.attachments.each do |attachment|
|
||||
parts << { type: 'image_url', image_url: { url: attachment.source.to_s } }
|
||||
end
|
||||
|
||||
return '' if parts.blank?
|
||||
return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
|
||||
|
||||
parts
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::ChatwootHub
|
||||
ENTERPRISE_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
|
||||
|
||||
def base_url
|
||||
return ENV.fetch('CHATWOOT_HUB_URL', ENTERPRISE_BASE_URL) if Rails.env.development?
|
||||
|
||||
ENTERPRISE_BASE_URL
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }]
|
||||
|
||||
def perform
|
||||
api_response = make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: message }
|
||||
]
|
||||
)
|
||||
|
||||
return api_response if api_response[:error]
|
||||
|
||||
build_result(api_response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_result(response_message)
|
||||
parsed = parse_json_response(response_message)
|
||||
return { error: 'Invalid LLM response format' } if parsed.blank?
|
||||
|
||||
core_result(parsed).merge(message: response_message)
|
||||
end
|
||||
|
||||
def core_result(parsed)
|
||||
{
|
||||
classification: normalize_classification(parsed['classification']),
|
||||
optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
template = prompt_from_file('csat_utility_analysis')
|
||||
Liquid::Template.parse(template).render(prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
{
|
||||
'message' => message.to_s,
|
||||
'button_text' => button_text.to_s,
|
||||
'language' => language.to_s,
|
||||
'baseline_classification' => baseline[:classification].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def parse_json_response(content)
|
||||
raw = content.to_s.strip
|
||||
json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw
|
||||
JSON.parse(json)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_classification(value)
|
||||
normalized = value.to_s.upcase
|
||||
return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized)
|
||||
|
||||
baseline[:classification].presence || 'UNCLEAR'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'csat_utility_analysis'
|
||||
end
|
||||
end
|
||||
+32
-23
@@ -1,12 +1,30 @@
|
||||
# TODO: lets use HTTParty instead of RestClient
|
||||
class ChatwootHub
|
||||
BASE_URL = ENV.fetch('CHATWOOT_HUB_URL', 'https://hub.2.chatwoot.com')
|
||||
PING_URL = "#{BASE_URL}/ping".freeze
|
||||
REGISTRATION_URL = "#{BASE_URL}/instances".freeze
|
||||
PUSH_NOTIFICATION_URL = "#{BASE_URL}/send_push".freeze
|
||||
EVENTS_URL = "#{BASE_URL}/events".freeze
|
||||
BILLING_URL = "#{BASE_URL}/billing".freeze
|
||||
CAPTAIN_ACCOUNTS_URL = "#{BASE_URL}/instance_captain_accounts".freeze
|
||||
DEFAULT_BASE_URL = 'https://hub.2.chatwoot.com'.freeze
|
||||
|
||||
def self.base_url
|
||||
DEFAULT_BASE_URL
|
||||
end
|
||||
|
||||
def self.ping_url
|
||||
"#{base_url}/ping"
|
||||
end
|
||||
|
||||
def self.registration_url
|
||||
"#{base_url}/instances"
|
||||
end
|
||||
|
||||
def self.push_notification_url
|
||||
"#{base_url}/send_push"
|
||||
end
|
||||
|
||||
def self.events_url
|
||||
"#{base_url}/events"
|
||||
end
|
||||
|
||||
def self.billing_base_url
|
||||
"#{base_url}/billing"
|
||||
end
|
||||
|
||||
def self.installation_identifier
|
||||
identifier = InstallationConfig.find_by(name: 'INSTALLATION_IDENTIFIER')&.value
|
||||
@@ -15,7 +33,7 @@ class ChatwootHub
|
||||
end
|
||||
|
||||
def self.billing_url
|
||||
"#{BILLING_URL}?installation_identifier=#{installation_identifier}"
|
||||
"#{billing_base_url}?installation_identifier=#{installation_identifier}"
|
||||
end
|
||||
|
||||
def self.pricing_plan
|
||||
@@ -68,7 +86,7 @@ class ChatwootHub
|
||||
begin
|
||||
info = instance_config
|
||||
info = info.merge(instance_metrics) unless ENV['DISABLE_TELEMETRY']
|
||||
response = RestClient.post(PING_URL, info.to_json, { content_type: :json, accept: :json })
|
||||
response = RestClient.post(ping_url, info.to_json, { content_type: :json, accept: :json })
|
||||
parsed_response = JSON.parse(response)
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
@@ -80,7 +98,7 @@ class ChatwootHub
|
||||
|
||||
def self.register_instance(company_name, owner_name, owner_email)
|
||||
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
|
||||
RestClient.post(REGISTRATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(registration_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
@@ -89,32 +107,23 @@ class ChatwootHub
|
||||
|
||||
def self.send_push(fcm_options)
|
||||
info = { fcm_options: fcm_options }
|
||||
RestClient.post(PUSH_NOTIFICATION_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def self.get_captain_settings(account)
|
||||
info = {
|
||||
installation_identifier: installation_identifier,
|
||||
chatwoot_account_id: account.id,
|
||||
account_name: account.name
|
||||
}
|
||||
HTTParty.post(CAPTAIN_ACCOUNTS_URL,
|
||||
body: info.to_json,
|
||||
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' })
|
||||
end
|
||||
|
||||
def self.emit_event(event_name, event_data)
|
||||
return if ENV['DISABLE_TELEMETRY']
|
||||
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
RestClient.post(EVENTS_URL, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
RestClient.post(events_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
|
||||
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
|
||||
Rails.logger.error "Exception: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
ChatwootHub.singleton_class.prepend_mod_with('ChatwootHub')
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
You are a WhatsApp template compliance assistant.
|
||||
Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy.
|
||||
|
||||
Rules:
|
||||
1. Prefer UTILITY only when the message is tied to an existing support or transactional event.
|
||||
2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent.
|
||||
3. Keep the rewritten message concise, explicit, and purely transactional.
|
||||
4. Do not invent product offers or marketing phrases.
|
||||
|
||||
Input:
|
||||
- Message: {{ message }}
|
||||
- Button text: {{ button_text }}
|
||||
- Language code: {{ language }}
|
||||
|
||||
Baseline heuristic:
|
||||
- Classification: {{ baseline_classification }}
|
||||
|
||||
Return ONLY valid JSON with this shape (example):
|
||||
{
|
||||
"classification": "LIKELY_UTILITY",
|
||||
"optimized_message": "rewritten utility-safe message"
|
||||
}
|
||||
|
||||
Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR".
|
||||
|
||||
Important:
|
||||
- Write `optimized_message` in the same language as `Language code`.
|
||||
+2
-2
@@ -35,7 +35,7 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.6",
|
||||
"@chatwoot/utils": "^0.0.51",
|
||||
"@chatwoot/utils": "^0.0.52",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
@@ -95,6 +95,7 @@
|
||||
"video.js": "7.18.1",
|
||||
"videojs-record": "4.5.0",
|
||||
"videojs-wavesurfer": "3.8.0",
|
||||
"virtua": "^0.48.6",
|
||||
"vue": "^3.5.12",
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
@@ -103,7 +104,6 @@
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-router": "~4.4.5",
|
||||
"vue-upload-component": "^3.1.17",
|
||||
"vue-virtual-scroller": "^2.0.0-beta.8",
|
||||
"vue3-click-away": "^1.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuex": "~4.1.0",
|
||||
|
||||
Generated
+32
-34
@@ -26,8 +26,8 @@ importers:
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.51
|
||||
version: 0.0.51
|
||||
specifier: ^0.0.52
|
||||
version: 0.0.52
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -205,6 +205,9 @@ importers:
|
||||
videojs-wavesurfer:
|
||||
specifier: 3.8.0
|
||||
version: 3.8.0
|
||||
virtua:
|
||||
specifier: ^0.48.6
|
||||
version: 0.48.6(vue@3.5.12(typescript@5.6.2))
|
||||
vue:
|
||||
specifier: ^3.5.12
|
||||
version: 3.5.12(typescript@5.6.2)
|
||||
@@ -229,9 +232,6 @@ importers:
|
||||
vue-upload-component:
|
||||
specifier: ^3.1.17
|
||||
version: 3.1.17
|
||||
vue-virtual-scroller:
|
||||
specifier: ^2.0.0-beta.8
|
||||
version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.2))
|
||||
vue3-click-away:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
@@ -457,8 +457,8 @@ packages:
|
||||
'@chatwoot/prosemirror-schema@1.3.6':
|
||||
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
|
||||
|
||||
'@chatwoot/utils@0.0.51':
|
||||
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
|
||||
'@chatwoot/utils@0.0.52':
|
||||
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -3305,9 +3305,6 @@ packages:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mitt@2.1.0:
|
||||
resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -4511,6 +4508,26 @@ packages:
|
||||
videojs-wavesurfer@3.8.0:
|
||||
resolution: {integrity: sha512-qHucCBiEW+4dZ0Zp1k4R1elprUOV+QDw87UDA9QRXtO7GK/MrSdoe/TMFxP9SLnJCiX9xnYdf4OQgrmvJ9UVVw==}
|
||||
|
||||
virtua@0.48.6:
|
||||
resolution: {integrity: sha512-Cl4uMvMV5c9RuOy9zhkFMYwx/V4YLBMYLRSWkO8J46opQZ3P7KMq0CqCVOOAKUckjl/r//D2jWTBGYWzmgtzrQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
solid-js: '>=1.0'
|
||||
svelte: '>=5.0'
|
||||
vue: '>=3.2'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
vite-node@2.0.1:
|
||||
resolution: {integrity: sha512-nVd6kyhPAql0s+xIVJzuF+RSRH8ZimNrm6U8ZvTA4MXv8CHI17TFaQwRaFiK75YX6XeFqZD4IoAaAfi9OR1XvQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -4625,11 +4642,6 @@ packages:
|
||||
vue-letter@0.2.1:
|
||||
resolution: {integrity: sha512-IYWp47XUikjKfEniWYlFxeJFKABZwAE5IEjz866qCBytBr2dzqVDdjoMDpBP//krxkzN/QZYyHe6C09y/IODYg==}
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
@@ -4643,11 +4655,6 @@ packages:
|
||||
vue-upload-component@3.1.17:
|
||||
resolution: {integrity: sha512-1orTC5apoFzBz4ku2HAydpviaAOck+ABc83rGypIK/Bgl+TqhtoWsQOhXqbb7vDv7pKlvRVWwml9PM224HyhkA==}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8:
|
||||
resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
vue3-click-away@1.2.4:
|
||||
resolution: {integrity: sha512-O9Z2KlvIhJT8OxaFy04eiZE9rc1Mk/bp+70dLok68ko3Kr8AW5dU+j8avSk4GDQu94FllSr4m5ul4BpzlKOw1A==}
|
||||
|
||||
@@ -5010,7 +5017,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.51':
|
||||
'@chatwoot/utils@0.0.52':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
@@ -8226,8 +8233,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
mitt@2.1.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
@@ -9574,6 +9579,10 @@ snapshots:
|
||||
video.js: 7.18.1
|
||||
wavesurfer.js: 7.8.6
|
||||
|
||||
virtua@0.48.6(vue@3.5.12(typescript@5.6.2)):
|
||||
optionalDependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vite-node@2.0.1(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -9692,10 +9701,6 @@ snapshots:
|
||||
dependencies:
|
||||
lettersanitizer: 1.0.6
|
||||
|
||||
vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
@@ -9707,13 +9712,6 @@ snapshots:
|
||||
|
||||
vue-upload-component@3.1.17: {}
|
||||
|
||||
vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
mitt: 2.1.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.2))
|
||||
|
||||
vue3-click-away@1.2.4: {}
|
||||
|
||||
vue@3.5.12(typescript@5.6.2):
|
||||
|
||||
@@ -10,10 +10,12 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
||||
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:web_widget_inbox) { create(:inbox, account: account) }
|
||||
let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) }
|
||||
let(:analysis_service) { instance_double(CsatTemplateUtilityAnalysisService) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
|
||||
allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service)
|
||||
allow(CsatTemplateUtilityAnalysisService).to receive(:new).and_return(analysis_service)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
|
||||
@@ -380,4 +382,93 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/analyze' do
|
||||
let(:valid_template_params) do
|
||||
{
|
||||
template: {
|
||||
message: 'How would you rate your experience?',
|
||||
button_text: 'Rate Us',
|
||||
language: 'en'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when captain_integration feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('captain_integration')
|
||||
end
|
||||
|
||||
it 'returns forbidden' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('Captain is required for template analysis')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_integration feature is enabled' do
|
||||
before do
|
||||
account.enable_features!('captain_integration')
|
||||
account.reload
|
||||
end
|
||||
|
||||
it 'returns analysis response' do
|
||||
allow(analysis_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_UTILITY',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = response.parsed_body
|
||||
expect(response_data['classification']).to eq('LIKELY_UTILITY')
|
||||
expect(response_data['optimized_message']).to eq('Your support request has been closed.')
|
||||
end
|
||||
|
||||
it 'returns error when message is missing' do
|
||||
invalid_params = { template: { button_text: 'Rate Us', language: 'en' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: invalid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Message is required')
|
||||
end
|
||||
|
||||
it 'returns unauthorized when agent is not assigned to inbox' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: other_agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'allows access when agent is assigned to inbox' do
|
||||
allow(analysis_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_UTILITY',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: valid_template_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -631,6 +631,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
it 'updates smtp configuration with starttls encryption' do
|
||||
smtp_connection = double
|
||||
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
|
||||
allow(smtp_connection).to receive(:start).and_return(true)
|
||||
allow(smtp_connection).to receive(:finish).and_return(true)
|
||||
allow(smtp_connection).to receive(:respond_to?).and_return(true)
|
||||
@@ -661,6 +662,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
it 'updates smtp configuration with ssl/tls encryption' do
|
||||
smtp_connection = double
|
||||
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
|
||||
allow(smtp_connection).to receive(:start).and_return(true)
|
||||
allow(smtp_connection).to receive(:finish).and_return(true)
|
||||
allow(smtp_connection).to receive(:respond_to?).and_return(true)
|
||||
@@ -691,6 +693,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
it 'updates smtp configuration with authentication mechanism' do
|
||||
smtp_connection = double
|
||||
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
|
||||
allow(smtp_connection).to receive(:start).and_return(true)
|
||||
allow(smtp_connection).to receive(:finish).and_return(true)
|
||||
allow(smtp_connection).to receive(:respond_to?).and_return(true)
|
||||
|
||||
@@ -32,23 +32,25 @@ RSpec.describe 'TikTok Authorization API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
travel_to Time.zone.parse('2025-01-01 00:00:00 UTC') do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
helper = Class.new do
|
||||
include Tiktok::IntegrationHelper
|
||||
end.new
|
||||
|
||||
expected_state = helper.generate_tiktok_token(account.id)
|
||||
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
|
||||
|
||||
expect(response.parsed_body['url']).to eq(expected_url)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
helper = Class.new do
|
||||
include Tiktok::IntegrationHelper
|
||||
end.new
|
||||
|
||||
expected_state = helper.generate_tiktok_token(account.id)
|
||||
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
|
||||
|
||||
expect(response.parsed_body['url']).to eq(expected_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ChatwootHub do
|
||||
describe '.base_url' do
|
||||
it 'uses the static hub url outside development for enterprise edition' do
|
||||
with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
|
||||
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
|
||||
|
||||
expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses CHATWOOT_HUB_URL in development for enterprise edition' do
|
||||
with_modified_env CHATWOOT_HUB_URL: 'https://custom.example.com' do
|
||||
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
|
||||
|
||||
expect(described_class.base_url).to eq('https://custom.example.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -74,12 +74,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
it 'runs agent with extracted user message and context' do
|
||||
expected_context = {
|
||||
expected_context = hash_including(
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello there', agent_name: nil },
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
|
||||
{ role: :user, content: 'I need help with my account', agent_name: nil }
|
||||
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
|
||||
],
|
||||
state: hash_including(
|
||||
account_id: account.id,
|
||||
@@ -87,7 +86,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
conversation: hash_including(id: conversation.id),
|
||||
contact: hash_including(id: contact.id)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
expect(mock_runner).to receive(:run).with(
|
||||
'I need help with my account',
|
||||
@@ -98,6 +97,71 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
context 'when the latest user message is multimodal' do
|
||||
let(:multimodal_message_history) do
|
||||
[
|
||||
{ role: 'assistant', content: 'Please share a screenshot' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'What does this error mean?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
it 'passes image attachments to the runner input' do
|
||||
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
|
||||
expect(input).to be_a(RubyLLM::Content)
|
||||
expect(input.text).to eq('What does this error mean?')
|
||||
expect(input.attachments.first.source.to_s).to eq('https://example.com/error.png')
|
||||
expect(context[:conversation_history]).to eq([{ role: :assistant, content: 'Please share a screenshot', agent_name: nil }])
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: multimodal_message_history)
|
||||
end
|
||||
|
||||
it 'preserves multimodal content in earlier history messages' do
|
||||
history_with_prior_image = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is my error screenshot' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
]
|
||||
},
|
||||
{ role: 'assistant', content: 'I see the error. Try restarting.' },
|
||||
{ role: 'user', content: 'It still does not work' }
|
||||
]
|
||||
|
||||
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
|
||||
expect(input).to eq('It still does not work')
|
||||
# The earlier user message with the image should preserve the multimodal array
|
||||
first_history_msg = context[:conversation_history].first
|
||||
expect(first_history_msg[:content]).to be_a(Array)
|
||||
expect(first_history_msg[:content]).to include(
|
||||
{ type: 'text', text: 'Here is my error screenshot' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
|
||||
)
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: history_with_prior_image)
|
||||
end
|
||||
|
||||
it 'stores multimodal trace payloads in runner context' do
|
||||
expect(mock_runner).to receive(:run) do |_input, context:, max_turns:|
|
||||
expect(context[:captain_v2_trace_input]).to include('image_url')
|
||||
expect(context[:captain_v2_trace_current_input]).to include('image_url')
|
||||
expect(max_turns).to eq(100)
|
||||
end
|
||||
|
||||
service.generate_response(message_history: multimodal_message_history)
|
||||
end
|
||||
end
|
||||
|
||||
it 'processes and formats agent result' do
|
||||
result = service.generate_response(message_history: message_history)
|
||||
|
||||
@@ -197,22 +261,21 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
|
||||
context 'with multimodal content' do
|
||||
let(:multimodal_message_history) do
|
||||
let(:multimodal_content) do
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Can you help with this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
}
|
||||
{ type: 'text', text: 'Can you help with this image?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
end
|
||||
|
||||
it 'extracts text content from multimodal messages' do
|
||||
let(:multimodal_message_history) do
|
||||
[{ role: 'user', content: multimodal_content }]
|
||||
end
|
||||
|
||||
it 'preserves multimodal arrays in conversation history for image context retention' do
|
||||
context = service.send(:build_context, multimodal_message_history)
|
||||
|
||||
expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
|
||||
expect(context[:conversation_history].first[:content]).to eq(multimodal_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -225,6 +288,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
|
||||
expect(result).to eq('I need help with my account')
|
||||
end
|
||||
|
||||
it 'returns multimodal content with image attachments for the runner input' do
|
||||
multimodal_message_history = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Can you check this screenshot?' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = service.send(:extract_last_user_message, multimodal_message_history)
|
||||
|
||||
expect(result).to be_a(RubyLLM::Content)
|
||||
expect(result.text).to eq('Can you check this screenshot?')
|
||||
expect(result.attachments.first.source.to_s).to eq('https://example.com/image.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_text_from_content' do
|
||||
@@ -256,6 +337,28 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#dynamic_trace_attributes' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
it 'adds serialized trace input attributes when present in context' do
|
||||
context = {
|
||||
state: {
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: { id: conversation.id, display_id: conversation.display_id }
|
||||
},
|
||||
captain_v2_trace_input: '[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image.jpg"}}]}]'
|
||||
}
|
||||
context_wrapper = Struct.new(:context).new(context)
|
||||
|
||||
attributes = service.send(:dynamic_trace_attributes, context_wrapper)
|
||||
|
||||
expect(attributes['langfuse.trace.input']).to include('image_url')
|
||||
expect(attributes['langfuse.observation.input']).to include('image_url')
|
||||
expect(attributes['langfuse.user.id']).to eq(account.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_state' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
|
||||
|
||||
|
||||
@@ -44,17 +44,19 @@ describe Enterprise::Billing::TopupCheckoutService do
|
||||
end
|
||||
|
||||
it 'raises error for invalid credits' do
|
||||
expect do
|
||||
service.create_checkout_session(credits: 500)
|
||||
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
|
||||
expect { service.create_checkout_session(credits: 500) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error')
|
||||
expect(error.message).to eq(I18n.t('errors.topup.invalid_option'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises error when account is on free plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
|
||||
|
||||
expect do
|
||||
service.create_checkout_session(credits: 1000)
|
||||
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
|
||||
expect { service.create_checkout_session(credits: 1000) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error')
|
||||
expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible'))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::CsatUtilityAnalysisService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
allow(service).to receive(:make_api_call).and_return({
|
||||
message: '{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}'
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns parsed payload and preserves raw message for usage metering' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_UTILITY')
|
||||
expect(result[:optimized_message]).to eq('Utility-safe message')
|
||||
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ChatwootHub do
|
||||
describe '.base_url' do
|
||||
it 'uses the static hub url' do
|
||||
expect(described_class::DEFAULT_BASE_URL).to eq('https://hub.2.chatwoot.com')
|
||||
expect(described_class.base_url).to eq('https://hub.2.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'generates installation identifier' do
|
||||
installation_identifier = described_class.installation_identifier
|
||||
expect(installation_identifier).not_to be_nil
|
||||
@@ -12,7 +19,7 @@ describe ChatwootHub do
|
||||
version = '1.1.1'
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL, described_class.instance_config
|
||||
expect(RestClient).to have_received(:post).with(described_class.ping_url, described_class.instance_config
|
||||
.merge(described_class.instance_metrics).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
@@ -21,7 +28,7 @@ describe ChatwootHub do
|
||||
with_modified_env DISABLE_TELEMETRY: 'true' do
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.ping_url,
|
||||
described_class.instance_config.to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
@@ -41,7 +48,7 @@ describe ChatwootHub do
|
||||
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.register_instance(company_name, owner_name, owner_email)
|
||||
expect(RestClient).to have_received(:post).with(described_class::REGISTRATION_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.registration_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
@@ -54,7 +61,7 @@ describe ChatwootHub do
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).to have_received(:post).with(described_class::EVENTS_URL,
|
||||
expect(RestClient).to have_received(:post).with(described_class.events_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
@@ -64,29 +71,9 @@ describe ChatwootHub do
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).not_to have_received(:post)
|
||||
.with(described_class::EVENTS_URL,
|
||||
.with(described_class.events_url,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fetching captain settings' do
|
||||
it 'returns the captain settings' do
|
||||
account = create(:account)
|
||||
stub_request(:post, ChatwootHub::CAPTAIN_ACCOUNTS_URL).with(
|
||||
body: { installation_identifier: described_class.installation_identifier, chatwoot_account_id: account.id, account_name: account.name }
|
||||
).to_return(
|
||||
body: { account_email: 'test@test.com', account_id: '123', access_token: '123', assistant_id: '123' }.to_json
|
||||
)
|
||||
|
||||
expect(described_class.get_captain_settings(account).body).to eq(
|
||||
{
|
||||
account_email: 'test@test.com',
|
||||
account_id: '123',
|
||||
access_token: '123',
|
||||
assistant_id: '123'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -142,7 +142,24 @@ describe NotificationListener do
|
||||
expect(first_agent.notifications.first.notification_type).to eq('conversation_mention')
|
||||
end
|
||||
|
||||
it 'will not create duplicate new message notifications for assignment & participation' do
|
||||
it 'will create a mention notification when a user is mentioned in a private note' do
|
||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})",
|
||||
private: true
|
||||
)
|
||||
event = Events::Base.new(event_name, Time.zone.now, message: message)
|
||||
listener.message_created(event)
|
||||
|
||||
expect(first_agent.notifications.count).to eq(1)
|
||||
expect(first_agent.notifications.first.notification_type).to eq('conversation_mention')
|
||||
end
|
||||
|
||||
it 'will not create new message notifications for private messages without mentions' do
|
||||
create(:inbox_member, user: first_agent, inbox: inbox)
|
||||
conversation.update(assignee: first_agent)
|
||||
# participants is created by async job. so creating it directly for testcase
|
||||
@@ -160,8 +177,7 @@ describe NotificationListener do
|
||||
listener.message_created(event)
|
||||
|
||||
expect(conversation.conversation_participants.map(&:user)).to include(first_agent)
|
||||
expect(first_agent.notifications.count).to eq(1)
|
||||
expect(first_agent.notifications.first.notification_type).to eq('assigned_conversation_new_message')
|
||||
expect(first_agent.notifications.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe AutoAssignment::RateLimiter do
|
||||
|
||||
it 'still tracks the assignment with default window' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i)
|
||||
expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 5.minutes.to_i)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
end
|
||||
@@ -154,12 +154,12 @@ RSpec.describe AutoAssignment::RateLimiter do
|
||||
allow(inbox).to receive(:assignment_policy).and_return(assignment_policy)
|
||||
end
|
||||
|
||||
it 'uses the default window value of 24 hours' do
|
||||
it 'uses the default window value of 5 minutes' do
|
||||
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
|
||||
expect(Redis::Alfred).to receive(:set).with(
|
||||
expected_key,
|
||||
conversation.id.to_s,
|
||||
ex: 86_400
|
||||
ex: 5.minutes.to_i
|
||||
)
|
||||
rate_limiter.track_assignment(conversation)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CsatTemplateUtilityAnalysisService do
|
||||
let(:account) { build_stubbed(:account) }
|
||||
let(:inbox) { build_stubbed(:inbox) }
|
||||
let(:llm_service) { instance_double(Captain::CsatUtilityAnalysisService) }
|
||||
|
||||
before do
|
||||
allow(Captain::CsatUtilityAnalysisService).to receive(:new).and_return(llm_service)
|
||||
allow(llm_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message is utility-compatible' do
|
||||
it 'returns likely utility classification and keeps original message' do
|
||||
message = 'Your support request has been closed. If you still need help, reply to this message.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_UTILITY')
|
||||
expect(result[:optimized_message]).to eq(message)
|
||||
expect(result.keys).to contain_exactly(:classification, :optimized_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains marketing intent' do
|
||||
it 'returns likely marketing classification with utility-safe rewrite' do
|
||||
message = 'Please rate us and check out our special offer with a discount.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
expect(result[:optimized_message]).to include('support request')
|
||||
expect(result[:optimized_message]).to include('reply to this message')
|
||||
expect(result.keys).to contain_exactly(:classification, :optimized_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when language is non-English and fallback rewrite is used' do
|
||||
it 'returns English rewrite content' do
|
||||
message = 'Tu caso está cerrado. Califícanos y no te pierdas nuestra oferta.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'es').perform
|
||||
|
||||
expect(result[:optimized_message]).to include('Your support request has been closed.')
|
||||
expect(result[:optimized_message]).to include('If you still need help')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when llm returns inconsistent marketing classification' do
|
||||
it 'keeps likely marketing classification' do
|
||||
allow(llm_service).to receive(:perform).and_return({
|
||||
classification: 'LIKELY_MARKETING',
|
||||
optimized_message: 'Your support request has been closed.'
|
||||
})
|
||||
|
||||
message = "Your case is closed. Don't miss our limited-time premium offer. Rate us below."
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rules classify as marketing' do
|
||||
it 'short-circuits without calling llm' do
|
||||
expect(llm_service).not_to receive(:perform)
|
||||
|
||||
message = 'Your request is closed. Special offer: subscribe now and save.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when rules classify as marketing for plural promo terms' do
|
||||
it 'keeps likely marketing classification from baseline rules' do
|
||||
message = 'Thanks for contacting us. Rate us and check out our new plans with special discounts.'
|
||||
result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform
|
||||
|
||||
expect(result[:classification]).to eq('LIKELY_MARKETING')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -405,5 +405,111 @@ describe Line::IncomingMessageService do
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is false' do
|
||||
before do
|
||||
line_channel.inbox.update(lock_to_single_conversation: false)
|
||||
end
|
||||
|
||||
it 'creates a new conversation when all previous conversations are resolved' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
# Create a contact and a resolved conversation
|
||||
described_class.new(inbox: line_channel.inbox, params: params).perform
|
||||
|
||||
# Mark the conversation as resolved
|
||||
conversation = line_channel.inbox.conversations.last
|
||||
conversation.update(status: :resolved)
|
||||
|
||||
# Send a new message
|
||||
new_params = params.deep_dup
|
||||
new_params[:events][0][:message][:id] = '325709'
|
||||
new_params[:events][0][:message][:text] = 'Second message'
|
||||
|
||||
described_class.new(inbox: line_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should create a new conversation
|
||||
expect(line_channel.inbox.conversations.count).to eq(2)
|
||||
expect(line_channel.inbox.conversations.last.messages.first.content).to eq('Second message')
|
||||
end
|
||||
|
||||
it 'uses the existing conversation when there is an unresolved conversation' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
# Create a contact and an unresolved conversation
|
||||
described_class.new(inbox: line_channel.inbox, params: params).perform
|
||||
|
||||
# Send a new message
|
||||
new_params = params.deep_dup
|
||||
new_params[:events][0][:message][:id] = '325709'
|
||||
new_params[:events][0][:message][:text] = 'Second message'
|
||||
|
||||
described_class.new(inbox: line_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should use the same conversation
|
||||
expect(line_channel.inbox.conversations.count).to eq(1)
|
||||
expect(line_channel.inbox.conversations.last.messages.count).to eq(2)
|
||||
expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is true' do
|
||||
before do
|
||||
line_channel.inbox.update(lock_to_single_conversation: true)
|
||||
end
|
||||
|
||||
it 'uses the existing conversation even when it is resolved' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
|
||||
# Create a contact and a resolved conversation
|
||||
described_class.new(inbox: line_channel.inbox, params: params).perform
|
||||
|
||||
# Mark the conversation as resolved
|
||||
conversation = line_channel.inbox.conversations.last
|
||||
conversation.update(status: :resolved)
|
||||
|
||||
# Send a new message
|
||||
new_params = params.deep_dup
|
||||
new_params[:events][0][:message][:id] = '325709'
|
||||
new_params[:events][0][:message][:text] = 'Second message'
|
||||
|
||||
described_class.new(inbox: line_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should use the same conversation
|
||||
expect(line_channel.inbox.conversations.count).to eq(1)
|
||||
expect(line_channel.inbox.conversations.last.messages.count).to eq(2)
|
||||
expect(line_channel.inbox.conversations.last.messages.last.content).to eq('Second message')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,11 +2,17 @@ require 'rails_helper'
|
||||
|
||||
describe Messages::NewMessageNotificationService do
|
||||
context 'when message is not notifiable' do
|
||||
it 'will not create any notifications' do
|
||||
it 'will not create any notifications for activity messages' do
|
||||
message = build(:message, message_type: :activity)
|
||||
expect(NotificationBuilder).not_to receive(:new)
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
|
||||
it 'will not create any notifications for private messages' do
|
||||
message = build(:message, message_type: :outgoing, private: true)
|
||||
expect(NotificationBuilder).not_to receive(:new)
|
||||
described_class.new(message: message).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is notifiable' do
|
||||
|
||||
@@ -410,6 +410,94 @@ describe Telegram::IncomingMessageService do
|
||||
expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('contact')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is false' do
|
||||
before do
|
||||
telegram_channel.inbox.update(lock_to_single_conversation: false)
|
||||
end
|
||||
|
||||
it 'creates a new conversation when all previous conversations are resolved' do
|
||||
# Create a contact and a resolved conversation
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'first message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
# Mark the conversation as resolved
|
||||
conversation = telegram_channel.inbox.conversations.last
|
||||
conversation.update(status: :resolved)
|
||||
|
||||
# Send a new message
|
||||
new_params = {
|
||||
'update_id' => 2_342_342_343_243,
|
||||
'message' => { 'text' => 'second message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should create a new conversation
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(2)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('second message')
|
||||
end
|
||||
|
||||
it 'uses the existing conversation when there is an unresolved conversation' do
|
||||
# Create a contact and an unresolved conversation
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'first message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
# Send a new message
|
||||
new_params = {
|
||||
'update_id' => 2_342_342_343_243,
|
||||
'message' => { 'text' => 'second message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should use the same conversation
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is true' do
|
||||
before do
|
||||
telegram_channel.inbox.update(lock_to_single_conversation: true)
|
||||
end
|
||||
|
||||
it 'uses the existing conversation even when it is resolved' do
|
||||
# Create a contact and a resolved conversation
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'first message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
# Mark the conversation as resolved
|
||||
conversation = telegram_channel.inbox.conversations.last
|
||||
conversation.update(status: :resolved)
|
||||
|
||||
# Send a new message
|
||||
new_params = {
|
||||
'update_id' => 2_342_342_343_243,
|
||||
'message' => { 'text' => 'second message' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: new_params).perform
|
||||
|
||||
# Should use the same conversation
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.count).to eq(2)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.last.content).to eq('second message')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is enabled' do
|
||||
|
||||
@@ -6,8 +6,29 @@ RSpec.describe Tiktok::MessageService do
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let(:text_content) do
|
||||
{
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-lock',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello from TikTok' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
subject(:perform_text_message) do
|
||||
service = described_class.new(channel: channel, content: current_content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
end
|
||||
|
||||
let(:current_content) { text_content }
|
||||
|
||||
it 'creates an incoming text message' do
|
||||
content = {
|
||||
type: 'text',
|
||||
@@ -113,5 +134,31 @@ RSpec.describe Tiktok::MessageService do
|
||||
ensure
|
||||
tempfile.close!
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is enabled' do
|
||||
it 'reuses the last resolved conversation' do
|
||||
inbox.update!(lock_to_single_conversation: true)
|
||||
resolved_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
perform_text_message
|
||||
|
||||
expect(inbox.conversations.count).to eq(1)
|
||||
expect(resolved_conversation.reload.messages.last.content).to eq('Hello from TikTok')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock_to_single_conversation is disabled' do
|
||||
let(:current_content) { text_content.merge(message_id: 'tt-msg-lock-2') }
|
||||
|
||||
it 'creates a new conversation if the previous one is resolved' do
|
||||
inbox.update!(lock_to_single_conversation: false)
|
||||
create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
perform_text_message
|
||||
|
||||
expect(inbox.conversations.count).to eq(2)
|
||||
expect(inbox.conversations.last.messages.last.content).to eq('Hello from TikTok')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,5 +31,31 @@ RSpec.describe Tiktok::ReadStatusService do
|
||||
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time))
|
||||
end
|
||||
|
||||
it 'updates the latest active conversation when lock_to_single_conversation is disabled' do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
|
||||
inbox.update!(lock_to_single_conversation: false)
|
||||
conversation.update!(status: :resolved)
|
||||
active_conversation = create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
status: :open,
|
||||
additional_attributes: { conversation_id: 'tt-conv-1' }
|
||||
)
|
||||
|
||||
content = {
|
||||
conversation_id: 'tt-conv-1',
|
||||
read: { last_read_timestamp: 1_700_000_000_000 },
|
||||
from_user: { id: 'user-1' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
described_class.new(channel: channel, content: content).perform
|
||||
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(active_conversation.id, kind_of(Time))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -119,7 +119,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
|
||||
expect(result).to eq({
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
category: 'UTILITY',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
@@ -169,7 +169,7 @@ RSpec.describe Whatsapp::CsatTemplateService do
|
||||
expected_body = {
|
||||
name: expected_template_name,
|
||||
language: 'en',
|
||||
category: 'MARKETING',
|
||||
category: 'UTILITY',
|
||||
components: [
|
||||
{
|
||||
type: 'BODY',
|
||||
|
||||
Reference in New Issue
Block a user