Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7ec0ca60 | ||
|
|
d67d8b373f | ||
|
|
3809d44d29 |
@@ -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/`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ';
|
||||
|
||||
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('');
|
||||
});
|
||||
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';
|
||||
const strippedSignature = 'Thanks';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
@@ -219,31 +166,24 @@ describe('appendSignature with channelType', () => {
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
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(';
|
||||
});
|
||||
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);
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 723 B |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 112 KiB |
@@ -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
|
||||
|
||||
|
||||