Compare commits

..
200 changed files with 4954 additions and 8415 deletions
+1 -1
View File
@@ -1 +1 @@
23.7.0
20.5.1
+1 -8
View File
@@ -7,7 +7,6 @@ plugins:
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength:
@@ -42,12 +41,6 @@ Style/SymbolArray:
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
@@ -95,7 +88,7 @@ Metrics/ModuleLength:
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- enterprise/app/helpers/captain/chat_response_helper.rb
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
+3 -4
View File
@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe', '~> 18.0'
gem 'stripe'
## - helper gems --##
## to populate db with sample data
@@ -191,10 +191,9 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.7.0'
gem 'ai-agents', '>= 0.4.3'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
@@ -215,7 +214,7 @@ group :production do
end
group :development do
gem 'annotaterb'
gem 'annotate'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
+11 -13
View File
@@ -126,11 +126,11 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
ai-agents (0.4.3)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
@@ -819,7 +819,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.8.2)
ruby_llm (1.5.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -827,9 +827,8 @@ 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_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -929,7 +928,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1018,8 +1017,8 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
annotaterb
ai-agents (>= 0.4.3)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
aws-actionmailbox-ses (~> 0)
@@ -1120,7 +1119,6 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1141,7 +1139,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe (~> 18.0)
stripe
telephone_number
test-prof
tidewave
@@ -9,8 +9,6 @@ class Messages::Messenger::MessageBuilder
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
@@ -29,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -41,17 +39,9 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: url,
remote_file_url: url
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
@@ -78,21 +68,6 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
end
# This is a placeholder method to be overridden by child classes
def get_story_object_from_source_id(_source_id)
{}
@@ -101,6 +76,6 @@ class Messages::Messenger::MessageBuilder
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
[:template, :unsupported_type].include? attachment_type.to_sym
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,7 +92,14 @@ 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)
params.permit(
:auto_resolve_after,
:auto_resolve_message,
:auto_resolve_ignore_waiting,
:audio_transcriptions,
:auto_resolve_label,
conversation_required_attributes: []
)
end
def check_signup_enabled
@@ -23,10 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
export default new EnterpriseAccountAPI();
@@ -11,8 +11,6 @@ describe('#enterpriseAccountAPI', () => {
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
expect(accountAPI).toHaveProperty('toggleDeletion');
expect(accountAPI).toHaveProperty('createTopupCheckout');
expect(accountAPI).toHaveProperty('getLimits');
});
describe('API calls', () => {
@@ -61,29 +59,5 @@ describe('#enterpriseAccountAPI', () => {
{ action_type: 'undelete' }
);
});
it('#createTopupCheckout with credits', () => {
accountAPI.createTopupCheckout(1000);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits: 1000 }
);
});
it('#createTopupCheckout with different credit amounts', () => {
const creditAmounts = [1000, 2500, 6000, 12000];
creditAmounts.forEach(credits => {
accountAPI.createTopupCheckout(credits);
expect(axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/topup_checkout',
{ credits }
);
});
});
it('#getLimits', () => {
accountAPI.getLimits();
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
});
});
});
@@ -0,0 +1,11 @@
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: { type: String, default: 'resolution' },
});
const colorClass = computed(() => {
const colorMap = {
'pre-chat': 'blue',
resolution: 'teal',
};
const color = colorMap[props.type] || colorMap.resolution;
return `text-n-${color}-11`;
});
const iconName = computed(() => {
const iconMap = {
'pre-chat': 'i-lucide-message-circle',
resolution: 'i-lucide-circle-check-big',
};
return iconMap[props.type] || iconMap.resolution;
});
const displayLabel = computed(() => {
const labelMap = {
'pre-chat': 'Pre-chat',
resolution: 'Resolution',
};
return labelMap[props.type] || labelMap.resolution;
});
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="iconName" class="size-4" :class="colorClass" />
<span class="text-xs" :class="colorClass">{{ displayLabel }}</span>
</div>
</template>
@@ -0,0 +1,80 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/ConversationWorkflow/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-white rounded-2xl border border-n-weak dark:bg-n-solid-1"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
<div class="w-px h-3 bg-n-weak" />
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.label"
:type="badge.type"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>
@@ -0,0 +1,145 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
defineProps({
title: { type: String, default: '' },
description: { type: String, default: '' },
});
const emit = defineEmits(['click']);
const { t } = useI18n();
const { currentAccount, updateAccount } = useAccount();
const showDropdown = ref(false);
const isSaving = ref(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() =>
allAttributeOptions.value.filter(
attribute => !selectedAttributeKeys.value.includes(attribute.value)
)
);
const conversationRequiredAttributes = computed(() =>
selectedAttributeKeys.value
.map(key =>
allAttributeOptions.value.find(attribute => attribute.value === key)
)
.filter(Boolean)
);
const handleAddAttributesClick = event => {
event.stopPropagation();
showDropdown.value = !showDropdown.value;
};
const saveRequiredAttributes = async keys => {
try {
isSaving.value = true;
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
isSaving.value = false;
showDropdown.value = false;
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
showDropdown.value = false;
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<CardLayout
class="[&>div]:px-0 [&>div]:py-0 [&>div]:gap-0 [&>div]:divide-y [&>div]:divide-n-weak"
@click="handleClick"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">{{ title }}</h3>
<div v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">{{ description }}</p>
</div>
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</CardLayout>
</template>
@@ -0,0 +1,165 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = ref({});
const close = () => {
dialogRef.value?.close();
};
const getPlaceholder = type => {
const placeholders = {
text: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'
),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'
),
date: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'
),
list: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'
),
};
return placeholders[type] || '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues.value?.[attribute.value];
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const open = (attributes = [], initialValues = {}) => {
visibleAttributes.value = attributes;
formValues.value = attributes.reduce((acc, attribute) => {
const presetValue = initialValues[attribute.value];
acc[attribute.value] =
presetValue !== undefined && presetValue !== null ? presetValue : '';
return acc;
}, {});
dialogRef.value?.open();
};
const handleConfirm = () => {
emit('submit', { ...formValues.value });
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:show-confirm-button="isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === 'text'">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder('text')"
/>
</template>
<template v-else-if="attribute.type === 'number'">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder('number')"
/>
</template>
<template v-else-if="attribute.type === 'link'">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder('link')"
/>
</template>
<template v-else-if="attribute.type === 'date'">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder('date')"
/>
</template>
<template v-else-if="attribute.type === 'list'">
<ComboBox
v-model="formValues[attribute.value]"
:options="
(
attribute.attribute_values ||
attribute.attributeValues ||
[]
).map(option => ({
value: option,
label: option,
}))
"
:placeholder="getPlaceholder('list')"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === 'checkbox'">
<ChoiceToggle
v-model="formValues[attribute.value]"
:yes-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.YES')
"
:no-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.CHECKBOX.NO')
"
/>
</template>
</div>
</div>
</Dialog>
</template>
@@ -19,12 +19,12 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -102,12 +102,12 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -139,6 +139,19 @@ watch(
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
@@ -172,7 +172,7 @@ const previewArticle = () => {
@apply mr-0;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
@apply p-0 mt-1 !mr-0;
svg {
width: 20px !important;
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
@@ -50,6 +50,12 @@ const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const signatureToApply = computed(() =>
props.isEmailOrWebWidgetInbox
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature)
);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
@@ -74,20 +80,12 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
const setSignature = () => {
if (props.messageSignature) {
if (signatureToApply.value) {
if (sendWithSignature.value) {
emit('addSignature', props.messageSignature);
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', props.messageSignature);
emit('removeSignature', signatureToApply.value);
}
}
};
@@ -103,7 +101,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
});
},
{ immediate: true }
@@ -222,7 +220,7 @@ useKeyboardEvents(keyboardEvents);
/>
</FileUpload>
<Button
v-if="shouldShowSignatureButton"
v-if="hasSelectedInbox && isRegularMessageMode"
icon="i-lucide-signature"
color="slate"
size="sm"
@@ -39,7 +39,7 @@ const removeAttachment = id => {
</script>
<template>
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
<div class="flex flex-col gap-4 p-4">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -87,12 +87,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 +194,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 = [];
@@ -209,28 +202,25 @@ const handleInboxAction = ({ value, action, ...rest }) => {
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
);
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
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 => {
@@ -238,19 +228,11 @@ const onClickInsertEmoji = emoji => {
};
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
state.message = appendSignature(state.message, signature);
};
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,127 @@
<script setup>
import { computed } from 'vue';
import { ref, watch, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
appendSignature,
extractTextFromMarkdown,
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: '',
});
const plainTextSignature = computed(() =>
extractTextFromMarkdown(props.messageSignature)
);
watch(
modelValue,
newValue => {
if (props.isEmailOrWebWidgetInbox) return;
const bodyWithoutSignature = newValue
? removeSignature(newValue, plainTextSignature.value)
: '';
// 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, plainTextSignature.value)
: 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>
@@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
yesLabel: {
type: String,
default: 'Yes',
},
noLabel: {
type: String,
default: 'No',
},
});
const emit = defineEmits(['update:modelValue']);
const options = computed(() => [
{ label: props.yesLabel, value: 'yes' },
{ label: props.noLabel, value: 'no' },
]);
const handleSelect = value => {
emit('update:modelValue', value);
};
</script>
<template>
<div
class="flex gap-4 items-center px-4 py-2.5 w-full bg-white rounded-lg divide-x transition-colors outline outline-1 outline-n-weak dark:bg-n-solid-1 hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
>
<div
v-for="option in options"
:key="option.value"
class="flex flex-1 gap-2 justify-center items-center"
>
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
<input
type="radio"
:value="option.value"
:checked="modelValue === option.value"
class="size-4 accent-n-blue-9 text-n-blue-9"
@change="handleSelect(option.value)"
/>
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
</label>
</div>
</div>
</template>
@@ -299,12 +299,7 @@ const componentToRender = computed(() => {
return DyteBubble;
}
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
if (props.contentAttributes.imageType === 'story_mention') {
return InstagramStoryBubble;
}
@@ -481,7 +476,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full message-bubble-container mb-2"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -49,8 +49,6 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
export const CONTENT_TYPES = {
@@ -520,7 +520,7 @@ const menuItems = computed(() => {
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-workflow',
icon: 'i-lucide-repeat',
to: accountScopedRoute('automation_list'),
},
{
@@ -565,6 +565,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: ' Conversation Workflow',
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('conversation_workflow_index'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
@@ -646,7 +652,7 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['tabChanged']);
const emit = defineEmits(['change', 'tabChanged']);
const activeTab = computed(() => props.initialActiveTab);
@@ -51,6 +51,7 @@ onMounted(() => {
});
const selectTab = index => {
emit('change', index);
emit('tabChanged', props.tabs[index]);
};
@@ -3,9 +3,14 @@ import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAccount } from 'dashboard/composables/useAccount';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -17,13 +22,19 @@ import {
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const { currentAccount } = useAccount();
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const resolveAttributesModalRef = ref(null);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
@@ -52,31 +63,103 @@ const showOpenButton = computed(() => {
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const requiredAttributes = computed(() =>
requiredAttributeKeys.value.map(key => {
const definition = conversationAttributes.value?.find(
attribute => attribute.attributeKey === key
);
if (!definition) {
return {
value: key,
label: key,
type: 'text',
attribute_values: [],
};
}
return {
...definition,
value: definition.attributeKey,
label: definition.attributeDisplayName,
type: definition.attributeDisplayType,
};
})
);
const getMissingRequiredAttributes = () => {
if (!currentChat.value?.id) return [];
const existingValues = currentChat.value.custom_attributes || {};
return requiredAttributes.value.filter(attribute => {
const value = existingValues?.[attribute.value];
return value === undefined || value === null || String(value) === '';
});
};
const toggleStatusInternal = async (status, snoozedUntil = null) => {
closeDropdown();
isLoading.value = true;
try {
await store.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
});
useAlert(t('CONVERSATION.CHANGE_STATUS'));
} finally {
isLoading.value = false;
}
};
const resolveWithAttributes = async customAttributes => {
if (!currentChat.value?.id) return;
try {
isLoading.value = true;
if (requiredAttributeKeys.value.length) {
await store.dispatch('updateCustomAttributes', {
conversationId: currentChat.value.id,
customAttributes,
});
}
await toggleStatusInternal(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
isLoading.value = false;
}
};
const openResolveModal = () => {
closeDropdown();
if (!currentChat.value?.id) return;
const missing = getMissingRequiredAttributes();
if (!missing.length) {
resolveWithAttributes(currentChat.value.custom_attributes || {});
return;
}
resolveAttributesModalRef.value?.open(
missing,
currentChat.value.custom_attributes || {}
);
};
const handleModalSubmit = async values => {
if (!currentChat.value?.id) return;
resolveAttributesModalRef.value?.close();
await resolveWithAttributes({
...(currentChat.value.custom_attributes || {}),
...values,
});
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
@@ -97,7 +180,7 @@ const onCmdOpenConversation = () => {
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
openResolveModal();
};
const keyboardEvents = {
@@ -106,21 +189,11 @@ const keyboardEvents = {
allowOnFocusedInput: true,
},
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
},
action: () => openResolveModal(),
},
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
action: event => {
openResolveModal();
event.preventDefault();
},
},
@@ -133,9 +206,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="flex relative justify-end items-center resolve-actions">
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleModalSubmit"
/>
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -26,11 +26,13 @@ import { useAlert } from 'dashboard/composables';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
import {
messageSchema,
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
@@ -51,10 +53,6 @@ import {
removeSignature as removeSignatureHelper,
scrollCursorIntoView,
setURLWithQueryAndSize,
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -77,12 +75,12 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
// allowSignature is a kill switch, ensuring no signature methods
// 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 },
});
@@ -105,40 +103,22 @@ const { t } = useI18n();
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 formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
});
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content),
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema,
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions: editorMenuOptions.value,
enabledMenuOptions,
}),
});
};
@@ -173,8 +153,6 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -196,6 +174,12 @@ const shouldShowCannedResponses = computed(() => {
);
});
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
});
function createSuggestionPlugin({
trigger,
minChars = 0,
@@ -309,13 +293,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 +362,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);
// need to reload first, ensuring that the editorView is updated
reloadState(content);
@@ -399,11 +374,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);
}
@@ -429,38 +400,6 @@ function setToolbarPosition() {
};
}
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
// Calculate coords and final position
const coords = getSelectionCoords(editorView, selection, rect);
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
wrapper.style.setProperty('--selection-left', `${left}px`);
wrapper.style.setProperty(
'--selection-right',
`${rect.width - left - width}px`
);
wrapper.style.setProperty('--selection-top', `${top}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -590,9 +529,7 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
insertNodeIntoEditor(node, from, undefined);
}
@@ -659,7 +596,6 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -825,33 +761,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.ProseMirror-menubar-wrapper {
@apply flex flex-col gap-3;
@apply flex flex-col;
.ProseMirror-menubar {
min-height: 1.25rem !important;
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
.ProseMirror-menu-active {
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
@apply bg-n-slate-5 dark:bg-n-solid-3;
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center justify-center;
.ProseMirror-icon {
@apply size-4 flex items-center justify-center flex-shrink-0;
svg {
@apply size-full;
}
}
}
}
.ProseMirror-menubar:not(:has(*)) {
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
}
> .ProseMirror {
@@ -942,53 +860,4 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.editor-warning__message {
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
// Hide menu completely when it has no items
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
width: fit-content !important;
position: absolute !important;
// Default/LTR: position from left
top: var(--selection-top);
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
.ProseMirror-menuitem {
@apply mr-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>
@@ -78,6 +78,10 @@ export default {
type: Boolean,
default: false,
},
showEditorToggle: {
type: Boolean,
default: false,
},
isOnPrivateNote: {
type: Boolean,
default: false,
@@ -126,6 +130,7 @@ export default {
emits: [
'replaceText',
'toggleInsertArticle',
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
@@ -320,8 +325,18 @@ export default {
sm
@click="toggleAudioRecorder"
/>
<NextButton
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="i-ph-quotes"
slate
faded
sm
@click="$emit('toggleEditor')"
/>
<NextButton
v-if="showAudioPlayStopButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
:icon="audioRecorderPlayStopIcon"
slate
faded
@@ -45,7 +45,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
appendSignature,
removeSignature,
getEffectiveChannelType,
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
@@ -61,6 +61,7 @@ export default {
ArticleSearchPopover,
AttachmentPreview,
AudioRecorder,
CannedResponse,
ReplyBoxBanner,
EmojiInput,
MessageSignatureMissingAlert,
@@ -68,12 +69,11 @@ export default {
ReplyEmailHead,
ReplyToMessage,
ReplyTopPanel,
ResizableTextArea,
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -86,6 +86,7 @@ export default {
setup() {
const {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -96,6 +97,7 @@ export default {
return {
uiSettings,
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
@@ -113,6 +115,7 @@ export default {
isRecordingAudio: false,
recordingAudioState: '',
recordingAudioDurationText: '',
isUploading: false,
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
@@ -144,12 +147,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 (
@@ -159,6 +159,20 @@ export default {
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
showWhatsappTemplates() {
// We support templates for API channels if someone updates templates manually via API
// That's why we don't explicitly check for channel type here
@@ -286,6 +300,9 @@ export default {
hasAttachments() {
return this.attachedFiles.length;
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
},
@@ -325,11 +342,21 @@ export default {
return !this.isPrivate && this.sendWithSignature;
},
isSignatureAvailable() {
return !!this.messageSignature;
return !!this.signatureToApply;
},
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
@@ -356,6 +383,12 @@ export default {
});
return variables;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
connectedPortalSlug() {
const { help_center: portal = {} } = this.inbox;
const { slug = '' } = portal;
@@ -406,19 +439,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) {
@@ -492,7 +512,7 @@ export default {
mounted() {
this.getFromDraft();
// Don't use the keyboard listener mixin here as the events here are supposed to be
// working even if the editor is focussed.
// working even if input/textarea is focussed.
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCAndToEmailsFromLastChat();
@@ -546,6 +566,28 @@ export default {
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
if (!this.showRichContentEditor && this.messageSignature) {
// remove the old signature -> extract text from markdown -> attach new signature
let message = removeSignature(this.message, this.messageSignature);
message = extractTextFromMarkdown(message);
message = appendSignature(message, plainTextSignature);
this.message = message;
} else {
this.message = replaceSignature(
this.message,
plainTextSignature,
this.messageSignature
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
@@ -611,23 +653,7 @@ 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);
@@ -690,7 +716,7 @@ export default {
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
this.$refs.messageInput.$el.blur();
}
if (!data.length || !data[0]) {
return;
@@ -825,19 +851,7 @@ 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.signatureToApply);
}
const updatedMessage = replaceVariablesInMessage({
@@ -894,19 +908,7 @@ export default {
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.signatureToApply);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -924,15 +926,19 @@ export default {
},
toggleAudioRecorder() {
this.isRecordingAudio = !this.isRecordingAudio;
this.isRecorderAudioStopped = !this.isRecordingAudio;
if (!this.isRecordingAudio) {
this.resetAudioRecorderInput();
}
},
toggleAudioRecorderPlayPause() {
if (!this.$refs.audioRecorderInput) return;
if (!this.recordingAudioState) {
if (!this.isRecordingAudio) {
return;
}
if (!this.isRecorderAudioStopped) {
this.isRecorderAudioStopped = true;
this.$refs.audioRecorderInput.stopRecording();
} else {
} else if (this.isRecorderAudioStopped) {
this.$refs.audioRecorderInput.playPause();
}
},
@@ -1239,17 +1245,16 @@ export default {
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
:signature="signatureToApply"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1297,6 +1302,7 @@ export default {
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
@@ -1309,6 +1315,7 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
@@ -1,18 +1,43 @@
import { ref, unref } from 'vue';
import { computed, ref, unref } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
export function useBulkActions() {
const store = useStore();
const { t } = useI18n();
const { currentAccount } = useAccount();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const allConversations = useMapGetter('getAllConversations');
const selectedInboxes = ref([]);
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const conversationsById = computed(() =>
(allConversations.value || []).reduce((acc, conversation) => {
acc[conversation.id] = conversation;
return acc;
}, {})
);
const hasAllRequiredValues = conversation => {
if (!conversation) return false;
const attrs = conversation.custom_attributes || {};
return requiredAttributeKeys.value.every(key => {
const value = attrs?.[key];
return (
value !== undefined && value !== null && String(value).trim() !== ''
);
});
};
function selectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
selectedInboxes.value = [...selectedInboxes.value, inboxId];
@@ -116,17 +141,58 @@ export function useBulkActions() {
}
async function onUpdateConversations(status, snoozedUntil) {
const selectedIds = selectedConversations.value;
const requiresCheck =
status === 'resolved' && requiredAttributeKeys.value.length > 0;
const resolveCandidates = requiresCheck
? selectedIds.reduce(
(result, id) => {
const conversation = conversationsById.value[id];
if (hasAllRequiredValues(conversation)) {
result.resolvable.push(id);
} else {
result.blocked.push(id);
}
return result;
},
{ resolvable: [], blocked: [] }
)
: { resolvable: selectedIds, blocked: [] };
if (!resolveCandidates.resolvable.length) {
if (requiresCheck) {
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
return;
}
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: selectedConversations.value,
ids: resolveCandidates.resolvable,
fields: {
status,
},
snoozed_until: snoozedUntil,
});
// Update selection to keep only blocked conversations, if any.
store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
selectedInboxes.value = [];
if (resolveCandidates.blocked.length) {
store.dispatch('bulkActions/setSelectedConversationIds', [
...resolveCandidates.blocked,
]);
selectedInboxes.value = resolveCandidates.blocked
.map(id => conversationsById.value[id]?.inbox_id)
.filter(Boolean);
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
}
} catch (err) {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,7 +9,6 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -35,8 +34,6 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -49,6 +46,5 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}
+25 -217
View File
@@ -1,143 +1,23 @@
// Formatting rules for different contexts (channels and special contexts)
// marks: inline formatting (strong, em, code, link, strike)
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
export const FORMATTING = {
// Channel formatting
'Channel::Email': {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::WebWidget': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::TwitterProfile': {
marks: [],
nodes: [],
menu: [],
},
'Channel::TwilioSms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Sms': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Whatsapp': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'strong',
'em',
'code',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'strong',
'em',
'code',
'bulletList',
'orderedList',
'strike',
'undo',
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::MessageSignature': {
marks: ['strong', 'em', 'link'],
nodes: ['image'],
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
},
'Context::InboxSettings': {
marks: ['strong', 'em', 'link'],
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
};
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
'imageUpload',
];
// Editor menu options for Full Editor
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
@@ -153,86 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
/**
* Markdown formatting patterns for stripping unsupported formatting.
*
* Maps camelCase type names to ProseMirror snake_case schema names.
* Order matters: codeBlock before code to avoid partial matches.
*/
export const MARKDOWN_PATTERNS = [
// --- BLOCK NODES ---
{
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
patterns: [
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
],
},
{
type: 'blockquote', // PM: blockquote, eg: > quote
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
},
{
type: 'bulletList', // PM: bullet_list, eg: - item
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
},
{
type: 'orderedList', // PM: ordered_list, eg: 1. item
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
},
{
type: 'heading', // PM: heading, eg: ## Heading
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
},
{
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
},
{
type: 'image', // PM: image, eg: ![alt](url)
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
},
{
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
patterns: [
{ pattern: /\\\n/g, replacement: '\n' },
{ pattern: / {2,}\n/g, replacement: '\n' },
],
},
// --- INLINE MARKS ---
{
type: 'strong', // PM: strong, eg: **bold** or __bold__
patterns: [
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
{ pattern: /__(.+?)__/g, replacement: '$1' },
],
},
{
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',
},
],
},
{
type: 'strike', // PM: strike, eg: ~~strikethrough~~
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
},
{
type: 'code', // PM: code, eg: `inline code`
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
},
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
// Editor image resize options for Message Editor
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
+30 -258
View File
@@ -5,82 +5,6 @@ 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 camelcaseKeys from 'camelcase-keys';
/**
* 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.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.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.
@@ -143,39 +67,15 @@ export function findSignatureInBody(body, signature) {
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @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
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @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().
* @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)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
export function appendSignature(body, signature) {
const cleanedSignature = cleanSignature(signature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
@@ -186,34 +86,16 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
*
* @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
export function removeSignature(body, signature) {
// this will find the index of the signature if it exists
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// 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
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
const signatureIndex = findSignatureInBody(body, cleanedSignature);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
@@ -254,6 +136,28 @@ export function replaceSignature(body, oldSignature, newSignature) {
return appendSignature(withoutSignature, newSignature);
}
/**
* 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.
*
* @param {string} markdown - markdown text to be extracted
* @returns
*/
export function extractTextFromMarkdown(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Scrolls the editor view into current cursor position
*
@@ -379,47 +283,6 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
@@ -450,17 +313,8 @@ const createNode = (editorView, nodeType, content) => {
return mentionNode;
}
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
@@ -535,85 +389,3 @@ export const getContentNode = (
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */
-5
View File
@@ -13,11 +13,6 @@ export const INBOX_TYPES = {
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
@@ -1,11 +1,15 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
@@ -58,18 +62,12 @@ describe('getContentNode', () => {
const to = 10;
const updatedMessage = 'Hello John';
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
const { node } = getContentNode(
editorView,
'cannedResponse',
content,
@@ -81,15 +79,8 @@ describe('getContentNode', () => {
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
@@ -5,18 +5,11 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
getFormattingForEditor,
getSelectionCoords,
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
import { EditorView } from '@chatwoot/prosemirror-schema';
import { Schema } from 'prosemirror-model';
@@ -145,107 +138,6 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)');
});
it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
expect(result).not.toContain('[link]('); // link syntax removed
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![](');
});
it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
expect(result).not.toContain('**');
expect(result).not.toContain('_');
expect(result).not.toContain('[');
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
});
});
describe('appendSignature with channelType', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('keeps images for Email channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Email'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps images for WebWidget channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::WebWidget'
);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('strips images but keeps text for Api channel', () => {
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('strips images but keeps text for WhatsApp channel', () => {
const result = appendSignature(
'Hello',
signatureWithImage,
'Channel::Whatsapp'
);
expect(result).not.toContain('![](');
expect(result).toContain('Thanks');
});
it('keeps images when channelType is not provided', () => {
const result = appendSignature('Hello', signatureWithImage);
expect(result).toContain('![](http://localhost:3000/image.png');
});
it('keeps bold/italic for channels that support them', () => {
const boldSignature = '**Bold** *italic* Thanks';
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
// Api supports strong and em
expect(result).toContain('**Bold**');
expect(result).toContain('*italic*');
});
});
describe('cleanSignature', () => {
it('removes any instance of horizontal rule', () => {
const options = [
@@ -304,37 +196,6 @@ describe('removeSignature', () => {
});
});
describe('removeSignature with stripped signature', () => {
const signatureWithImage =
'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)';
it('removes stripped signature from body', () => {
// Simulate a body where signature was added with images stripped
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
const result = removeSignature(
bodyWithStrippedSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('removes original signature from body', () => {
// Simulate a body where signature was added with images (using cleanSignature format)
const cleanedSig = cleanSignature(signatureWithImage);
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
const result = removeSignature(
bodyWithOriginalSignature,
signatureWithImage
);
expect(result).toBe('Hello\n\n');
});
it('handles signature without images', () => {
const simpleSignature = 'Best regards';
const body = 'Hello\n\n--\n\nBest regards';
const result = removeSignature(body, simpleSignature);
expect(result).toBe('Hello\n\n');
});
});
describe('replaceSignature', () => {
it('appends the new signature if not present', () => {
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
@@ -397,11 +258,15 @@ describe('insertAtCursor', () => {
expect(result).toBeUndefined();
});
it('should insert text node at cursor position', () => {
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
const docNode = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('Hello')]),
]);
const editorState = createEditorState();
const editorView = new EditorView(document.body, { state: editorState });
insertAtCursor(editorView, schema.text('Hello'), 0);
insertAtCursor(editorView, docNode, 0);
// Check if node was unwrapped and inserted correctly
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
@@ -761,349 +626,3 @@ describe('getContentNode', () => {
});
});
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns signature formatting for Context::MessageSignature', () => {
const result = getFormattingForEditor('Context::MessageSignature');
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
});
it('returns widget builder formatting for Context::InboxSettings', () => {
const result = getFormattingForEditor('Context::InboxSettings');
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
});
});
describe('fallback behavior', () => {
it('returns default formatting for unknown channel type', () => {
const result = getFormattingForEditor('Channel::Unknown');
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for null channel type', () => {
const result = getFormattingForEditor(null);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for undefined channel type', () => {
const result = getFormattingForEditor(undefined);
expect(result).toEqual(FORMATTING['Context::Default']);
});
it('returns default formatting for empty string', () => {
const result = getFormattingForEditor('');
expect(result).toEqual(FORMATTING['Context::Default']);
});
});
describe('return value structure', () => {
it('always returns an object with marks, nodes, and menu properties', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toHaveProperty('marks');
expect(result).toHaveProperty('nodes');
expect(result).toHaveProperty('menu');
expect(Array.isArray(result.marks)).toBe(true);
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.menu)).toBe(true);
});
});
});
describe('stripUnsupportedFormatting', () => {
describe('when schema supports all formatting', () => {
const fullSchema = {
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
};
it('preserves all formatting when schema supports it', () => {
const content = '**bold** and *italic* and `code`';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves links when schema supports them', () => {
const content = 'Check [this link](https://example.com)';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
});
describe('when schema has no formatting support (eg:SMS channel)', () => {
const emptySchema = {
marks: {},
nodes: {},
};
it('strips bold formatting', () => {
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
'bold text'
);
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
'bold text'
);
});
it('strips italic formatting', () => {
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
'italic text'
);
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
'italic text'
);
});
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'
);
});
it('strips strikethrough formatting', () => {
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
'strikethrough'
);
});
it('strips links but keeps text', () => {
expect(
stripUnsupportedFormatting(
'Check [this link](https://example.com)',
emptySchema
)
).toBe('Check this link');
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
).toBe('item 1\nitem 2');
expect(
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
).toBe('item 1\nitem 2');
});
it('strips ordered list markers', () => {
expect(
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
).toBe('first\nsecond');
});
it('strips code block markers', () => {
expect(
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
).toBe('code here\n');
});
it('strips blockquote markers', () => {
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
'quoted text'
);
});
it('handles complex content with multiple formatting types', () => {
const content =
'**Bold** and *italic* with `code` and [link](url)\n- list item';
const expected = 'Bold and italic with code and link\nlist item';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
});
describe('when schema has partial support', () => {
const partialSchema = {
marks: { strong: {}, em: {} },
nodes: {},
};
it('preserves supported marks and strips unsupported ones', () => {
const content = '**bold** and `code`';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** and code'
);
});
it('strips unsupported nodes but keeps supported marks', () => {
const content = '**bold** text\n- list item';
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
'**bold** text\nlist item'
);
});
});
describe('edge cases', () => {
it('returns content unchanged if content is empty', () => {
expect(stripUnsupportedFormatting('', {})).toBe('');
});
it('returns content unchanged if content is null', () => {
expect(stripUnsupportedFormatting(null, {})).toBe(null);
});
it('returns content unchanged if content is undefined', () => {
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
});
it('returns content unchanged if schema is null', () => {
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
});
it('handles nested formatting correctly', () => {
const emptySchema = { marks: {}, nodes: {} };
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
expect(
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
).toBe('bold and italic');
});
});
});
describe('Menu positioning helpers', () => {
const mockEditorView = {
coordsAtPos: vi.fn((pos, bias) => {
// Return different coords based on position
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
return { top: 100, bottom: 120, left: 150, right: 200 };
}),
};
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
describe('getSelectionCoords', () => {
it('returns selection coordinates with onTop flag', () => {
const selection = { from: 0, to: 10 };
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
expect(result).toHaveProperty('start');
expect(result).toHaveProperty('end');
expect(result).toHaveProperty('selTop');
expect(result).toHaveProperty('onTop');
});
});
describe('getMenuAnchor', () => {
it('returns end.left when menu is below selection', () => {
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
});
it('returns start.left for LTR when menu is above and visible', () => {
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
});
it('returns start.right for RTL when menu is above and visible', () => {
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
});
});
describe('calculateMenuPosition', () => {
it('returns bounded left and top positions', () => {
const coords = {
start: { top: 100, bottom: 120, left: 50 },
end: { top: 100, bottom: 120, left: 150 },
selTop: 100,
onTop: false,
};
const result = calculateMenuPosition(coords, wrapperRect, false);
expect(result).toHaveProperty('left');
expect(result).toHaveProperty('top');
expect(result).toHaveProperty('width', 300);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
});
@@ -20,7 +20,8 @@
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
"UPDATE_FAILED": "Failed to update conversations. Please try again.",
"REQUIRED_ATTRIBUTES_MISSING": "Some conversations need required attributes before resolving and were skipped."
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
@@ -196,6 +196,7 @@
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Show rich text editor",
"TIP_EMOJI_ICON": "Show emoji selector",
"TIP_ATTACH_ICON": "Attach files",
"TIP_AUDIORECORDER_ICON": "Record audio",
@@ -377,7 +377,8 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"SECURITY": "Security",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -399,39 +400,15 @@
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Credits",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
"TITLE": "Confirm Purchase",
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
}
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
@@ -505,6 +482,46 @@
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
"TITLE": "Conversation Workflows",
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
}
},
"REQUIRED_ATTRIBUTES": {
"TITLE": "Attributes required on resolution",
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
"NO_ATTRIBUTES": "No attributes added yet",
"ADD": {
"TITLE": "Add Attributes",
"SEARCH_PLACEHOLDER": "Search attributes"
},
"SAVE": {
"SUCCESS": "Required attributes updated",
"ERROR": "Could not update required attributes, please try again"
},
"MODAL": {
"TITLE": "Resolve conversation",
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
"ACTIONS": {
"RESOLVE": "Resolve conversation",
"CANCEL": "Cancel"
},
"PLACEHOLDERS": {
"TEXT": "Write a note...",
"NUMBER": "Enter a number",
"LINK": "Add a link",
"DATE": "Pick a date",
"LIST": "Select an option"
},
"CHECKBOX": {
"YES": "Yes",
"NO": "No"
}
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
@@ -5,9 +5,7 @@ import {
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
@@ -54,22 +52,12 @@ const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const { isCloudFeatureEnabled } = useAccount();
const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearClientIdConfigured = computed(() => {
return !!linearIntegration.value?.id;
});
const isLinearConnected = computed(
const isLinearIntegrationEnabled = computed(
() => linearIntegration.value?.enabled || false
);
@@ -250,13 +238,7 @@ onMounted(() => {
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'linear_issues' &&
isLinearFeatureEnabled &&
isLinearClientIdConfigured
"
>
<div v-else-if="element.name === 'linear_issues'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
@@ -265,7 +247,7 @@ onMounted(() => {
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearConnected" />
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
@@ -2,20 +2,32 @@
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import EditAttribute from './EditAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import {
useStoreGetters,
useStore,
useMapGetter,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
const { t } = useI18n();
const getters = useStoreGetters();
const store = useStore();
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const showAddPopup = ref(false);
const selectedTabIndex = ref(0);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const openAddPopup = () => {
showAddPopup.value = true;
@@ -23,6 +35,14 @@ const openAddPopup = () => {
const hideAddPopup = () => {
showAddPopup.value = false;
};
const hideEditPopup = () => {
showEditPopup.value = false;
selectedAttribute.value = {};
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const tabs = computed(() => {
return [
@@ -49,9 +69,63 @@ const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = index => {
selectedTabIndex.value = index;
const onClickTabChange = payload => {
selectedTabIndex.value = typeof payload === 'number' ? payload : payload?.key;
};
const handleEditAttribute = attribute => {
selectedAttribute.value = attribute;
showEditPopup.value = true;
};
const handleDeleteAttribute = attribute => {
selectedAttribute.value = attribute;
showDeletePopup.value = true;
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const hasPreChatBadge = attribute => {
return (inboxes.value || []).some(inbox => {
const fields =
inbox?.pre_chat_form_options?.pre_chat_fields ||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
[];
return fields.some(field => field.name === attribute.attribute_key);
});
};
const buildBadges = attribute => {
const badges = [];
if (hasPreChatBadge(attribute)) {
badges.push({
type: 'pre-chat',
});
}
if (
attribute.attribute_model === 'conversation_attribute' &&
requiredAttributeKeys.value.includes(attribute.attribute_key)
) {
badges.push({
type: 'resolution',
});
}
return badges;
};
const derivedAttributes = computed(() =>
attributes.value.map(attribute => ({
...attribute,
label: attribute.attribute_display_name,
type: attribute.attribute_display_type,
value: attribute.attribute_key,
badges: buildBadges(attribute),
}))
);
</script>
<template>
@@ -77,27 +151,25 @@ const onClickTabChange = index => {
</template>
</BaseSettingsHeader>
</template>
<template #preBody>
<woot-tabs
class="font-medium [&_ul]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabs.map(tab => ({ label: tab.name, key: tab.key }))"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@change="onClickTabChange"
/>
<div class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@edit="handleEditAttribute"
@delete="handleDeleteAttribute"
/>
</div>
</div>
</template>
<AddAttribute
v-if="showAddPopup"
@@ -105,5 +177,39 @@ const onClickTabChange = index => {
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: selectedAttribute.attribute_display_name,
})
"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
selectedAttribute.attribute_display_name || ''
}`"
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: selectedAttribute.attribute_display_name,
})
"
@on-confirm="
() => {
store.dispatch('attributes/delete', selectedAttribute.id);
closeDelete();
}
"
@on-close="closeDelete"
/>
</SettingsLayout>
</template>
@@ -11,7 +11,6 @@ import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -24,7 +23,6 @@ const {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
@@ -34,7 +32,6 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
@@ -48,11 +45,6 @@ const planName = computed(() => {
return customAttributes.value.plan_name;
});
const canPurchaseCredits = computed(() => {
const plan = planName.value?.toLowerCase();
return plan && plan !== 'hacker';
});
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
@@ -79,9 +71,8 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
};
const handleBillingPageLogic = async () => {
@@ -128,15 +119,6 @@ const onToggleChatWindow = () => {
}
};
const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
const handleTopupSuccess = () => {
// Refresh limits to show updated credit balance
fetchLimits();
};
onMounted(handleBillingPageLogic);
</script>
@@ -196,27 +178,9 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
sm
flushed
slate
icon="i-lucide-refresh-cw"
:is-loading="isFetchingLimits"
@click="fetchLimits"
>
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
</ButtonV4>
<ButtonV4
v-if="canPurchaseCredits"
sm
solid
blue
@click="openPurchaseCreditsModal"
>
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
</ButtonV4>
</div>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
@@ -259,10 +223,6 @@ onMounted(handleBillingPageLogic);
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
</template>
</SettingsLayout>
</template>
@@ -1,101 +0,0 @@
<script setup>
defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
currency: {
type: String,
default: 'usd',
},
isSelected: {
type: Boolean,
default: false,
},
isPopular: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
});
const emit = defineEmits(['select']);
const formatCredits = credits => {
return credits.toLocaleString();
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
<template>
<label
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
:class="[
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
]"
>
<input
type="radio"
:name="name"
:value="credits"
:checked="isSelected"
class="sr-only"
@change="emit('select')"
/>
<span
v-if="isPopular"
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
:class="
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
"
>
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
</span>
<div
v-if="isSelected"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
{{ formatCredits(credits) }}
</span>
<span
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
>
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
</span>
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
{{ formatAmount(amount, currency) }}
<span class="text-sm text-n-slate-11 ml-0.5">{{
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
}}</span>
</span>
</label>
</template>
@@ -1,220 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
if (!selectedOption.value) return '';
return selectedOption.value.credits.toLocaleString();
});
const dialogTitle = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
});
const dialogDescription = computed(() => {
return currentStep.value === STEP_SELECT
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
: '';
});
const dialogWidth = computed(() => {
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
};
const goBackToSelectStep = () => {
currentStep.value = STEP_SELECT;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
isLoading.value = true;
try {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
close();
emit('success', response.data);
useAlert(
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
credits: response.data.credits,
})
);
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="dialogTitle"
:description="dialogDescription"
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
<template v-else>
<div class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
credits: formattedCredits,
amount: formattedAmount,
})
}}
</p>
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
<p class="text-sm text-n-amber-11">
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
</p>
</div>
</div>
</template>
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
<!-- Step 2 Footer -->
<div v-else class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
class="w-full"
:disabled="isLoading"
@click="goBackToSelectStep"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
class="w-full"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>
@@ -110,7 +110,6 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
@@ -114,7 +114,6 @@ export default {
v-model="content"
class="message-editor [&>div]:px-1"
:class="{ editor_warning: v$.content.$error }"
channel-type="Context::Default"
enable-variables
:enable-canned-responses="false"
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
@@ -0,0 +1,28 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import ConversationWorkflowIndex from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
component: SettingsWrapper,
children: [
{
path: '',
redirect: to => {
return { name: 'conversation_workflow_index', params: to.params };
},
},
{
path: 'index',
name: 'conversation_workflow_index',
component: ConversationWorkflowIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};
@@ -0,0 +1,28 @@
<script setup>
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
</script>
<template>
<SettingsLayout :no-records-found="false" class="gap-10">
<template #header>
<BaseSettingsHeader
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
feature-name="assignment-policy"
/>
</template>
<template #body>
<div class="flex">
<ConversationRequiredAttributes
:title="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE')"
:description="
$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION')
"
/>
</div>
</template>
</SettingsLayout>
</template>
@@ -27,6 +27,7 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -80,6 +81,7 @@ export default {
selectedTabIndex: 0,
selectedPortalSlug: '',
showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
healthData: null,
isLoadingHealth: false,
healthError: null,
@@ -624,7 +626,7 @@ export default {
)
"
:max-length="255"
channel-type="Context::InboxSettings"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
/>
<label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,6 +7,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -75,6 +76,7 @@ export default {
checked: false,
},
],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -335,7 +337,7 @@ export default {
)
"
:max-length="255"
channel-type="Context::InboxSettings"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/>
<label>
@@ -5,6 +5,7 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
@@ -23,6 +24,7 @@ export default {
channelWelcomeTagline: '',
greetingEnabled: false,
greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
};
},
computed: {
@@ -145,7 +147,7 @@ export default {
)
"
:max-length="255"
channel-type="Context::InboxSettings"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/>
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -11,6 +12,7 @@ const props = defineProps({
});
const emit = defineEmits(['updateSignature']);
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
watch(
() => props.messageSignature ?? '',
@@ -32,7 +34,7 @@ const updateSignature = () => {
class="message-editor h-[10rem] !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
channel-type="Context::MessageSignature"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
show-image-resize-toolbar
/>
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
export default {
routes: [
@@ -63,5 +64,6 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...conversationWorkflow.routes,
],
};
@@ -18,7 +18,6 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingLimits: false,
},
};
@@ -142,14 +141,11 @@ export const actions = {
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
@@ -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);
+83 -157
View File
@@ -1,176 +1,102 @@
# TODO: logic is written tailored to contact import since its the only import available
# let's break this logic and clean this up in future
# let's break this logic and clean this up in future
class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
class DataImportJob < ApplicationJob
queue_as :low
retry_on ActiveStorage::FileNotFoundError, wait: 1.minute, attempts: 3
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
begin
process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end
private
def process_import_file
@data_import.update!(status: :processing)
identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records = parse_csv_and_build_contacts
all_valid_contacts = identifiable_contacts + non_identifiable_contacts
# Count contacts before import
contacts_before = @data_import.account.contacts.count
import_contacts(all_valid_contacts)
# Count contacts after import to get actual imported count
contacts_after = @data_import.account.contacts.count
actual_imported_count = contacts_after - contacts_before
# Calculate actual imported counts based on what we built vs what was imported
actual_identifiable_count, actual_non_identifiable_count = calculate_actual_import_counts(
identifiable_contacts, non_identifiable_contacts, actual_imported_count
)
update_data_import_status(actual_identifiable_count, actual_non_identifiable_count, rejected_contacts.length, total_csv_records)
save_failed_records_csv(rejected_contacts)
end
# rubocop:disable Metrics/MethodLength
def parse_csv_and_build_contacts
identifiable_contacts = []
non_identifiable_contacts = []
rejected_contacts = []
total_csv_records = 0
with_import_file do |file|
csv_reader(file).each do |row|
total_csv_records += 1
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
is_identifiable = @contact_manager.contact_identifiable?(current_contact)
if is_identifiable
identifiable_contacts << current_contact
else
non_identifiable_contacts << current_contact
end
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
end
[identifiable_contacts, non_identifiable_contacts, rejected_contacts, total_csv_records]
end
# rubocop:enable Metrics/MethodLength
def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
end
def import_contacts(contacts)
# Returns ActiveRecord::Import::Result with info about what was actually imported
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
end
def calculate_actual_import_counts(identifiable_contacts, non_identifiable_contacts, actual_imported_count)
Rails.logger.info "Actual imported: #{actual_imported_count}, expected identifiable: #{identifiable_contacts.length}, " \
"expected non-identifiable: #{non_identifiable_contacts.length}"
expected_count = identifiable_contacts.length + non_identifiable_contacts.length
if actual_imported_count <= expected_count
# Prioritize identifiable contacts in the count since they're more important
actual_identifiable_count = [actual_imported_count, identifiable_contacts.length].min
actual_non_identifiable_count = actual_imported_count - actual_identifiable_count
else
# This shouldn't happen, but fallback to expected counts
actual_identifiable_count = identifiable_contacts.length
actual_non_identifiable_count = non_identifiable_contacts.length
end
Rails.logger.info "Final counts: identifiable=#{actual_identifiable_count}, non-identifiable=#{actual_non_identifiable_count}"
[actual_identifiable_count, actual_non_identifiable_count]
end
def update_data_import_status(identifiable_records, non_identifiable_records, _rejected_records, total_csv_records)
processed_records = identifiable_records + non_identifiable_records
@data_import.update!(
status: :completed,
processed_records: processed_records,
non_identifiable_records: non_identifiable_records,
total_records: total_csv_records
)
end
def save_failed_records_csv(rejected_contacts)
csv_data = generate_csv_data(rejected_contacts)
return if csv_data.blank?
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
content_type: 'text/csv')
def perform(data_import)
@data_import = data_import
@contact_manager = DataImport::ContactManager.new(@data_import.account)
begin
process_import_file
send_import_notification_to_admin
rescue CSV::MalformedCSVError => e
handle_csv_error(e)
end
end
def generate_csv_data(rejected_contacts)
headers = csv_headers
headers << 'errors'
return if rejected_contacts.blank?
private
CSV.generate do |csv|
csv << headers
rejected_contacts.each do |record|
csv << record
end
def process_import_file
@data_import.update!(status: :processing)
contacts, rejected_contacts = parse_csv_and_build_contacts
import_contacts(contacts)
update_data_import_status(contacts.length, rejected_contacts.length)
save_failed_records_csv(rejected_contacts)
end
def parse_csv_and_build_contacts
contacts = []
rejected_contacts = []
# Ensuring that importing non utf-8 characters will not throw error
data = @data_import.import_file.download
utf8_data = data.force_encoding('UTF-8')
# Ensure that the data is valid UTF-8, preserving valid characters
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
csv = CSV.parse(clean_data, headers: true)
csv.each do |row|
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
if current_contact.valid?
contacts << current_contact
else
append_rejected_contact(row, current_contact, rejected_contacts)
end
end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
[contacts, rejected_contacts]
end
def send_import_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def append_rejected_contact(row, contact, rejected_contacts)
row['errors'] = contact.errors.full_messages.join(', ')
rejected_contacts << row
end
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
def import_contacts(contacts)
# <struct ActiveRecord::Import::Result failed_instances=[], num_inserts=1, ids=[444, 445], results=[]>
Contact.import(contacts, synchronize: contacts, on_duplicate_key_ignore: true, track_validation_failures: true, validate: true, batch_size: 1000)
end
def csv_headers
header_row = nil
with_import_file do |file|
header_row = csv_reader(file).first
end
header_row&.headers || []
end
def update_data_import_status(processed_records, rejected_records)
@data_import.update!(status: :completed, processed_records: processed_records, total_records: processed_records + rejected_records)
end
def csv_reader(file)
file.rewind
raw_data = file.read
utf8_data = raw_data.force_encoding('UTF-8')
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
def save_failed_records_csv(rejected_contacts)
csv_data = generate_csv_data(rejected_contacts)
return if csv_data.blank?
CSV.new(StringIO.new(clean_data), headers: true)
end
@data_import.failed_records.attach(io: StringIO.new(csv_data), filename: "#{Time.zone.today.strftime('%Y%m%d')}_contacts.csv",
content_type: 'text/csv')
send_import_notification_to_admin
end
def with_import_file
temp_dir = Rails.root.join('tmp/imports')
FileUtils.mkdir_p(temp_dir)
def generate_csv_data(rejected_contacts)
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
headers << 'errors'
return if rejected_contacts.blank?
@data_import.import_file.open(tmpdir: temp_dir) do |file|
file.binmode
yield file
CSV.generate do |csv|
csv << headers
rejected_contacts.each do |record|
csv << record
end
end
end
end
def handle_csv_error(error) # rubocop:disable Lint/UnusedMethodArgument
@data_import.update!(status: :failed)
send_import_failed_notification_to_admin
end
def send_import_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_complete(@data_import).deliver_later
end
def send_import_failed_notification_to_admin
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
end
end
+6 -9
View File
@@ -2,6 +2,10 @@ class DeleteObjectJob < ApplicationJob
queue_as :low
BATCH_SIZE = 5_000
HEAVY_ASSOCIATIONS = {
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
def perform(object, user = nil, ip = nil)
# Pre-purge heavy associations for large objects to avoid
@@ -15,18 +19,11 @@ class DeleteObjectJob < ApplicationJob
private
def heavy_associations
{
Account => %i[conversations contacts inboxes reporting_events],
Inbox => %i[conversations contact_inboxes reporting_events]
}.freeze
end
def purge_heavy_associations(object)
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) }
return unless klass
heavy_associations[klass].each do |assoc|
HEAVY_ASSOCIATIONS[klass].each do |assoc|
next unless object.respond_to?(assoc)
batch_destroy(object.public_send(assoc))
@@ -32,16 +32,9 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
end
# Calculate metrics with backward compatibility
non_identifiable_count = resource.respond_to?(:non_identifiable_records) ? resource.non_identifiable_records : 0
identifiable_count = resource.processed_records - non_identifiable_count
failed_count = resource.total_records - resource.processed_records
meta = {
'identifiable_contacts' => identifiable_count,
'non_identifiable_contacts' => non_identifiable_count,
'failed_contacts' => failed_count,
'total_contacts' => resource.total_records
'failed_contacts' => resource.total_records - resource.processed_records,
'imported_contacts' => resource.processed_records
}
send_notification(subject, action_url: action_url, meta: meta)
@@ -1,51 +0,0 @@
# Handles attachment processing for ConversationReplyMailer flows.
module ConversationReplyMailerAttachmentHelper
private
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
current_total_size = handle_attachment_inline(current_total_size, attachment)
end
end
def read_blob_content(blob)
buffer = +''
blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def handle_attachment_inline(current_total_size, attachment)
blob = attachment.file.blob
return current_total_size if blob.blank?
file_size = blob.byte_size
attachment_name = attachment.file.filename.to_s
if current_total_size + file_size <= 20.megabytes
content = read_blob_content(blob)
mail.attachments[attachment_name] = {
mime_type: attachment.file.content_type || 'application/octet-stream',
content: content
}
@options[:attachments] << { name: attachment_name }
current_total_size + file_size
else
@large_attachments << attachment
current_total_size
end
end
end
@@ -1,6 +1,4 @@
module ConversationReplyMailerHelper
include ConversationReplyMailerAttachmentHelper
def prepare_mail(cc_bcc_enabled)
@options = {
to: to_emails,
@@ -29,6 +27,34 @@ module ConversationReplyMailerHelper
mail(@options)
end
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
raw_data = attachment.file.download
attachment_name = attachment.file.filename.to_s
file_size = raw_data.bytesize
# Attach files directly until we hit 20MB total
# After reaching 20MB, send remaining files as links
if current_total_size + file_size <= 20.megabytes
mail.attachments[attachment_name] = raw_data
@options[:attachments] << { name: attachment_name }
current_total_size += file_size
else
@large_attachments << attachment
end
end
end
private
def oauth_smtp_settings
+6 -2
View File
@@ -37,7 +37,11 @@ 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] }
'auto_resolve_label': { 'type': %w[string null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
}
},
'required': [],
'additionalProperties': true
@@ -55,7 +59,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
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
+1 -1
View File
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
:contact => 8, :ig_reel => 9 }
def push_event_data
return unless file_type
+12 -21
View File
@@ -130,39 +130,30 @@ class Channel::Telegram < ApplicationRecord
def convert_markdown_to_telegram_html(text)
# ref: https://core.telegram.org/bots/api#html-style
# Escape HTML entities first to prevent HTML injection
# This ensures only markdown syntax is converted, not raw HTML
escaped_text = CGI.escapeHTML(text)
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
# Parse markdown with extensions:
# - strikethrough: support ~~text~~
# - hardbreaks: preserve all newlines as <br>
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
# convert markdown to html
html = CommonMarker.render_html(text).strip
# Convert paragraph breaks to double newlines to preserve them
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Remove opening and closing <p> tags
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
# Sanitize to only allowed tags
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
attributes: %w[href])
# Convert <br /> tags to newlines for Telegram
stripped_html.gsub(%r{<br\s*/?>}, "\n")
# converted escaped br tags to \n
stripped_html.gsub('&lt;br&gt;', "\n")
end
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
# text is already converted to HTML by MessageContentPresenter
text_payload = convert_markdown_to_telegram_html(text)
business_body = {}
business_body[:business_connection_id] = business_connection_id if business_connection_id
HTTParty.post("#{telegram_api_url}/sendMessage",
body: {
chat_id: chat_id,
text: text,
text: text_payload,
reply_markup: reply_markup,
parse_mode: 'HTML',
reply_to_message_id: reply_to_message_id
+9 -10
View File
@@ -2,16 +2,15 @@
#
# Table name: data_imports
#
# id :bigint not null, primary key
# data_type :string not null
# non_identifiable_records :integer default(0), not null
# processed_records :integer
# processing_errors :text
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# data_type :string not null
# processed_records :integer
# processing_errors :text
# status :integer default("pending"), not null
# total_records :integer
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
+1 -1
View File
@@ -53,7 +53,7 @@ class Integrations::App
when 'slack'
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
when 'linear'
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
when 'shopify'
shopify_enabled?(account)
when 'leadsquared'
-15
View File
@@ -254,21 +254,6 @@ class Message < ApplicationRecord
Messages::SearchDataPresenter.new(self).search_data
end
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[Attachment]' if attachments.any?
end
private
def prevent_message_flooding
-4
View File
@@ -30,8 +30,4 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
def topup_checkout?
@account_user.administrator?
end
end
+5 -12
View File
@@ -1,18 +1,11 @@
class MessageContentPresenter < SimpleDelegator
def outgoing_content
content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
return content unless should_append_survey_link?
Messages::MarkdownRendererService.new(
content_to_send,
conversation.inbox.channel_type,
conversation.inbox.channel
).render
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
end
private
@@ -9,10 +9,6 @@ class DataImport::ContactManager
contact
end
def contact_identifiable?(contact)
contact.email.present? || contact.phone_number.present? || contact.identifier.present?
end
def find_or_initialize_contact(params)
contact = find_existing_contact(params)
contact_params = params.slice(:email, :identifier, :phone_number)
-9
View File
@@ -59,20 +59,11 @@ class Instagram::MessageText < Instagram::BaseMessageText
# We can safely create an unknown contact, making this integration work.
return unknown_user(ig_scope_id) if error_code == 9010
# Handle error code 100: Object doesn't exist or missing permissions
# This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions
# We can safely create an unknown contact, similar to error 9010
return unknown_user(ig_scope_id) if error_code == 100
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
# Explicitly return empty hash for any unhandled error codes
# This prevents the exception tracker result from being returned
{}
end
def base_uri
@@ -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)
@@ -1,113 +0,0 @@
class Messages::MarkdownRendererService
CHANNEL_RENDERERS = {
'Channel::Email' => :render_html,
'Channel::WebWidget' => :render_html,
'Channel::Telegram' => :render_telegram_html,
'Channel::Whatsapp' => :render_whatsapp,
'Channel::FacebookPage' => :render_instagram,
'Channel::Instagram' => :render_instagram,
'Channel::Line' => :render_line,
'Channel::TwitterProfile' => :render_plain_text,
'Channel::Sms' => :render_plain_text,
'Channel::TwilioSms' => :render_plain_text
}.freeze
def initialize(content, channel_type, channel = nil)
@content = content
@channel_type = channel_type
@channel = channel
end
def render
return @content if @content.blank?
renderer_method = CHANNEL_RENDERERS[effective_channel_type]
renderer_method ? send(renderer_method) : @content
end
private
def effective_channel_type
# For Twilio SMS channel, check if it's actually WhatsApp
if @channel_type == 'Channel::TwilioSms' && @channel&.whatsapp?
'Channel::Whatsapp'
else
@channel_type
end
end
def commonmarker_doc
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
end
def render_html
markdown_renderer = BaseMarkdownRenderer.new
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
markdown_renderer.render(doc)
end
def render_telegram_html
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_whatsapp
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_instagram
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_line
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::LineRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
restore_multiple_newlines(result)
end
def render_plain_text
# Strip whitespace from whitespace-only lines to normalize newlines
normalized_content = @content.gsub(/^[ \t]+$/m, '')
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
result = renderer.render(doc).gsub(/\n+\z/, '')
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+
def preserve_multiple_newlines(content)
content.gsub(/\n{2,}/) do |match|
"{{PRESERVE_#{match.length}_NEWLINES}}"
end
end
# Restore multiple newlines from placeholders
def restore_multiple_newlines(content)
content.gsub(/\{\{PRESERVE_(\d+)_NEWLINES\}\}/) do |_match|
"\n" * Regexp.last_match(1).to_i
end
end
end
@@ -1,39 +0,0 @@
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
def document(_node)
out(:children)
end
def paragraph(_node)
out(:children)
cr
end
def text(node)
out(node.string_content)
end
def softbreak(_node)
out(' ')
end
def linebreak(_node)
out("\n")
end
def strikethrough(_node)
out('<del>')
out(:children)
out('</del>')
end
def method_missing(method_name, node = nil, *args, **kwargs, &)
return super unless node.is_a?(CommonMarker::Node)
out(:children)
cr unless %i[text softbreak linebreak].include?(node.type)
end
def respond_to_missing?(_method_name, _include_private = false)
true
end
end
@@ -1,48 +0,0 @@
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out(node.string_content)
end
def link(node)
out(node.url)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -1,36 +0,0 @@
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out(' *', :children, '* ')
end
def emph(_node)
out(' _', :children, '_ ')
end
def code(node)
out(' `', node.string_content, '` ')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out(:children)
cr
end
def code_block(node)
out(' ```', "\n", node.string_content, '``` ', "\n")
end
def blockquote(_node)
out(:children)
cr
end
end
@@ -1,62 +0,0 @@
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def link(node)
out(:children)
out(' ', node.url) if node.url.present?
end
def strong(_node)
out(:children)
end
def emph(_node)
out(:children)
end
def code(node)
out(node.string_content)
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('- ', :children)
end
cr
end
def blockquote(_node)
out(:children)
cr
end
def code_block(node)
out(node.string_content, "\n")
end
def header(_node)
out(:children)
cr
end
def thematic_break(_node)
out("\n")
end
def softbreak(_node)
out("\n")
end
end
@@ -1,60 +0,0 @@
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def initialize
super
@list_item_number = 0
end
def strong(_node)
out('<strong>', :children, '</strong>')
end
def emph(_node)
out('<em>', :children, '</em>')
end
def code(node)
out('<code>', node.string_content, '</code>')
end
def link(node)
out('<a href="', node.url, '">', :children, '</a>')
end
def strikethrough(_node)
out('<del>', :children, '</del>')
end
def blockquote(_node)
out('<blockquote>', :children, '</blockquote>')
end
def code_block(node)
out('<pre>', node.string_content, '</pre>')
end
def list(node)
@list_type = node.list_type
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
out(:children)
cr
end
def list_item(_node)
if @list_type == :ordered_list
out("#{@list_item_number}. ", :children)
@list_item_number += 1
else
out('• ', :children)
end
cr
end
def header(_node)
out('<strong>', :children, '</strong>')
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -1,36 +0,0 @@
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
def strong(_node)
out('*', :children, '*')
end
def emph(_node)
out('_', :children, '_')
end
def code(node)
out('`', node.string_content, '`')
end
def link(node)
out(node.url)
end
def list(_node)
out(:children)
cr
end
def list_item(_node)
out('- ', :children)
cr
end
def blockquote(_node)
out('> ', :children)
cr
end
def softbreak(_node)
out("\n")
end
end
@@ -96,16 +96,11 @@ class Telegram::SendAttachmentsService
# Telegram picks up the file name from original field name, so we need to save the file with the original name.
# Hence not using Tempfile here.
def save_attachment_to_tempfile(attachment)
temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}")
raw_data = attachment.file.download
temp_dir = Rails.root.join('tmp/uploads')
FileUtils.mkdir_p(temp_dir)
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
File.open(temp_file_path, 'wb') do |file|
attachment.file.blob.open do |blob_file|
IO.copy_stream(blob_file, file)
end
end
File.write(temp_file_path, raw_data, mode: 'wb')
temp_file_path
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
@@ -3,6 +3,6 @@ json.payload do
json.id inbox_member.user.id
json.name inbox_member.user.available_name
json.avatar_url inbox_member.user.avatar_url
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id)&.availability_status
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status
end
end
@@ -1,21 +1,10 @@
<p>Hello,</p>
<p>Your contact import has been completed. Here's a summary of the results:</p>
<p>Your contact import has been completed. Please check the contacts tab to view the imported contacts.</p>
<p><strong>Contacts imported successfully:</strong> {{meta['identifiable_contacts']}}</p>
<p><em>These contacts are visible in your contacts list and can be used for conversations.</em></p>
<p>Number of records imported: {{meta['imported_contacts']}}</p>
{% if meta['non_identifiable_contacts'] > 0 %}
<p><strong>Contacts created but not visible:</strong> {{meta['non_identifiable_contacts']}}</p>
<p><em>These contacts were created successfully but don't have email, phone number, or identifier, so they won't appear in your contacts list until you add this information.</em></p>
{% endif %}
{% if meta['failed_contacts'] > 0 %}
<p><strong>Contacts that failed to import:</strong> {{meta['failed_contacts']}}</p>
<p><em>These contacts had validation errors and could not be imported.</em></p>
{% endif %}
<p><strong>Total contacts processed:</strong> {{meta['total_contacts']}}</p>
<p>Number of records failed: {{meta['failed_contacts']}}</p>
{% if meta['failed_contacts'] == 0 %}
<p>
@@ -23,6 +12,6 @@
</p>
{% else %}
<p>
Click <a href="{{ action_url }}" target="_blank">here</a> to download the failed records and see specific error messages.
Click <a href="{{ action_url }}" target="_blank">here</a> to view failed records.
</p>
{% endif %}
@@ -1,7 +1,7 @@
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
<% elsif @message.content %>
<%= @message.outgoing_content.html_safe %>
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
<p>Attachments:</p>
+2 -2
View File
@@ -4,8 +4,8 @@ require 'agents'
Rails.application.config.after_initialize do
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
if api_key.present?
Agents.configure do |config|
-9
View File
@@ -122,13 +122,6 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
topup:
credits_required: Credits amount is required
invalid_credits: Invalid credits amount
invalid_option: Invalid topup option
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
profile:
mfa:
enabled: MFA enabled successfully
@@ -204,8 +197,6 @@ en:
messages:
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
instagram_shared_story_content: 'Shared story'
instagram_shared_post_content: 'Shared post'
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
-3
View File
@@ -203,8 +203,6 @@ Rails.application.routes.draw do
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
@@ -440,7 +438,6 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
post :topup_checkout
end
end
end
@@ -1,5 +0,0 @@
class AddNonIdentifiableRecordsToDataImports < ActiveRecord::Migration[7.0]
def change
add_column :data_imports, :non_identifiable_records, :integer, default: 0, null: false
end
end
+1257 -147
View File
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
end
def destroy
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
@sla_policy.destroy!
head :ok
end
@@ -55,22 +55,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
result = service.create_checkout_session(credits: params[:credits].to_i)
@account.reload
render json: result.merge(
id: @account.id,
limits: @account.limits,
custom_attributes: @account.custom_attributes
)
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
render_could_not_create_error(e.message)
end
private
def check_cloud_env
@@ -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
+20 -62
View File
@@ -1,81 +1,31 @@
module Captain::ChatHelper
include Integrations::LlmInstrumentation
include Captain::ChatResponseHelper
include Captain::ToolExecutionHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
with_agent_session do
response = chat.ask(conversation_messages.last[:content])
build_response(response)
response = instrument_llm_call(instrumentation_params) do
@client.chat(
parameters: chat_parameters
)
end
handle_response(response)
end
rescue StandardError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
raise e
end
private
def build_chat
llm_chat = chat(model: @model, temperature: temperature)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
setup_event_handlers(llm_chat)
end
def setup_tools(llm_chat)
@tools&.each do |tool|
llm_chat = llm_chat.with_tool(tool)
end
llm_chat
end
def setup_system_instructions(chat)
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
combined_instructions = system_messages.pluck(:content).join("\n\n")
chat.with_instructions(combined_instructions)
end
def setup_event_handlers(chat)
chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) }
chat.on_end_message { |message| end_llm_turn_span(message) }
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
chat.on_tool_result { |result| handle_tool_result(result) }
chat
end
def handle_tool_call(tool_call)
persist_thinking_message(tool_call)
start_tool_span(tool_call)
@pending_tool_calls ||= []
@pending_tool_calls.push(tool_call)
end
def handle_tool_result(result)
end_tool_span(result)
persist_tool_completion
end
def add_messages_to_chat(chat)
conversation_messages[0...-1].each do |msg|
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
end
end
def instrumentation_params(chat = nil)
def instrumentation_params
{
span_name: "llm.captain.#{feature_name}",
account_id: resolved_account_id,
conversation_id: @conversation_id,
feature_name: feature_name,
model: @model,
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
messages: @messages,
temperature: temperature,
metadata: {
assistant_id: @assistant&.id
@@ -83,8 +33,14 @@ module Captain::ChatHelper
}
end
def conversation_messages
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
def chat_parameters
{
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' },
temperature: temperature
}
end
def temperature
@@ -95,6 +51,8 @@ module Captain::ChatHelper
@account&.id || @assistant&.account_id
end
private
# Ensures all LLM calls and tool executions within an agentic loop
# are grouped under a single trace/session in Langfuse.
#
@@ -120,7 +78,7 @@ module Captain::ChatHelper
def log_chat_completion_request
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
for messages #{@messages} with #{@tools&.length || 0} tools
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
"
)
end
@@ -1,52 +0,0 @@
module Captain::ChatResponseHelper
private
def build_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
parsed = parse_json_response(response.content)
persist_message(parsed, 'assistant')
parsed
end
def parse_json_response(content)
content = content.gsub('```json', '').gsub('```', '')
content = content.strip
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
{ 'content' => content }
end
def persist_thinking_message(tool_call)
return if @copilot_thread.blank?
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Using #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
def persist_tool_completion
return if @copilot_thread.blank?
tool_call = @pending_tool_calls&.pop
return unless tool_call
tool_name = tool_call.name.to_s
persist_message(
{
'content' => "Completed #{tool_name}",
'function_name' => tool_name
},
'assistant_thinking'
)
end
end
@@ -0,0 +1,83 @@
module Captain::ToolExecutionHelper
private
def handle_response(response)
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
message = JSON.parse(message['content'].strip)
persist_message(message, 'assistant')
message
end
end
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
tool_calls.each { |tool_call| process_tool_call(tool_call) }
request_chat_completion
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(function_name, tool_call_id)
end
end
def execute_tool(function_name, arguments, tool_call_id)
persist_tool_status(function_name, 'captain.copilot.using_tool')
result = perform_tool_call(function_name, arguments)
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
append_tool_response(result, tool_call_id)
end
def perform_tool_call(function_name, arguments)
instrument_tool_call(function_name, arguments) do
@tool_registry.send(function_name, arguments)
end
rescue StandardError => e
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
"Error executing #{function_name}: #{e.message}"
end
def persist_tool_status(function_name, translation_key)
persist_message(
{
content: I18n.t(translation_key, function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
tool_calls: tool_calls
}
end
def process_invalid_tool_call(function_name, tool_call_id)
persist_message(
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
'assistant_thinking'
)
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: content
}
end
end
@@ -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')

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