Compare commits

..
Author SHA1 Message Date
iamsivin df7ec0ca60 fix: Backslash issue when hard break space added 2025-12-11 10:37:57 +05:30
iamsivin d67d8b373f chore: Minor fix 2025-12-11 10:21:17 +05:30
iamsivin 3809d44d29 fix: Improve markdown for empty paragraphs and breaks 2025-12-11 00:37:47 +05:30
124 changed files with 1077 additions and 5457 deletions
-2
View File
@@ -48,7 +48,6 @@
- Remove dead/unreachable/unused code
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
## Project-Specific
@@ -79,4 +78,3 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
-1
View File
@@ -827,7 +827,6 @@ GEM
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
-74
View File
@@ -1,74 +0,0 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -1,160 +0,0 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
def extract_template_params
params.require(:template).permit(:message, :button_text, :language)
end
def render_missing_message_error
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
end
def render_failed_template_creation(result)
whatsapp_error = parse_whatsapp_error(result[:response_body])
error_message = whatsapp_error[:user_message] || result[:error]
render json: {
error: error_message,
details: whatsapp_error[:technical_details]
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
begin
error_data = JSON.parse(response_body)
whatsapp_error = error_data['error'] || {}
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
technical_details = {
code: whatsapp_error['code'],
subcode: whatsapp_error['error_subcode'],
type: whatsapp_error['type'],
title: whatsapp_error['error_user_title']
}.compact
{ user_message: user_message, technical_details: technical_details }
rescue JSON::ParserError
{ user_message: nil, technical_details: response_body }
end
end
end
@@ -152,37 +152,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def format_csat_config(config)
formatted = {
'display_type' => config['display_type'] || 'emoji',
'message' => config['message'] || '',
:survey_rules => {
'operator' => config.dig('survey_rules', 'operator') || 'contains',
'values' => config.dig('survey_rules', 'values') || []
},
'button_text' => config['button_text'] || 'Please rate us',
'language' => config['language'] || 'en'
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
}
format_template_config(config, formatted)
formatted
end
def format_template_config(config, formatted)
formatted['template'] = config['template'] if config['template'].present?
end
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
end
def permitted_params(channel_attributes = [])
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
params.permit(
*inbox_attributes,
channel: [:type, *channel_attributes]
)
end
def channel_type_from_params
@@ -198,7 +192,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
end
def whatsapp_channel?
@@ -92,8 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled
@@ -1,26 +0,0 @@
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
def show
year = params[:year] || 2025
cache_key = "year_in_review_#{Current.account.id}_#{year}"
cached_data = Current.user.ui_settings&.dig(cache_key)
if cached_data.present?
render json: cached_data
else
builder = YearInReviewBuilder.new(
account: Current.account,
user_id: Current.user.id,
year: year
)
data = builder.build
ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings)
render json: data
end
end
end
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class YearInReviewAPI extends ApiClient {
constructor() {
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
}
get(year) {
return axios.get(`${this.url}`, {
params: { year },
});
}
}
export default new YearInReviewAPI();
@@ -24,7 +24,6 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -107,7 +106,6 @@ watch(
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -9,8 +9,6 @@ import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
@@ -228,8 +226,6 @@ const keyboardEvents = {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -164,20 +163,6 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents);
const onPaste = e => {
if (!props.isEmailOrWebWidgetInbox) return;
const files = e.clipboardData?.files;
if (!files?.length) return;
Array.from(files).forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
});
};
useEventListener(document, 'paste', onPaste);
</script>
<template>
@@ -6,7 +6,6 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -87,12 +86,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
@@ -200,7 +193,6 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
@@ -210,27 +202,21 @@ const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
state.message = removeSignature(state.message, props.messageSignature);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -241,16 +227,12 @@ const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
inboxChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
@@ -374,10 +356,10 @@ const shouldShowMessageEditor = computed(() => {
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
@@ -1,49 +1,122 @@
<script setup>
import { computed } from 'vue';
import { ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
const props = defineProps({
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
const state = ref({
hasSlashCommand: false,
showMentions: false,
mentionSearchKey: '',
});
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, props.messageSignature)
: '';
// Check if message starts with slash
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Update slash command and mentions state
state.value = {
...state.value,
hasSlashCommand: startsWithSlash,
showMentions: startsWithSlash,
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
};
},
{ immediate: true }
);
const hideMention = () => {
state.value.showMentions = false;
};
const replaceText = async message => {
// Only append signature on replace if sendWithSignature is true
const finalMessage = props.sendWithSignature
? appendSignature(message, props.messageSignature, props.channelType)
: message;
await nextTick();
modelValue.value = finalMessage;
};
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<Editor
:key="editorKey"
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
<template v-if="isEmailOrWebWidgetInbox">
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
auto-height
allow-signature
:signature="messageSignature"
:send-with-signature="sendWithSignature"
>
<CannedResponse
v-if="state.showMentions && state.hasSlashCommand"
v-on-clickaway="hideMention"
class="normal-editor__canned-box"
:search-key="state.mentionSearchKey"
@replace="replaceText"
/>
</TextArea>
</template>
</div>
</template>
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -10,8 +9,6 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
};
@@ -38,7 +35,7 @@ const onClick = () => {
v-for="(document, index) in documentsList.slice(0, 5)"
:id="document.id"
:key="`document-${index}`"
:name="replaceInstallationName(document.name)"
:name="document.name"
:assistant="document.assistant"
:external-link="document.external_link"
:created-at="document.created_at"
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
@@ -27,7 +26,6 @@ const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
@@ -65,8 +63,8 @@ const onClearFilters = () => {
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
:key="`response-${index}`"
:question="replaceInstallationName(response.question)"
:answer="replaceInstallationName(response.answer)"
:question="response.question"
:answer="response.answer"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
@@ -9,14 +9,11 @@ import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -99,15 +96,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -635,14 +623,14 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="onComposeOpen(toggle)"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -663,7 +651,6 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,14 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -25,8 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const route = useRoute();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -36,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -70,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -192,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -1,239 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { toPng } from 'html-to-image';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
slideElement: {
type: Object,
default: null,
},
slideBackground: {
type: String,
required: true,
},
year: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const isGenerating = ref(false);
const shareImageUrl = ref(null);
const generateImage = async () => {
if (!props.slideElement) return;
isGenerating.value = true;
try {
let slideElement = props.slideElement;
if (slideElement && '$el' in slideElement) {
slideElement = slideElement.$el;
}
if (!slideElement) {
// eslint-disable-next-line no-console
console.error('No slide element found');
return;
}
const colorMap = {
'bg-[#5BD58A]': '#5BD58A',
'bg-[#60a5fa]': '#60a5fa',
'bg-[#fb923c]': '#fb923c',
'bg-[#f87171]': '#f87171',
'bg-[#fbbf24]': '#fbbf24',
};
const bgColor = colorMap[props.slideBackground] || '#ffffff';
const dataUrl = await toPng(slideElement, {
pixelRatio: 1.2,
backgroundColor: bgColor,
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
fontEmbedCSS: '',
cacheBust: true,
});
const img = new Image();
img.src = dataUrl;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const finalCanvas = document.createElement('canvas');
const borderSize = 20;
const bottomPadding = 50;
finalCanvas.width = img.width + borderSize * 2;
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
const ctx = finalCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
ctx.drawImage(img, borderSize, borderSize);
ctx.fillStyle = '#1f2d3d';
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
borderSize,
img.height + borderSize + 35
);
const logo = new Image();
logo.src = '/brand-assets/logo.svg';
await new Promise(resolve => {
logo.onload = resolve;
});
const logoHeight = 30;
const logoWidth = (logo.width / logo.height) * logoHeight;
const logoX = finalCanvas.width - borderSize - logoWidth;
const logoY = img.height + borderSize + 15;
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
shareImageUrl.value = finalCanvas.toDataURL('image/png');
} catch (err) {
// Handle errors silently for now
// eslint-disable-next-line no-console
console.error('Failed to generate image:', err);
} finally {
isGenerating.value = false;
}
};
const downloadImage = () => {
if (!shareImageUrl.value) return;
const link = document.createElement('a');
link.href = shareImageUrl.value;
link.download = `chatwoot-year-in-review-${props.year}.png`;
link.click();
};
const shareImage = async () => {
if (!shareImageUrl.value) return;
try {
const response = await fetch(shareImageUrl.value);
const blob = await response.blob();
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
type: 'image/png',
});
if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
year: props.year,
}),
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
files: [file],
});
return;
}
downloadImage();
} catch (err) {
// Fallback to download if sharing fails
downloadImage();
}
};
const close = () => {
shareImageUrl.value = null;
emit('close');
};
const handleOpen = async () => {
if (props.show && !shareImageUrl.value) {
await generateImage();
}
};
defineExpose({ handleOpen });
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
@click="close"
>
<div v-if="isGenerating" class="flex items-center justify-center">
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
/>
<p class="mt-4 text-sm text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
</p>
</div>
</div>
<div
v-else-if="shareImageUrl"
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
@click.stop
>
<div class="flex items-center justify-between">
<h3 class="text-xl font-medium text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
</h3>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
<div>
<img
:src="shareImageUrl"
alt="Year in Review"
class="w-full h-auto"
/>
</div>
<div class="flex gap-3">
<button
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="downloadImage"
>
<i class="i-lucide-download w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
}}</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareImage"
>
<i class="i-lucide-share-2 w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,93 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import YearInReviewModal from './YearInReviewModal.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const yearInReviewBannerImage =
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const showModal = ref(false);
const modalRef = ref(null);
const currentYear = 2025;
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const bannerClosedKey = computed(() => {
const accountId = getters.getCurrentAccountId.value;
return `yir_closed_${accountId}_${currentYear}`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const shouldShowBanner = computed(
() =>
route.query['year-in-review'] === 'true' &&
!isBannerClosed.value &&
!isACustomBrandedInstance.value
);
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
const closeBanner = event => {
event.stopPropagation();
updateUISettings({ [bannerClosedKey.value]: true });
};
</script>
<template>
<div v-if="shouldShowBanner" class="relative">
<div
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
@click="openModal"
>
<div class="flex items-start justify-between gap-2 mb-3">
<span
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
>
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
</span>
<button
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
@click="closeBanner"
>
<Icon
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
/>
</button>
</div>
<div class="flex flex-col gap-3">
<img
:src="yearInReviewBannerImage"
alt="Year in Review"
class="w-full h-auto rounded"
/>
<button
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
@click.stop="openModal"
>
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
</button>
</div>
</div>
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
</div>
</template>
@@ -1,384 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import YearInReviewAPI from 'dashboard/api/yearInReview';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
import PersonalitySlide from './slides/PersonalitySlide.vue';
import ThankYouSlide from './slides/ThankYouSlide.vue';
import ShareModal from './ShareModal.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const getters = useStoreGetters();
const isOpen = ref(false);
const currentSlide = ref(0);
const yearData = ref(null);
const isLoading = ref(false);
const error = ref(null);
const slideRefs = ref([]);
const showShareModal = ref(false);
const shareModalRef = ref(null);
const drumrollAudio = ref(null);
const hasConversations = computed(() => {
return yearData.value?.total_conversations > 0;
});
const totalSlides = computed(() => {
if (!hasConversations.value) {
return 3;
}
return 5;
});
const slideIndexMap = computed(() => {
if (!hasConversations.value) {
return [0, 1, 4];
}
return [0, 1, 2, 3, 4];
});
const currentVisualSlide = computed(() => {
return slideIndexMap.value.indexOf(currentSlide.value);
});
const slideBackgrounds = [
'bg-[#5BD58A]',
'bg-[#60a5fa]',
'bg-[#fb923c]',
'bg-[#f87171]',
'bg-[#fbbf24]',
];
const playDrumroll = () => {
try {
if (!drumrollAudio.value) {
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
drumrollAudio.value.volume = 0.5;
}
drumrollAudio.value.currentTime = 0;
drumrollAudio.value.play().catch(err => {
// eslint-disable-next-line no-console
console.log('Could not play drumroll:', err);
});
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error playing drumroll:', err);
}
};
const fetchYearInReviewData = async () => {
const year = 2025;
const accountId = getters.getCurrentAccountId.value;
const cacheKey = `year_in_review_${accountId}_${year}`;
const cachedData = uiSettings.value?.[cacheKey];
if (cachedData) {
yearData.value = cachedData;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await YearInReviewAPI.get(year);
yearData.value = response.data;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
const nextSlide = () => {
if (currentSlide.value < 4) {
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
currentSlide.value += 1;
}
}
};
const previousSlide = () => {
if (currentSlide.value > 0) {
if (!hasConversations.value && currentSlide.value === 4) {
currentSlide.value = 1;
} else {
currentSlide.value -= 1;
}
}
};
const goToSlide = visualIndex => {
currentSlide.value = slideIndexMap.value[visualIndex];
};
const close = () => {
currentSlide.value = 0;
isOpen.value = false;
yearData.value = null;
isLoading.value = false;
error.value = null;
emit('close');
};
const open = () => {
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
shareModalRef.value.handleOpen();
}
});
};
const closeShareModal = () => {
showShareModal.value = false;
};
const keyboardEvents = {
Escape: { action: close },
ArrowLeft: { action: previousSlide },
ArrowRight: { action: nextSlide },
};
useKeyboardEvents(keyboardEvents);
defineExpose({ open, close });
watch(
() => props.show,
newValue => {
if (newValue) {
open();
}
}
);
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
>
<div class="relative w-full h-full overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
/>
<p class="mt-4 text-sm text-n-slate-11">
{{ t('YEAR_IN_REVIEW.LOADING') }}
</p>
</div>
</div>
<div
v-else-if="error"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<p class="text-lg font-semibold text-red-600">
{{ t('YEAR_IN_REVIEW.ERROR') }}
</p>
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
<button
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="close"
>
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.CLOSE')
}}</span>
</button>
</div>
</div>
<div
v-else-if="yearData"
class="relative w-full h-full"
:class="currentSlideBackground"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<IntroSlide
v-if="currentSlide === 0"
:key="0"
:ref="el => (slideRefs[0] = el)"
:year="yearData.year"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ConversationsSlide
v-if="currentSlide === 1"
:key="1"
:ref="el => (slideRefs[1] = el)"
:total-conversations="yearData.total_conversations"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<BusiestDaySlide
v-if="
currentSlide === 2 && hasConversations && yearData.busiest_day
"
:key="2"
:ref="el => (slideRefs[2] = el)"
:busiest-day="yearData.busiest_day"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<PersonalitySlide
v-if="
currentSlide === 3 &&
hasConversations &&
yearData.support_personality
"
:key="3"
:ref="el => (slideRefs[3] = el)"
:support-personality="yearData.support_personality"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ThankYouSlide
v-if="currentSlide === 4"
:key="4"
:ref="el => (slideRefs[4] = el)"
:year="yearData.year"
/>
</Transition>
<div
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
>
<button
v-if="currentSlide > 0"
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="previousSlide"
>
<i class="i-lucide-chevron-left w-5 h-5" />
<span class="text-sm font-medium">
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
</span>
</button>
<div v-else class="w-20" />
<div class="flex gap-2">
<button
v-for="index in totalSlides"
:key="index"
class="w-2 h-2 rounded-full transition-all"
:class="
currentVisualSlide === index - 1
? 'bg-white w-8'
: 'bg-white bg-opacity-50'
"
@click="goToSlide(index - 1)"
/>
</div>
<button
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
@click="nextSlide"
>
<span
v-if="currentVisualSlide < totalSlides - 1"
class="text-sm font-medium"
>
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
</span>
<i
v-if="currentVisualSlide < totalSlides - 1"
class="i-lucide-chevron-right w-5 h-5"
/>
</button>
</div>
<button
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareCurrentSlide"
>
<i class="i-lucide-share-2 w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
}}</span>
</button>
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
</div>
</div>
<ShareModal
ref="shareModalRef"
:show="showShareModal"
:slide-element="slideRefs[currentSlide]"
:slide-background="currentSlideBackground"
:year="yearData?.year"
@close="closeShareModal"
/>
</Teleport>
</template>
@@ -1,76 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
busiestDay: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const coffeeImage =
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const performanceHelperText = computed(() => {
const count = props.busiestDay.count;
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-4 w-full max-w-3xl">
<div class="flex items-center justify-center flex-1">
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
<div class="text-white flex gap-2 flex-col">
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
{{ busiestDay.date }}
</div>
</div>
<img
:src="coffeeImage"
alt="Coffee"
class="w-auto h-32 md:h-56 lg:h-72"
/>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex items-center justify-center gap-3 md:gap-8">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<div class="flex-1">
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
count: busiestDay.count,
})
}}
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
totalConversations: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const cloudImage =
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const hasData = computed(() => {
return props.totalConversations > 0;
});
const formatNumber = num => {
if (num >= 100000) {
return '100k+';
}
return new Intl.NumberFormat().format(num);
};
const performanceHelperText = computed(() => {
if (!hasData.value) {
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
}
const count = props.totalConversations;
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
if (count <= 2000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
if (count <= 10000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
});
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div
class="flex flex-col gap-16"
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
>
<div class="flex items-center justify-center flex-1">
<div
class="flex items-center justify-between gap-6 flex-1"
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
>
<div class="text-white flex gap-3 flex-col">
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
{{ formatNumber(totalConversations) }}
</div>
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
</div>
</div>
<img
:src="cloudImage"
alt="Cloud"
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
/>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const candlesImagePath =
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
const { t } = useI18n();
</script>
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
:style="{
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
}"
>
<div class="text-center max-w-3xl">
<h1
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ year }}
</h1>
<h2
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.TITLE') }}
</h2>
</div>
<img
:src="candlesImagePath"
alt="Candles"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
/>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportPersonality: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const clockImage =
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const formatResponseTime = seconds => {
if (seconds < 60) {
return 'less than a minute';
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return hours === 1 ? '1 hour' : `${hours} hours`;
}
return 'more than a day';
};
const personality = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const minutes = seconds / 60;
if (minutes < 2) {
return 'Swift Helper';
}
if (minutes < 5) {
return 'Quick Responder';
}
if (minutes < 15) {
return 'Steady Support';
}
return 'Thoughtful Advisor';
});
const personalityMessage = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const time = formatResponseTime(seconds);
const personalityKeyMap = {
'Swift Helper': 'SWIFT_HELPER',
'Quick Responder': 'QUICK_RESPONDER',
'Steady Support': 'STEADY_SUPPORT',
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
};
const key = personalityKeyMap[personality.value];
if (!key) return '';
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-9 max-w-3xl">
<div class="mb-4 md:mb-6">
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
<div class="flex items-center justify-start flex-1 mt-9">
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
<div class="text-2xl md:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
</div>
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
{{ personality }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ personalityMessage }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const signatureImage =
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div class="flex flex-col items-start max-w-4xl">
<div
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
</div>
<div
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
}}
</div>
<div class="mt-12">
<img
:src="signatureImage"
alt="Chatwoot Team Signature"
class="w-auto h-8 md:h-10"
/>
</div>
</div>
</div>
</template>
@@ -54,7 +54,6 @@ import {
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -82,7 +81,6 @@ const props = defineProps({
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
focusOnMount: { type: Boolean, default: true },
});
@@ -107,16 +105,10 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
);
const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value;
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
@@ -124,7 +116,7 @@ const editorSchema = computed(() => {
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
: props.channelType || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
@@ -309,13 +301,8 @@ function isBodyEmpty(content) {
// if the signature is present, we need to remove it before checking
// note that we don't update the editorView, so this is safe
// Use effective channel type to match how signature was appended
const bodyWithoutSignature = props.signature
? removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
)
? removeSignatureHelper(content, props.signature)
: content;
// trimming should remove all the whitespaces, so we can check the length
@@ -383,11 +370,7 @@ function addSignature() {
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
const contentWasEmpty = isBodyEmpty(content);
content = appendSignature(
content,
props.signature,
effectiveChannelType.value
);
content = appendSignature(content, props.signature, props.channelType);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -399,11 +382,7 @@ function addSignature() {
function removeSignature() {
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
content,
props.signature,
effectiveChannelType.value
);
content = removeSignatureHelper(content, props.signature);
// reload the state, ensuring that the editorView is updated
reloadState(content);
}
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -72,8 +68,6 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -114,8 +108,6 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -144,12 +136,9 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
return this.$store.getters['contacts/getContact'](
this.currentChat.meta.sender.id
);
},
shouldShowReplyToMessage() {
return (
@@ -406,19 +395,6 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -461,25 +437,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -529,20 +487,14 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -611,26 +563,10 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature, this.channelType)
: removeSignature(message, this.messageSignature);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -646,7 +582,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -688,14 +623,7 @@ export default {
);
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) {
return;
}
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
@@ -829,19 +757,11 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
message = appendSignature(
message,
this.messageSignature,
this.channelType
);
}
const updatedMessage = replaceVariablesInMessage({
@@ -865,52 +785,26 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
this.message = appendSignature(
this.message,
this.messageSignature,
this.channelType
);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -945,9 +839,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1194,13 +1085,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1224,23 +1108,7 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1253,7 +1121,6 @@ export default {
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1366,10 +1233,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1389,9 +1252,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
+6 -6
View File
@@ -210,12 +210,7 @@ export const MARKDOWN_PATTERNS = [
type: 'em', // PM: em, eg: *italic* or _italic_
patterns: [
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
{
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
replacement: '$1',
},
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
],
},
{
@@ -232,6 +227,11 @@ export const MARKDOWN_PATTERNS = [
},
];
export const CHANNEL_WITH_RICH_SIGNATURE = [
'Channel::Email',
'Channel::WebWidget',
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
+62 -96
View File
@@ -5,10 +5,30 @@ import {
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import {
FORMATTING,
MARKDOWN_PATTERNS,
CHANNEL_WITH_RICH_SIGNATURE,
} from 'dashboard/constants/editor';
import camelcaseKeys from 'camelcase-keys';
/**
* Removes zero-width spaces and related invisible characters from text.
* Also handles backslash-newline patterns from hard breaks (Shift+Enter).
* These characters can interfere with signature detection and content handling.
*
* @param {string} text - The text to clean
* @returns {string} - The cleaned text without zero-width spaces
*/
export function removeZeroWidthSpaces(text) {
if (!text) return '';
return text
.replace(/\u200B\\\n/g, '\n') // Remove zero-width space + backslash + newline, keep newline
.replace(/\u200B\n/g, '\n') // Remove zero-width space + newline, keep newline
.replace(/\\\n/g, '\n') // Remove backslash before newline (hard break), keep newline
.replace(/\u200B/g, ''); // Remove standalone zero-width spaces
}
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
@@ -32,56 +52,6 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
@@ -93,6 +63,9 @@ export const SIGNATURE_DELIMITER = '--';
*/
export function cleanSignature(signature) {
try {
// Remove zero-width spaces before processing
signature = removeZeroWidthSpaces(signature);
// remove any horizontal rule tokens
signature = signature
.replace(/^( *\* *){3,} *$/gm, '')
@@ -102,14 +75,17 @@ export function cleanSignature(signature) {
const nodes = new MessageMarkdownTransformer(messageSchema).parse(
signature
);
return MessageMarkdownSerializer.serialize(nodes);
let result = MessageMarkdownSerializer.serialize(nodes);
// Remove zero-width spaces after serialization as well
return removeZeroWidthSpaces(result);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
Sentry.captureException(e);
// The parser can break on some cases
// for example, Token type `hr` not supported by Markdown parser
return signature;
return removeZeroWidthSpaces(signature);
}
}
@@ -132,7 +108,8 @@ function appendDelimiter(signature) {
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
*/
export function findSignatureInBody(body, signature) {
const trimmedBody = body.trimEnd();
// Remove zero-width spaces from body before comparison
const trimmedBody = removeZeroWidthSpaces(body).trimEnd();
const cleanedSignature = cleanSignature(signature);
// check if body ends with signature
@@ -144,80 +121,69 @@ export function findSignatureInBody(body, signature) {
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
* Checks if the channel supports image signatures.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
* @param {string} channelType - The channel type.
* @returns {boolean} - True if the channel supports image signatures.
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
export function supportsImageSignature(channelType) {
return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType);
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
* Automatically strips images for channels that don't support image signatures.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @param {string} channelType - Optional. The channel type to determine if images should be stripped.
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
// Remove zero-width spaces from body before processing
const cleanedBody = removeZeroWidthSpaces(body);
// For channels that don't support images, strip markdown formatting
const shouldStripImages = channelType && !supportsImageSignature(channelType);
const preparedSignature = shouldStripImages
? extractTextFromMarkdown(signature)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
if (findSignatureInBody(cleanedBody, cleanedSignature) > -1) {
return cleanedBody;
}
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
return `${cleanedBody.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
}
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
* Tries to find both the original signature and the stripped version (for non-image channels).
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
export function removeSignature(body, signature) {
// Remove zero-width spaces from body before processing
let newBody = removeZeroWidthSpaces(body);
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
channelStripped,
fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Build list of signatures to try: original first, then stripped version
// Always try both to handle cases where channelType is unknown or inbox is being removed
const cleanedSignature = cleanSignature(signature);
const strippedSignature = cleanSignature(extractTextFromMarkdown(signature));
const signaturesToTry =
cleanedSignature === strippedSignature
? [cleanedSignature]
: [cleanedSignature, strippedSignature];
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
(index, sig) => (index === -1 ? findSignatureInBody(newBody, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
// if signature is present, remove it and trim it
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
-5
View File
@@ -13,11 +13,6 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -5,7 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
supportsImageSignature,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -145,63 +145,10 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
const strippedSignature = 'Thanks';
it('keeps images for Email channel', () => {
const result = appendSignature(
@@ -219,31 +166,24 @@ describe('appendSignature with channelType', () => {
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
it('strips images for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
expect(result).toContain(strippedSignature);
});
it('strips images but keeps text for WhatsApp channel', () => {
it('strips images for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
expect(result).toContain(strippedSignature);
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
@@ -391,6 +331,24 @@ describe('extractTextFromMarkdown', () => {
});
});
describe('supportsImageSignature', () => {
it('returns true for Email channel', () => {
expect(supportsImageSignature('Channel::Email')).toBe(true);
});
it('returns true for WebWidget channel', () => {
expect(supportsImageSignature('Channel::WebWidget')).toBe(true);
});
it('returns false for Api channel', () => {
expect(supportsImageSignature('Channel::Api')).toBe(false);
});
it('returns false for WhatsApp channel', () => {
expect(supportsImageSignature('Channel::Whatsapp')).toBe(false);
});
it('returns false for Telegram channel', () => {
expect(supportsImageSignature('Channel::Telegram')).toBe(false);
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
@@ -926,26 +884,6 @@ describe('stripUnsupportedFormatting', () => {
);
});
it('preserves underscores in URLs and mid-word positions', () => {
// Underscores in URLs should not be stripped as italic formatting
expect(
stripUnsupportedFormatting(
'https://www.chatwoot.com/new_first_second-third/ssd',
emptySchema
)
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
// Underscores in variable names should not be stripped
expect(
stripUnsupportedFormatting('some_variable_name', emptySchema)
).toBe('some_variable_name');
// But actual italic formatting with spaces should still be stripped
expect(
stripUnsupportedFormatting('hello _world_ there', emptySchema)
).toBe('hello world there');
});
it('strips inline code formatting', () => {
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
'inline code'
@@ -38,7 +38,6 @@ import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
@@ -81,5 +80,4 @@ export default {
...whatsappTemplates,
...contentTemplates,
...mfa,
...yearInReview,
};
@@ -234,7 +234,6 @@
"CONTACT_SUPPORT": "Contact support",
"SELECTOR_SUBTITLE": "Select an account from the following list",
"PROFILE_SETTINGS": "Profile settings",
"YEAR_IN_REVIEW": "Year in Review",
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
"APPEARANCE": "Change appearance",
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
@@ -1,64 +0,0 @@
{
"YEAR_IN_REVIEW": {
"TITLE": "Year in Review",
"LOADING": "Loading your year in review...",
"ERROR": "Failed to load year in review",
"CLOSE": "Close",
"CONVERSATIONS": {
"TITLE": "You have handled",
"SUBTITLE": "conversations",
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
"COMPARISON": {
"0_50": "You showed up, and that's how every good inbox begins.",
"50_100": "You kept the replies flowing and the conversations alive.",
"100_500": "You handled serious volume and kept everything on track.",
"500_2000": "You kept things moving while the volume kept climbing.",
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
}
},
"BUSIEST_DAY": {
"TITLE": "Your busiest day was",
"MESSAGE": "{count} conversations that day.",
"COMPARISON": {
"0_5": "A warm-up lap that barely woke the inbox.",
"5_10": "Enough action to justify a second cup of coffee.",
"10_25": "Things got busy and the inbox stayed on its toes.",
"25_50": "A proper rush that barely broke a sweat.",
"50_100": "Controlled chaos, handled like a normal Tuesday.",
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
"500_PLUS": "The inbox lost all chill and never slowed down."
}
},
"PERSONALITY": {
"TITLE": "Your support personality is",
"MESSAGES": {
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
}
},
"THANK_YOU": {
"TITLE": "Congratulations on surviving the inbox of {year}.",
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
},
"SHARE_MODAL": {
"TITLE": "Share Your Year in Review",
"PREPARING": "Preparing your image...",
"DOWNLOAD": "Download",
"SHARE_TITLE": "My {year} Year in Review",
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
"BRANDING": "Made with Chatwoot"
},
"BANNER": {
"TITLE": "Your {year} Year in Review is here",
"BUTTON": "See your impact"
},
"NAVIGATION": {
"PREVIOUS": "Previous",
"NEXT": "Next",
"SHARE": "Share"
}
}
}
@@ -111,15 +111,10 @@ export default {
// watcher, this means that if the value is true, the signature
// is supposed to be added, else we remove it.
toggleSignatureInEditor(signatureEnabled) {
let valueWithSignature = signatureEnabled
const valueWithSignature = signatureEnabled
? appendSignature(this.modelValue, this.cleanedSignature)
: removeSignature(this.modelValue, this.cleanedSignature);
// Clean up whitespace when removing signature from empty body
if (!signatureEnabled && !valueWithSignature.trim()) {
valueWithSignature = '';
}
this.$emit('update:modelValue', valueWithSignature);
this.$emit('input', valueWithSignature);
+2 -6
View File
@@ -37,11 +37,7 @@ class Account < ApplicationRecord
'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
}
'auto_resolve_label': { 'type': %w[string null] }
},
'required': [],
'additionalProperties': true
@@ -59,7 +55,7 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
@@ -45,7 +45,6 @@ class CustomAttributeDefinition < ApplicationRecord
belongs_to :account
after_update :update_widget_pre_chat_custom_fields
after_destroy :sync_widget_pre_chat_custom_fields
after_destroy :cleanup_conversation_required_attributes
private
@@ -57,13 +56,6 @@ class CustomAttributeDefinition < ApplicationRecord
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def cleanup_conversation_required_attributes
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
account.save!
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
-15
View File
@@ -254,21 +254,6 @@ class Message < ApplicationRecord
Messages::SearchDataPresenter.new(self).search_data
end
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[Attachment]' if attachments.any?
end
private
def prevent_message_flooding
@@ -48,7 +48,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
'Bot'
end
sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content_for_llm}\n"
"#{sender}: #{message.content}\n"
end
def build_attributes
@@ -1,14 +1,6 @@
class MessageTemplates::Template::OutOfOffice
pattr_initialize [:conversation!]
def self.perform_if_applicable(conversation)
inbox = conversation.inbox
return unless inbox.out_of_office?
return if inbox.out_of_office_message.blank?
new(conversation: conversation).perform
end
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(out_of_office_message_params)
@@ -96,10 +96,10 @@ class Messages::MarkdownRendererService
restore_multiple_newlines(result)
end
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
# Preserve multiple consecutive newlines (3+) by replacing them with placeholders
# Standard markdown treats 2 newlines as paragraph break, we preserve 3+
def preserve_multiple_newlines(content)
content.gsub(/\n{2,}/) do |match|
content.gsub(/\n{3,}/) do |match|
"{{PRESERVE_#{match.length}_NEWLINES}}"
end
end
@@ -1,49 +0,0 @@
class Whatsapp::CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
def self.csat_template_name(inbox_id, version = nil)
base_name = csat_base_name_for_inbox(inbox_id)
version ? "#{base_name}_#{version}" : base_name
end
def self.extract_version(template_name, inbox_id)
return nil if template_name.blank?
pattern = versioned_pattern_for_inbox(inbox_id)
match = template_name.match(pattern)
match ? match[1].to_i : nil
end
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
return base_name if current_template_name.blank?
current_version = extract_version(current_template_name, inbox_id)
next_version = current_version ? current_version + 1 : 1
csat_template_name(inbox_id, next_version)
end
def self.matches_csat_pattern?(template_name, inbox_id)
return false if template_name.blank?
base_pattern = base_pattern_for_inbox(inbox_id)
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
end
def self.csat_base_name_for_inbox(inbox_id)
"#{CSAT_BASE_NAME}_#{inbox_id}"
end
def self.base_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
end
def self.versioned_pattern_for_inbox(inbox_id)
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
end
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
end
@@ -1,139 +0,0 @@
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_STATUS_PENDING = 'PENDING'.freeze
def initialize(whatsapp_channel)
@whatsapp_channel = whatsapp_channel
end
def create_template(template_config)
base_name = template_config[:template_name]
template_name = generate_template_name(base_name)
template_config_with_name = template_config.merge(template_name: template_name)
request_body = build_template_request_body(template_config_with_name)
response = send_template_creation_request(request_body)
process_template_creation_response(response, template_config_with_name)
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
)
{ success: response.success?, response_body: response.body }
end
def get_template_status(template_name)
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
if response.success? && response['data']&.any?
template_data = response['data'].first
{
success: true,
template: {
id: template_data['id'], name: template_data['name'],
status: template_data['status'], language: template_data['language']
}
}
else
{ success: false, error: 'Template not found' }
end
rescue StandardError => e
Rails.logger.error "Error fetching template status: #{e.message}"
{ success: false, error: e.message }
end
private
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
end
def build_template_request_body(template_config)
{
name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
category: TEMPLATE_CATEGORY,
components: build_template_components(template_config)
}
end
def build_template_components(template_config)
[
build_body_component(template_config[:message]),
build_buttons_component(template_config)
]
end
def build_body_component(message)
{
type: 'BODY',
text: message
}
end
def build_buttons_component(template_config)
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
example: ['12345']
}
]
}
end
def send_template_creation_request(request_body)
HTTParty.post(
"#{business_account_path}/message_templates",
headers: api_headers,
body: request_body.to_json
)
end
def process_template_creation_response(response, template_config = {})
if response.success?
{
success: true,
template_id: response['id'],
template_name: response['name'] || template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def business_account_path
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
end
def api_headers
{
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
'Content-Type' => 'application/json'
}
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
end
@@ -62,31 +62,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
end
def create_csat_template(template_config)
csat_template_service.create_template(template_config)
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
def get_template_status(template_name)
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
end
private
def csat_template_service
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
end
def api_base_path
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
end
+2 -2
View File
@@ -4,8 +4,8 @@ require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
if api_key.present?
Agents.configure do |config|
+7 -12
View File
@@ -203,15 +203,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
delete :destroy
@@ -425,7 +417,6 @@ Rails.application.routes.draw do
get :bot_metrics
end
end
resource :year_in_review, only: [:show]
resources :live_reports, only: [] do
collection do
get :conversation_metrics
@@ -557,9 +548,13 @@ Rails.application.routes.draw do
resources :delivery_status, only: [:create]
if ChatwootApp.enterprise?
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
resource :voice, only: [], controller: 'voice' do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
post 'conference_status/:phone', action: :conference_status
end
end
end
end
@@ -1,58 +0,0 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
def token
render json: Voice::Provider::Twilio::TokenService.new(
inbox: @voice_inbox,
user: Current.user,
account: Current.account
).generate
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @voice_inbox, :show?
end
def fetch_conversation_by_display_id
cid = params[:conversation_id]
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
authorize conversation, :show?
conversation
end
end
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
def search_articles
if @portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
@articles = @articles.vector_search(list_params) if list_params[:query].present?
else
super
end
@@ -151,8 +151,9 @@ class Twilio::VoiceController < ApplicationController
end
def conference_status_callback_url
host = ENV.fetch('FRONTEND_URL', '')
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
"#{host}/twilio/voice/conference_status/#{phone_digits}"
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
@@ -21,18 +21,20 @@ module Captain::ChatHelper
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
llm_chat
end
def setup_tools(llm_chat)
def setup_tools(chat)
@tools&.each do |tool|
llm_chat = llm_chat.with_tool(tool)
chat.with_tool(tool)
end
llm_chat
chat
end
def setup_system_instructions(chat)
@@ -87,15 +87,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
I18n.with_locale(@assistant.account.locale) do
create_handoff_message
@conversation.bot_handoff!
send_out_of_office_message_if_applicable
end
end
end
def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end
def create_handoff_message
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def generate_standard_faqs(document)
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
end
def build_paginated_service(document, options)
@@ -2,8 +2,7 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
account_id = record.account_id
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
record.update!(embedding: embedding)
end
end
@@ -19,8 +19,6 @@ class ArticleEmbedding < ApplicationRecord
after_commit :update_response_embedding
delegate :account_id, to: :article
private
def update_response_embedding
@@ -44,8 +44,8 @@ class Captain::AssistantResponse < ApplicationRecord
enum status: { pending: 0, approved: 1 }
def self.search(query, account_id: nil)
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
def self.search(query)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
end
+6 -7
View File
@@ -42,13 +42,11 @@ class Channel::Voice < ApplicationRecord
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
def initiate_call(to:)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
Voice::Provider::TwilioAdapter.new(self).initiate_call(
to: to
)
else
raise "Unsupported voice provider: #{provider}"
@@ -58,12 +56,12 @@ class Channel::Voice < ApplicationRecord
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
end
private
@@ -89,6 +87,7 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
def provider_config_hash
if provider_config.is_a?(Hash)
+1 -1
View File
@@ -43,7 +43,7 @@ module Concerns::Agentable
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
end
def agent_response_schema
@@ -11,7 +11,7 @@ module Enterprise::Concerns::Article
add_article_embedding_association
def self.vector_search(params)
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
records = joins(
:category
).search_by_category_slug(
@@ -1,5 +1,4 @@
class Captain::Llm::ContactAttributesService < Llm::BaseAiService
include Integrations::LlmInstrumentation
class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -18,38 +17,33 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService
attr_reader :content
def generate_attributes
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
[]
end
def instrumentation_params
def chat_parameters
prompt = Captain::Llm::SystemPromptsService.attributes_generator
{
span_name: 'llm.captain.contact_attributes',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
feature_name: 'contact_attributes',
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
}
end
def system_prompt
Captain::Llm::SystemPromptsService.attributes_generator
end
def parse_response(content)
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
JSON.parse(content.strip).fetch('attributes', [])
@@ -1,5 +1,4 @@
class Captain::Llm::ContactNotesService < Llm::BaseAiService
include Integrations::LlmInstrumentation
class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
def initialize(assistant, conversation)
super()
@assistant = assistant
@@ -19,42 +18,38 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService
attr_reader :content
def generate_notes
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
[]
end
def instrumentation_params
def chat_parameters
account_language = @conversation.account.locale_english_name
prompt = Captain::Llm::SystemPromptsService.notes_generator(account_language)
{
span_name: 'llm.captain.contact_notes',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
feature_name: 'contact_notes',
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
}
end
def system_prompt
account_language = @conversation.account.locale_english_name
Captain::Llm::SystemPromptsService.notes_generator(account_language)
end
def parse_response(response)
return [] if response.nil?
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
JSON.parse(response.strip).fetch('notes', [])
JSON.parse(content.strip).fetch('notes', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
@@ -1,5 +1,4 @@
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
include Integrations::LlmInstrumentation
class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
DISTANCE_THRESHOLD = 0.3
def initialize(assistant, conversation)
@@ -36,7 +35,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
faqs.each do |faq|
combined_text = "#{faq['question']}: #{faq['answer']}"
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(combined_text)
similar_faqs = find_similar_faqs(embedding)
if similar_faqs.any?
@@ -82,43 +81,38 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
end
def generate
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "LLM API Error: #{e.message}"
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
[]
end
def instrumentation_params
def chat_parameters
account_language = @conversation.account.locale_english_name
prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
{
span_name: 'llm.captain.conversation_faq',
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.id,
feature_name: 'conversation_faq',
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
],
metadata: { assistant_id: @assistant.id }
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
}
end
def system_prompt
account_language = @conversation.account.locale_english_name
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
end
def parse_response(response)
return [] if response.nil?
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
JSON.parse(response.strip).fetch('faqs', [])
JSON.parse(content.strip).fetch('faqs', [])
rescue JSON::ParserError => e
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
[]
@@ -1,38 +1,22 @@
class Captain::Llm::EmbeddingService
include Integrations::LlmInstrumentation
require 'openai'
class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
class EmbeddingsError < StandardError; end
def initialize(account_id: nil)
Llm::Config.initialize!
@account_id = account_id
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
end
def self.embedding_model
InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || OpenAiConstants::DEFAULT_EMBEDDING_MODEL
end
def get_embedding(content, model: @embedding_model)
return [] if content.blank?
def get_embedding(content, model: self.class.embedding_model)
response = @client.embeddings(
parameters: {
model: model,
input: content
}
)
instrument_embedding_call(instrumentation_params(content, model)) do
RubyLLM.embed(content, model: model).vectors
end
rescue RubyLLM::Error => e
Rails.logger.error "Embedding API Error: #{e.message}"
response.dig('data', 0, 'embedding')
rescue StandardError => e
raise EmbeddingsError, "Failed to create an embedding: #{e.message}"
end
private
def instrumentation_params(content, model)
{
span_name: 'llm.captain.embedding',
model: model,
input: content,
feature_name: 'embedding',
account_id: @account_id
}
end
end
@@ -1,24 +1,15 @@
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
include Integrations::LlmInstrumentation
def initialize(content, language = 'english', account_id: nil)
class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
def initialize(content, language = 'english')
super()
@language = language
@content = content
@account_id = account_id
end
def generate
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' })
.with_instructions(system_prompt)
.ask(@content)
end
parse_response(response.content)
rescue RubyLLM::Error => e
Rails.logger.error "LLM API Error: #{e.message}"
response = @client.chat(parameters: chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error "OpenAI API Error: #{e.message}"
[]
end
@@ -26,25 +17,26 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
attr_reader :content, :language
def system_prompt
Captain::Llm::SystemPromptsService.faq_generator(language)
end
def instrumentation_params
def chat_parameters
prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
{
span_name: 'llm.captain.faq_generator',
model: @model,
temperature: @temperature,
feature_name: 'faq_generator',
account_id: @account_id,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: @content }
{
role: 'system',
content: prompt
},
{
role: 'user',
content: content
}
]
}
end
def parse_response(content)
def parse_response(response)
content = response.dig('choices', 0, 'message', 'content')
return [] if content.nil?
JSON.parse(content.strip).fetch('faqs', [])
@@ -1,6 +1,4 @@
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
# Default pages per chunk - easily configurable
DEFAULT_PAGES_PER_CHUNK = 10
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
@@ -15,7 +13,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
@max_pages = options[:max_pages] # Optional limit from UI
@total_pages_processed = 0
@iterations_completed = 0
@model = LlmConstants::PDF_PROCESSING_MODEL
@model = OpenAiConstants::PDF_PROCESSING_MODEL
end
def generate
@@ -45,19 +43,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
private
def generate_standard_faqs
params = standard_chat_parameters
instrumentation_params = {
span_name: 'llm.faq_generation',
account_id: @document&.account_id,
feature_name: 'faq_generation',
model: @model,
messages: params[:messages]
}
response = instrument_llm_call(instrumentation_params) do
@client.chat(parameters: params)
end
response = @client.chat(parameters: standard_chat_parameters)
parse_response(response)
rescue OpenAI::Error => e
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
@@ -98,13 +84,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def process_page_chunk(start_page, end_page)
params = build_chunk_parameters(start_page, end_page)
instrumentation_params = build_instrumentation_params(params, start_page, end_page)
response = instrument_llm_call(instrumentation_params) do
@client.chat(parameters: params)
end
response = @client.chat(parameters: params)
result = parse_chunk_response(response)
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
rescue OpenAI::Error => e
@@ -200,26 +180,21 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
def similarity_score(str1, str2)
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
common_words = words1 & words2
total_words = (words1 + words2).uniq.size
return 0 if total_words.zero?
common_words.size.to_f / total_words
end
def build_instrumentation_params(params, start_page, end_page)
{
span_name: 'llm.paginated_faq_generation',
account_id: @document&.account_id,
feature_name: 'paginated_faq_generation',
model: @model,
messages: params[:messages],
metadata: {
document_id: @document&.id,
start_page: start_page,
end_page: end_page,
iteration: @iterations_completed + 1
}
}
def determine_stop_reason(last_chunk_result)
return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
return 'End of document reached' if last_chunk_result[:has_content] == false
'Unknown'
end
end
@@ -1,6 +1,4 @@
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
def initialize(document)
super()
@document = document
@@ -21,30 +19,13 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
def upload_pdf_to_openai
with_tempfile do |temp_file|
instrument_file_upload do
response = @client.files.upload(
parameters: {
file: temp_file,
purpose: 'assistants'
}
)
response['id']
end
end
end
def instrument_file_upload(&)
return yield unless ChatwootApp.otel_enabled?
tracer.in_span('llm.file.upload') do |span|
span.set_attribute('gen_ai.provider', 'openai')
span.set_attribute('file.purpose', 'assistants')
span.set_attribute(ATTR_LANGFUSE_USER_ID, document.account_id.to_s)
span.set_attribute(ATTR_LANGFUSE_TAGS, ['pdf_upload'].to_json)
span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'document_id'), document.id.to_s)
file_id = yield
span.set_attribute('file.id', file_id) if file_id
file_id
response = @client.files.upload(
parameters: {
file: temp_file,
purpose: 'assistants'
}
)
response['id']
end
end
@@ -1,5 +1,4 @@
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
include Integrations::LlmInstrumentation
class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
MAX_CONTENT_LENGTH = 8000
def initialize(website_url)
@@ -58,29 +57,19 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
end
def extract_business_info
response = instrument_llm_call(instrumentation_params) do
chat
.with_params(response_format: { type: 'json_object' }, max_tokens: 1000)
.with_temperature(0.1)
.with_instructions(build_analysis_prompt)
.ask(@website_content)
end
prompt = build_analysis_prompt
parse_llm_response(response.content)
end
response = client.chat(
parameters: {
model: model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.1,
max_tokens: 1000
}
)
def instrumentation_params
{
span_name: 'llm.captain.website_analyzer',
model: @model,
temperature: 0.1,
feature_name: 'website_analyzer',
messages: [
{ role: 'system', content: build_analysis_prompt },
{ role: 'user', content: @website_content }
],
metadata: { website_url: @website_url }
}
parse_llm_response(response.dig('choices', 0, 'message', 'content'))
end
def build_analysis_prompt
@@ -106,7 +95,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
end
def parse_llm_response(response_text)
parsed_response = JSON.parse(response_text.strip)
parsed_response = JSON.parse(response_text)
{
success: true,
@@ -9,24 +9,6 @@ module Enterprise::MessageTemplates::HookExecutionService
schedule_captain_response
end
def should_send_greeting?
return false if captain_handling_conversation?
super
end
def should_send_out_of_office_message?
return false if captain_handling_conversation?
super
end
def should_send_email_collect?
return false if captain_handling_conversation?
super
end
private
def schedule_captain_response
@@ -64,14 +46,5 @@ module Enterprise::MessageTemplates::HookExecutionService
content: 'Transferring to another agent for further assistance.'
)
conversation.bot_handoff!
send_out_of_office_message_after_handoff
end
def send_out_of_office_message_after_handoff
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end
@@ -1,61 +1,50 @@
class Internal::AccountAnalysis::ContentEvaluatorService
include Integrations::LlmInstrumentation
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
def initialize
Llm::Config.initialize!
super()
@model = 'gpt-4o-mini'.freeze
end
def evaluate(content)
return default_evaluation if content.blank?
moderation_result = instrument_moderation_call(instrumentation_params(content)) do
RubyLLM.moderate(content.to_s[0...10_000])
begin
response = send_to_llm(content)
evaluation = handle_response(response)
log_evaluation_results(evaluation)
evaluation
rescue StandardError => e
handle_evaluation_error(e)
end
build_evaluation(moderation_result)
rescue StandardError => e
handle_evaluation_error(e)
end
private
def instrumentation_params(content)
def send_to_llm(content)
Rails.logger.info('Sending content to LLM for security evaluation')
@client.chat(
parameters: {
model: @model,
messages: llm_messages(content),
response_format: { type: 'json_object' }
}
)
end
def handle_response(response)
return default_evaluation if response.nil?
parsed = JSON.parse(response.dig('choices', 0, 'message', 'content').strip)
{
span_name: 'llm.internal.content_moderation',
model: 'text-moderation-latest',
input: content,
feature_name: 'content_evaluator'
'threat_level' => parsed['threat_level'] || 'unknown',
'threat_summary' => parsed['threat_summary'] || 'No threat summary provided',
'detected_threats' => parsed['detected_threats'] || [],
'illegal_activities_detected' => parsed['illegal_activities_detected'] || false,
'recommendation' => parsed['recommendation'] || 'review'
}
end
def build_evaluation(result)
flagged = result.flagged?
categories = result.flagged_categories
evaluation = {
'threat_level' => flagged ? determine_threat_level(result) : 'safe',
'threat_summary' => flagged ? "Content flagged for: #{categories.join(', ')}" : 'No threats detected',
'detected_threats' => categories,
'illegal_activities_detected' => categories.any? { |c| c.include?('violence') || c.include?('self-harm') },
'recommendation' => flagged ? 'review' : 'approve'
}
log_evaluation_results(evaluation)
evaluation
end
def determine_threat_level(result)
scores = result.category_scores
max_score = scores.values.max || 0
case max_score
when 0.8.. then 'critical'
when 0.5..0.8 then 'high'
when 0.2..0.5 then 'medium'
else 'low'
end
end
def default_evaluation(error_type = nil)
{
'threat_level' => 'unknown',
@@ -67,11 +56,18 @@ class Internal::AccountAnalysis::ContentEvaluatorService
end
def log_evaluation_results(evaluation)
Rails.logger.info("Moderation evaluation - Level: #{evaluation['threat_level']}, Threats: #{evaluation['detected_threats'].join(', ')}")
Rails.logger.info("LLM evaluation - Level: #{evaluation['threat_level']}, Illegal activities: #{evaluation['illegal_activities_detected']}")
end
def handle_evaluation_error(error)
Rails.logger.error("Error evaluating content: #{error.message}")
default_evaluation('evaluation_failure')
end
def llm_messages(content)
[
{ role: 'system', content: 'You are a security analysis system that evaluates content for potential threats and scams.' },
{ role: 'user', content: Internal::AccountAnalysis::PromptsService.threat_analyser(content.to_s[0...10_000]) }
]
end
end
@@ -14,6 +14,8 @@ class Llm::BaseAiService
setup_temperature
end
# Returns a configured RubyLLM chat instance.
# Subclasses can override model/temperature via instance variables or pass them explicitly.
def chat(model: @model, temperature: @temperature)
RubyLLM.chat(model: model).with_temperature(temperature)
end
@@ -1,11 +1,8 @@
# frozen_string_literal: true
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
# Only used for PDF/file operations that require OpenAI's files API:
# - Captain::Llm::PdfProcessingService (files.upload for assistants)
# - Captain::Llm::PaginatedFaqGeneratorService (uses file_id from uploaded files)
#
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
# New features should use Llm::BaseAiService with RubyLLM instead.
# This class will be removed once all services are migrated to RubyLLM.
class Llm::LegacyBaseOpenAiService
DEFAULT_MODEL = 'gpt-4o-mini'
@@ -1,8 +1,4 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
attr_reader :attachment, :message, :account
def initialize(attachment)
@@ -50,7 +46,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
temp_file_path = fetch_audio_file
transcribed_text = nil
response_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
@@ -60,23 +56,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
temperature: 0.4
}
)
transcribed_text = response['text']
response_text = response['text']
end
FileUtils.rm_f(temp_file_path)
update_transcription(transcribed_text)
transcribed_text
end
def instrumentation_params(file_path)
{
span_name: 'llm.messages.audio_transcription',
model: WHISPER_MODEL,
account_id: account&.id,
feature_name: 'audio_transcription',
file_path: file_path
}
update_transcription(response_text)
response_text
end
def update_transcription(transcribed_text)
@@ -1,52 +0,0 @@
class Voice::Provider::Twilio::Adapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
call = twilio_client.calls.create(**call_params(to))
{
provider: 'twilio',
call_sid: call.sid,
status: call.status,
call_direction: 'outbound',
requires_agent_join: true,
agent_id: agent_id,
conference_sid: conference_sid
}
end
private
def call_params(to)
phone_digits = @channel.phone_number.delete_prefix('+')
{
from: @channel.phone_number,
to: to,
url: twilio_call_twiml_url(phone_digits),
status_callback: twilio_call_status_url(phone_digits),
status_callback_event: %w[
initiated ringing answered completed failed busy no-answer canceled
],
status_callback_method: 'POST'
}
end
def twilio_call_twiml_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
end
def twilio_call_status_url(phone_digits)
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
end
end
@@ -1,46 +0,0 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
end
def end_conference
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
end
end
@@ -1,62 +0,0 @@
class Voice::Provider::Twilio::TokenService
pattr_initialize [:inbox!, :user!, :account!]
def generate
{
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
end
def identity
@identity ||= "agent-#{user.id}-account-#{account.id}"
end
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: account.id,
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
@@ -0,0 +1,32 @@
class Voice::Provider::TwilioAdapter
def initialize(channel)
@channel = channel
end
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
cfg = @channel.provider_config_hash
host = ENV.fetch('FRONTEND_URL')
phone_digits = @channel.phone_number.delete_prefix('+')
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
params = {
from: @channel.phone_number,
to: to,
url: callback_url,
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
status_callback_event: %w[initiated ringing answered completed],
status_callback_method: 'POST'
}
call = twilio_client(cfg).calls.create(**params)
{ call_sid: call.sid }
end
private
def twilio_client(config)
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
end
+137
View File
@@ -0,0 +1,137 @@
require 'openai'
class Captain::Agent
attr_reader :name, :tools, :prompt, :persona, :goal, :secrets
def initialize(name:, config:)
@name = name
@prompt = construct_prompt(config)
@tools = prepare_tools(config[:tools] || [])
@messages = config[:messages] || []
@max_iterations = config[:max_iterations] || 10
@llm = Captain::LlmService.new(api_key: config[:secrets][:OPENAI_API_KEY])
@logger = Rails.logger
@logger.info(@prompt)
end
def execute(input, context)
setup_messages(input, context)
result = {}
@max_iterations.times do |iteration|
push_to_messages(role: 'system', content: 'Provide a final answer') if iteration == @max_iterations - 1
result = @llm.call(@messages, functions)
handle_llm_result(result)
break if result[:stop]
end
result[:output]
end
def register_tool(tool)
@tools << tool
end
private
def setup_messages(input, context)
if @messages.empty?
push_to_messages({ role: 'system', content: @prompt })
push_to_messages({ role: 'assistant', content: context }) if context.present?
end
push_to_messages({ role: 'user', content: input })
end
def handle_llm_result(result)
if result[:tool_call]
tool_result = execute_tool(result[:tool_call])
push_to_messages({ role: 'assistant', content: tool_result })
else
push_to_messages({ role: 'assistant', content: result[:output] })
end
result[:output]
end
def execute_tool(tool_call)
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
tool = @tools.find { |t| t.name == function_name }
tool.execute(arguments, {})
rescue StandardError => e
"Tool execution failed: #{e.message}"
end
def construct_prompt(config)
return config[:prompt] if config[:prompt]
<<~PROMPT
Persona: #{config[:persona]}
Objective: #{config[:goal]}
Guidelines:
- Persistently work towards achieving the stated objective without deviation.
- Use only the provided tools to complete the task. Avoid inventing or assuming function names.
- Set `'stop': true` once the objective is fully achieved.
- DO NOT return tool usage as the final result.
- If sufficient information is available to deliver result, compile and present it to the user.
- Always return a final result and ENSURE the final result is formatted in Markdown.
Output Structure:
1. **Tool Usage:**
- If a relevant function is identified, call it directly without unnecessary explanations.
2. **Final Answer:**
When ready to provide a complete response, follow this JSON format:
```json
{
"thought_process": "Explain the reasoning and steps taken to arrive at the final result.",
"result": "Provide the complete response in clear, structured text.",
"stop": true
}
PROMPT
end
def prepare_tools(tools = [])
tools.map do |_, tool|
Captain::Tool.new(
name: tool['name'],
config: {
description: tool['description'],
properties: tool['properties'],
secrets: tool['secrets'],
implementation: tool['implementation']
}
)
end
end
def functions
@tools.map do |tool|
properties = {}
tool.properties.each do |property_name, property_details|
properties[property_name] = {
type: property_details[:type],
description: property_details[:description]
}
end
required = tool.properties.select { |_, details| details[:required] == true }.keys
{
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: { type: 'object', properties: properties, required: required }
}
}
end
end
def push_to_messages(message)
@logger.info("\n\n\nMessage: #{message}\n\n\n")
@messages << message
end
end
+64
View File
@@ -0,0 +1,64 @@
require 'openai'
class Captain::LlmService
def initialize(config)
@client = OpenAI::Client.new(
access_token: config[:api_key],
log_errors: Rails.env.development?
)
@logger = Rails.logger
end
def call(messages, functions = [])
openai_params = {
model: 'gpt-4o',
response_format: { type: 'json_object' },
messages: messages
}
openai_params[:tools] = functions if functions.any?
response = @client.chat(parameters: openai_params)
handle_response(response)
rescue StandardError => e
handle_error(e)
end
private
def handle_response(response)
if response['choices'][0]['message']['tool_calls']
handle_tool_calls(response)
else
handle_direct_response(response)
end
end
def handle_tool_calls(response)
tool_call = response['choices'][0]['message']['tool_calls'][0]
{
tool_call: tool_call,
output: nil,
stop: false
}
end
def handle_direct_response(response)
content = response.dig('choices', 0, 'message', 'content').strip
parsed = JSON.parse(content)
{
output: parsed['result'] || parsed['thought_process'],
stop: parsed['stop'] || false
}
rescue JSON::ParserError => e
handle_error(e, content)
end
def handle_error(error, content = nil)
@logger.error("LLM call failed: #{error.message}")
@logger.error(error.backtrace.join("\n"))
@logger.error("Content: #{content}") if content
{ output: 'Error occurred, retrying', stop: false }
end
end
+66
View File
@@ -0,0 +1,66 @@
class Captain::Tool
class InvalidImplementationError < StandardError; end
class InvalidSecretsError < StandardError; end
class ExecutionError < StandardError; end
REQUIRED_PROPERTIES = %w[name description properties secrets].freeze
attr_reader :name, :description, :properties, :secrets, :implementation, :memory
def initialize(name:, config:)
@name = name
@description = config[:description]
@properties = config[:properties]
@secrets = config[:secrets] || []
@implementation = config[:implementation]
@memory = config[:memory] || {}
end
def register_method(&block)
@implementation = block
end
def execute(input, provided_secrets = {})
validate_secrets!(provided_secrets)
validate_input!(input)
raise ExecutionError, 'No implementation registered' unless @implementation
instance_exec(input, provided_secrets, memory, &@implementation)
rescue StandardError => e
raise ExecutionError, "Execution failed: #{e.message}"
end
private
def validate_config!(config)
missing_keys = REQUIRED_PROPERTIES - config.keys
return if missing_keys.empty?
raise InvalidImplementationError,
"Missing required properties: #{missing_keys.join(', ')}"
end
def validate_secrets!(provided_secrets)
required_secrets = secrets.map!(&:to_sym)
missing_secrets = required_secrets - provided_secrets.keys
return if missing_secrets.empty?
raise InvalidSecretsError, "Missing required secrets: #{missing_secrets.join(', ')}"
end
def validate_input!(input)
properties.each do |property, constraints|
validate_property!(input, property, constraints)
end
end
def validate_property!(input, property, constraints)
value = input[property.to_sym]
raise ArgumentError, "Missing required property: #{property}" if constraints['required'] && value.nil?
true
end
end
@@ -36,13 +36,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
# Trigger the bot handoff (sets status to open + dispatches events)
conversation.bot_handoff!
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
send_out_of_office_message_if_applicable(conversation)
end
def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# TODO: Future enhancement - Add team assignment capability
+47 -48
View File
@@ -7,6 +7,14 @@ module Integrations::LlmInstrumentation
include Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationSpans
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
def instrument_llm_call(params)
return yield unless ChatwootApp.otel_enabled?
@@ -58,57 +66,16 @@ module Integrations::LlmInstrumentation
end
end
def instrument_embedding_call(params)
return yield unless ChatwootApp.otel_enabled?
def determine_provider(model_name)
return 'openai' if model_name.blank?
instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
set_embedding_span_attributes(span, params)
result = yield
track_result.call(result)
set_embedding_result_attributes(span, result)
result
model = model_name.to_s.downcase
PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
end
end
def instrument_audio_transcription(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
set_audio_transcription_span_attributes(span, params)
result = yield
track_result.call(result)
set_transcription_result_attributes(span, result)
result
end
end
def instrument_moderation_call(params)
return yield unless ChatwootApp.otel_enabled?
instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
set_moderation_span_attributes(span, params)
result = yield
track_result.call(result)
set_moderation_result_attributes(span, result)
result
end
end
def instrument_with_span(span_name, params, &)
result = nil
executed = false
tracer.in_span(span_name) do |span|
track_result = lambda do |r|
executed = true
result = r
end
yield(span, track_result)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
raise unless executed
result
'openai'
end
private
@@ -119,4 +86,36 @@ module Integrations::LlmInstrumentation
nil
end
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
end
def record_completion(span, result)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result) if result.is_a?(Hash)
end
end
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
end
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
end
end
@@ -1,88 +0,0 @@
# frozen_string_literal: true
module Integrations::LlmInstrumentationCompletionHelpers
include Integrations::LlmInstrumentationConstants
private
def set_embedding_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_audio_transcription_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
set_common_span_metadata(span, params)
end
def set_moderation_span_attributes(span, params)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
set_common_span_metadata(span, params)
end
def set_common_span_metadata(span, params)
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
end
def set_embedding_result_attributes(span, result)
span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
end
def set_transcription_result_attributes(span, result)
transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
span.set_attribute('transcription.length', transcribed_text&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
end
def set_moderation_result_attributes(span, result)
span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
output = {
flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
}
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
end
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
end
+22 -36
View File
@@ -2,52 +2,38 @@
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
include Integrations::LlmInstrumentationCompletionHelpers
def determine_provider(model_name)
return 'openai' if model_name.blank?
model = model_name.to_s.downcase
LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
end
'openai'
end
private
def setup_span_attributes(span, params)
set_request_attributes(span, params)
set_prompt_messages(span, params[:messages])
set_metadata_attributes(span, params)
def set_completion_attributes(span, result)
set_completion_message(span, result)
set_usage_metrics(span, result)
set_error_attributes(span, result)
end
def record_completion(span, result)
if result.respond_to?(:content)
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
elsif result.is_a?(Hash)
set_completion_attributes(span, result)
end
def set_completion_message(span, result)
message = result[:message] || result.dig('choices', 0, 'message', 'content')
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
end
def set_request_attributes(span, params)
provider = determine_provider(params[:model])
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
def set_usage_metrics(span, result)
usage = result[:usage] || result['usage']
return if usage.blank?
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
end
def set_prompt_messages(span, messages)
messages.each_with_index do |msg, idx|
role = msg[:role] || msg['role']
content = msg[:content] || msg['content']
def set_error_attributes(span, result)
error = result[:error] || result['error']
return if error.blank?
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
end
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
end
def set_metadata_attributes(span, params)
@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'opentelemetry_config'
require_relative 'llm_instrumentation_constants'
module Integrations::LlmInstrumentationSpans
include Integrations::LlmInstrumentationConstants
+13 -14
View File
@@ -77,22 +77,21 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
end
def add_message_if_within_limit(character_count, message, messages, in_array_format)
content = message.content_for_llm
if valid_message?(content, character_count)
add_message_to_list(message, messages, in_array_format, content)
character_count += content.length
if valid_message?(message, character_count)
add_message_to_list(message, messages, in_array_format)
character_count += message.content.length
[character_count, true]
else
[character_count, false]
end
end
def valid_message?(content, character_count)
content.present? && character_count + content.length <= TOKEN_LIMIT
def valid_message?(message, character_count)
message.content.present? && character_count + message.content.length <= TOKEN_LIMIT
end
def add_message_to_list(message, messages, in_array_format, content)
formatted_message = format_message(message, in_array_format, content)
def add_message_to_list(message, messages, in_array_format)
formatted_message = format_message(message, in_array_format)
messages.prepend(formatted_message)
end
@@ -100,17 +99,17 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
in_array_format ? [] : ''
end
def format_message(message, in_array_format, content)
in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
def format_message(message, in_array_format)
in_array_format ? format_message_in_array(message) : format_message_in_string(message)
end
def format_message_in_array(message, content)
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
def format_message_in_array(message)
{ role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
end
def format_message_in_string(message, content)
def format_message_in_string(message)
sender_type = message.incoming? ? 'Customer' : 'Agent'
"#{sender_type} #{message.sender&.name} : #{content}\n"
"#{sender_type} #{message.sender&.name} : #{message.content}\n"
end
def summarize_body
-17
View File
@@ -1,17 +0,0 @@
# frozen_string_literal: true
module LlmConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
OPENAI_API_ENDPOINT = 'https://api.openai.com'
PROVIDER_PREFIXES = {
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
'anthropic' => %w[claude-],
'google' => %w[gemini-],
'mistral' => %w[mistral- codestral-],
'deepseek' => %w[deepseek-]
}.freeze
end
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module OpenAiConstants
DEFAULT_MODEL = 'gpt-4.1-mini'
DEFAULT_ENDPOINT = 'https://api.openai.com'
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
end
+1 -3
View File
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.6",
"@chatwoot/prosemirror-schema": "1.2.9",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
@@ -69,8 +69,6 @@
"flag-icons": "^7.2.3",
"floating-vue": "^5.2.2",
"highlight.js": "^11.10.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"idb": "^8.0.0",
"js-cookie": "^3.0.5",
"json-logic-js": "^2.0.5",
+5 -52
View File
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.2.6
version: 1.2.6
specifier: 1.2.9
version: 1.2.9
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -127,12 +127,6 @@ importers:
highlight.js:
specifier: ^11.10.0
version: 11.10.0
html-to-image:
specifier: ^1.11.13
version: 1.11.13
html2canvas:
specifier: ^1.4.1
version: 1.4.1
idb:
specifier: ^8.0.0
version: 8.0.0
@@ -427,8 +421,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.2.6':
resolution: {integrity: sha512-ej60kU3m/tP0VoGkOJhj0X+Mxt7fEX5DSEE4IibCZnTM4kMewkMxSYyPu0AXqaA4nKX1hTMrwcbv1t7gVQttWQ==}
'@chatwoot/prosemirror-schema@1.2.9':
resolution: {integrity: sha512-RTxjyoVTkvigt5wtInLD5apxErJwlsxepQBjB80QYmJD5x/RPNwMpnK1+xT+4yaNxVCnumNgIzSnFd4OpWaJgw==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -1678,10 +1672,6 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -1928,9 +1918,6 @@ packages:
peerDependencies:
postcss: ^8.4
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-prefers-color-scheme@8.0.2:
resolution: {integrity: sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==}
engines: {node: ^14 || ^16 || >=18}
@@ -2699,13 +2686,6 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -4237,9 +4217,6 @@ packages:
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
engines: {node: '>=18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -4436,9 +4413,6 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
video.js@7.18.1:
resolution: {integrity: sha512-mnXdmkVcD5qQdKMZafDjqdhrnKGettZaGSVkExjACiylSB4r2Yt5W1bchsKmjFpfuNfszsMjTUnnoIWSSqoe/Q==}
@@ -4888,7 +4862,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.2.6':
'@chatwoot/prosemirror-schema@1.2.9':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -6274,8 +6248,6 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -6558,10 +6530,6 @@ snapshots:
postcss-selector-parser: 6.1.1
postcss-value-parser: 4.2.0
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-prefers-color-scheme@8.0.2(postcss@8.4.47):
dependencies:
postcss: 8.4.47
@@ -7496,13 +7464,6 @@ snapshots:
html-escaper@2.0.2: {}
html-to-image@1.11.13: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -9197,10 +9158,6 @@ snapshots:
glob: 10.4.5
minimatch: 9.0.5
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@@ -9401,10 +9358,6 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
video.js@7.18.1:
dependencies:
'@babel/runtime': 7.25.6
Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
@@ -1,64 +0,0 @@
require 'rails_helper'
RSpec.describe YearInReviewBuilder, type: :model do
subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:year) { 2025 }
describe '#build' do
context 'when there is no data for the year' do
it 'returns empty aggregates' do
result = builder.build
expect(result[:year]).to eq(year)
expect(result[:total_conversations]).to eq(0)
expect(result[:busiest_day]).to be_nil
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 })
end
end
context 'when there is data for the year' do
let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) }
let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) }
before do
create(:conversation, account: account, assignee: user, created_at: busiest_date)
create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour)
create(:conversation, account: account, assignee: user, created_at: other_date)
create(
:reporting_event,
account: account,
user: user,
name: 'first_response',
value: 12.7,
created_at: busiest_date
)
end
it 'returns total conversations count' do
expect(builder.build[:total_conversations]).to eq(3)
end
it 'returns busiest day data' do
expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 })
end
it 'returns support personality data' do
expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
it 'scopes data to the provided year' do
create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1))
create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1))
result = builder.build
expect(result[:total_conversations]).to eq(3)
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
end
end
end
@@ -1,383 +0,0 @@
require 'rails_helper'
RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:web_widget_inbox) { create(:inbox, account: account) }
let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service)
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is not a WhatsApp channel' do
it 'returns bad request' do
get "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
end
end
context 'when it is a WhatsApp channel' do
it 'returns template not found when no configuration exists' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['template_exists']).to be false
end
it 'returns template status when template exists on WhatsApp' do
template_config = {
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en'
}
}
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.with('custom_survey_template')
.and_return({
success: true,
template: { id: '123456789', status: 'APPROVED' }
})
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['template_exists']).to be true
expect(response_data['template_name']).to eq('custom_survey_template')
expect(response_data['status']).to eq('APPROVED')
expect(response_data['template_id']).to eq('123456789')
end
it 'returns template not found when template does not exist on WhatsApp' do
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.with('custom_survey_template')
.and_return({ success: false, error: 'Template not found' })
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body
expect(response_data['template_exists']).to be false
expect(response_data['error']).to eq('Template not found')
end
it 'handles service errors gracefully' do
template_config = { 'template' => { 'name' => 'custom_survey_template' } }
whatsapp_inbox.update!(csat_config: template_config)
allow(mock_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body['error']).to eq('API connection failed')
end
it 'returns unauthorized when agent is not assigned to inbox' do
other_agent = create(:user, account: account, role: :agent)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: other_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'allows access when agent is assigned to inbox' do
whatsapp_inbox.update!(csat_config: { 'template' => { 'name' => 'test' } })
allow(mock_service).to receive(:get_template_status)
.and_return({ success: true, template: { id: '123', status: 'APPROVED' } })
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do
let(:valid_template_params) do
{
template: {
message: 'How would you rate your experience?',
button_text: 'Rate Us',
language: 'en'
}
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is not a WhatsApp channel' do
it 'returns bad request' do
post "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels')
end
end
context 'when it is a WhatsApp channel' do
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",
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 error when template parameters are completely missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: {},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Template parameters are required')
end
it 'creates template successfully' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '987654321'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
response_data = response.parsed_body
expect(response_data['template']['name']).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
expect(response_data['template']['template_id']).to eq('987654321')
expect(response_data['template']['status']).to eq('PENDING')
expect(response_data['template']['language']).to eq('en')
end
it 'uses default values for optional parameters' do
minimal_params = {
template: {
message: 'How would you rate your experience?'
}
}
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
expect(mock_service).to receive(:create_csat_template) do |config|
expect(config[:button_text]).to eq('Please rate us')
expect(config[:language]).to eq('en')
expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}")
{ success: true, template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", template_id: '123' }
end
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: minimal_params,
as: :json
expect(response).to have_http_status(:created)
end
it 'handles WhatsApp API errors with user-friendly messages' do
whatsapp_error_response = {
'error' => {
'code' => 100,
'error_subcode' => 2_388_092,
'message' => 'Invalid parameter',
'error_user_title' => 'Template Creation Failed',
'error_user_msg' => 'The template message contains invalid content. Please review your message and try again.'
}
}
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: false,
error: 'Template creation failed',
response_body: whatsapp_error_response.to_json
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = response.parsed_body
expect(response_data['error']).to eq('The template message contains invalid content. Please review your message and try again.')
expect(response_data['details']).to include({
'code' => 100,
'subcode' => 2_388_092,
'title' => 'Template Creation Failed'
})
end
it 'handles generic API errors' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: false,
error: 'Network timeout',
response_body: nil
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Network timeout')
end
it 'handles unexpected service errors' do
allow(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template)
.and_raise(StandardError, 'Unexpected error')
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body['error']).to eq('Template creation failed')
end
it 'deletes existing template before creating new one' do
whatsapp_inbox.update!(csat_config: {
'template' => {
'name' => 'existing_template',
'template_id' => '111111111'
}
})
allow(mock_service).to receive(:get_template_status)
.with('existing_template')
.and_return({ success: true, template: { id: '111111111' } })
expect(mock_service).to receive(:delete_csat_template)
.with('existing_template')
.and_return({ success: true })
expect(mock_service).to receive(:create_csat_template)
.and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '222222222'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
end
it 'continues with creation even if deletion fails' do
whatsapp_inbox.update!(csat_config: {
'template' => { 'name' => 'existing_template' }
})
allow(mock_service).to receive(:get_template_status).and_return({ success: true })
allow(mock_service).to receive(:delete_csat_template)
.and_return({ success: false, response_body: 'Delete failed' })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}",
template_id: '333333333'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: admin.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
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",
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(mock_service).to receive(:get_template_status).and_return({ success: false })
allow(mock_service).to receive(:create_csat_template).and_return({
success: true,
template_name: 'customer_satisfaction_survey',
template_id: '444444444'
})
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template",
headers: agent.create_new_auth_token,
params: valid_template_params,
as: :json
expect(response).to have_http_status(:created)
end
end
end
end
@@ -804,101 +804,6 @@ RSpec.describe 'Inboxes API', type: :request do
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
end
end
it 'successfully updates inbox with template configuration' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
template_config = inbox.csat_config['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
end
it 'returns template configuration in inbox details' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
template_config = json_response['csat_config']['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
expect(template_config['created_at']).to be_present
end
it 'removes template configuration when not provided in update' do
# First set up template configuration
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789'
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
# Then update without template
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config.merge({ 'message' => 'Updated message' })
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
config = inbox.csat_config
expect(config['message']).to eq('Updated message')
expect(config['template']).to be_nil # Template should be removed when not provided
end
end
end

Some files were not shown because too many files have changed in this diff Show More