Merge branch 'develop' into perf/conversation_count

This commit is contained in:
Tanmay Deep Sharma
2026-02-26 18:13:56 +05:30
62 changed files with 1657 additions and 365 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
+17 -21
View File
@@ -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)
+6
View File
@@ -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,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 doesnt 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
@@ -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')
);
}
},
},
@@ -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>
@@ -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: {
@@ -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 -10
View File
@@ -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)
+6 -1
View File
@@ -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
+9 -1
View File
@@ -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)
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+32 -34
View File
@@ -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
+21
View File
@@ -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
+12 -25
View File
@@ -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
+19 -3
View File
@@ -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',