Merge branch 'develop' into fix/missing-postmessage-validation

This commit is contained in:
Vinay Keerthi
2026-01-15 11:38:30 +05:30
committed by GitHub
111 changed files with 3880 additions and 421 deletions
@@ -0,0 +1,38 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -0,0 +1,76 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -1,38 +1,27 @@
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
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
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
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
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)
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(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
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
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)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
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
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, 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?
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
: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] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_conversations_report
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
summary = builder.summary
[generate_conversation_report_metrics(summary)]
end
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
def generate_conversation_report_metrics(summary)
[
summary[:conversations_count],
summary[:incoming_messages_count],
summary[:outgoing_messages_count],
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
summary[:resolutions_count],
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
]
end
end
@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
+6
View File
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/conversations_summary`, {
params: { since, until, business_hours: businessHours },
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
@@ -96,6 +97,7 @@ watch(
]"
>
<WootEditor
:editor-id="editorKey"
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
@@ -152,6 +154,13 @@ watch(
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -44,6 +44,12 @@ const emit = defineEmits([
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
@@ -176,7 +182,8 @@ const onPaste = e => {
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
onFileUpload({ file, name, type, size });
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
});
};
@@ -7,6 +7,7 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -47,6 +48,8 @@ const emit = defineEmits([
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest });
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
@@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
state.message = '';
stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<ActionButtons
:attached-files="state.attachedFiles"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
@@ -24,14 +23,14 @@ const modelValue = defineModel({
</script>
<template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<div class="flex-1 h-full">
<Editor
:key="editorKey"
v-model="modelValue"
:editor-key="editorKey"
: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="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
@@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
@@ -7,7 +7,6 @@ import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
@@ -218,17 +217,6 @@ export function useConversationFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER,
@@ -485,6 +485,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
@@ -45,6 +45,7 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -637,6 +638,25 @@ export default {
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.filter(file => {
const isAllowed = isFileTypeAllowedForChannel(file, {
channelType: this.channelType || this.inbox?.channel_type,
medium: this.inbox?.medium,
conversationType: this.conversationType,
isInstagramChannel: this.isAnInstagramChannel,
isOnPrivateNote: this.isOnPrivateNote,
});
if (!isAllowed) {
useAlert(
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
fileName: file.name,
})
);
}
return isAllowed;
})
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
@@ -78,14 +78,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
@@ -38,9 +38,14 @@ export function extractTextFromMarkdown(markdown) {
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
* @returns {string} - The markdown with unsupported formatting removed
*/
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
export function stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
@@ -55,6 +60,9 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
);
}, markdown);
if (!cleanWhitespace) return result;
// Clean whitespace for signatures
return result
.split('\n')
.map(line => line.trim())
@@ -155,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedSignatureMarkdown(signature, channelType)
? stripUnsupportedMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
@@ -178,7 +186,7 @@ export function appendSignature(body, signature, channelType) {
export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null;
const signaturesToTry = [
cleanSignature(signature),
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {
@@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -5,7 +5,7 @@ import {
replaceSignature,
cleanSignature,
extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown,
stripUnsupportedMarkdown,
insertAtCursor,
findNodeToInsertImage,
setURLWithQueryAndSize,
@@ -145,25 +145,19 @@ describe('appendSignature', () => {
});
});
describe('stripUnsupportedSignatureMarkdown', () => {
describe('stripUnsupportedMarkdown', () => {
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'
);
const result = stripUnsupportedMarkdown(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'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
expect(result).toContain('**Bold**');
expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept
@@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); // image removed
});
it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown(
richSignature,
'Channel::Telegram'
);
const result = stripUnsupportedMarkdown(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'
);
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
expect(result).toContain('Bold');
expect(result).toContain('italic');
expect(result).toContain('link');
@@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![](');
});
it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
});
describe('with cleanWhitespace parameter', () => {
const textWithWhitespace =
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
it('cleans whitespace when cleanWhitespace=true (default)', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
true
);
expect(result).toBe(
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
);
expect(result).not.toContain('\n\n');
expect(result).not.toContain(' ');
});
it('preserves whitespace when cleanWhitespace=false', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
false
);
expect(result).toContain('\n\n');
expect(result).toContain(' And spaces ');
expect(result).toBe(
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
);
});
it('strips formatting but preserves whitespace for messages', () => {
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
const result = stripUnsupportedMarkdown(
messageWithFormatting,
'Channel::Sms',
false
);
expect(result).toBe('Bold\n\ncode\n\nNormal text');
expect(result).toContain('\n\n');
expect(result).not.toContain('**');
expect(result).not.toContain('`');
});
});
});
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
@@ -247,6 +247,7 @@
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Bot",
@@ -7,6 +7,7 @@
},
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"PREFERRED": "Preferred"
}
}
@@ -403,6 +403,7 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -3,7 +3,7 @@
"HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
"METRICS": {
@@ -378,7 +378,56 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"SECURITY": "Security",
"CAPTAIN_AI": "Captain"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
}
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -370,7 +370,7 @@ onUnmounted(() => {
/>
</div>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-5xl mx-auto z-[60]">
<div class="w-full max-w-5xl mx-auto z-30">
<div class="flex flex-col w-full px-4">
<SearchHeader
v-model:filters="filters"
@@ -0,0 +1,182 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import ModelSelector from './components/ModelSelector.vue';
import FeatureToggle from './components/FeatureToggle.vue';
import CaptainPaywall from 'next/captain/pageComponents/Paywall.vue';
const { t } = useI18n();
const { captainEnabled } = useCaptain();
const { isEnterprise, enterprisePlanName } = useConfig();
const { isOnChatwootCloud } = useAccount();
const captainConfigStore = useCaptainConfigStore();
const { uiFlags } = storeToRefs(captainConfigStore);
const isLoading = computed(() => uiFlags.value.isFetching);
const modelFeatures = computed(() => [
{
key: 'editor',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.EDITOR.DESCRIPTION'),
},
{
key: 'assistant',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.ASSISTANT.DESCRIPTION'),
enterprise: true,
},
{
key: 'copilot',
title: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.TITLE'),
description: t('CAPTAIN_SETTINGS.MODEL_CONFIG.COPILOT.DESCRIPTION'),
enterprise: true,
},
]);
const featureToggles = computed(() => [
{
key: 'label_suggestion',
},
{
key: 'help_center_search',
enterprise: true,
},
{
key: 'audio_transcription',
enterprise: true,
},
]);
const shouldShowFeature = feature => {
// Cloud will always see these features as long as captain is enabled
if (isOnChatwootCloud.value && captainEnabled) {
return true;
}
if (feature.enterprise) {
// if the app is in enterprise mode, then we can show the feature
// this is not the installation plan, but when the enterprise folder is missing
return isEnterprise;
}
return true;
};
const isFeatureAccessible = feature => {
// Cloud will always see these features as long as captain is enabled
if (isOnChatwootCloud.value && captainEnabled) {
return true;
}
if (feature.enterprise) {
// plan is shown, but is it accessible?
// This ensures that the instance has purchased the enterprise license, and only then we allow
// access
return isEnterprise && enterprisePlanName === 'enterprise';
}
return true;
};
async function handleFeatureToggle({ feature, enabled }) {
try {
await captainConfigStore.updatePreferences({
captain_features: { [feature]: enabled },
});
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
captainConfigStore.fetch();
}
}
async function handleModelChange({ feature, model }) {
try {
await captainConfigStore.updatePreferences({
captain_models: { [feature]: model },
});
useAlert(t('CAPTAIN_SETTINGS.API.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN_SETTINGS.API.ERROR'));
captainConfigStore.fetch();
}
}
onMounted(() => {
captainConfigStore.fetch();
});
</script>
<template>
<SettingsLayout
:is-loading="isLoading"
:no-records-message="t('CAPTAIN_SETTINGS.NOT_ENABLED')"
:loading-message="t('CAPTAIN_SETTINGS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.TITLE')"
:description="t('CAPTAIN_SETTINGS.DESCRIPTION')"
:link-text="t('CAPTAIN_SETTINGS.LINK_TEXT')"
icon-name="captain"
feature-name="captain_billing"
/>
</template>
<template #body>
<div v-if="captainEnabled" class="flex flex-col gap-1">
<!-- Model Configuration Section -->
<SectionLayout
:title="t('CAPTAIN_SETTINGS.MODEL_CONFIG.TITLE')"
:description="t('CAPTAIN_SETTINGS.MODEL_CONFIG.DESCRIPTION')"
>
<div class="grid gap-4">
<ModelSelector
v-for="feature in modelFeatures"
v-show="shouldShowFeature(feature)"
:key="feature.key"
:is-allowed="isFeatureAccessible(feature)"
:feature-key="feature.key"
:title="feature.title"
:description="feature.description"
@change="handleModelChange"
/>
</div>
</SectionLayout>
<!-- Features Section -->
<SectionLayout
:title="t('CAPTAIN_SETTINGS.FEATURES.TITLE')"
:description="t('CAPTAIN_SETTINGS.FEATURES.DESCRIPTION')"
with-border
>
<div class="grid gap-4">
<FeatureToggle
v-for="feature in featureToggles"
v-show="shouldShowFeature(feature)"
:key="feature.key"
:is-allowed="isFeatureAccessible(feature)"
:feature-key="feature.key"
@change="handleFeatureToggle"
@model-change="handleModelChange"
/>
</div>
</SectionLayout>
</div>
<div v-else>
<CaptainPaywall />
</div>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,38 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/captain'),
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
component: SettingsWrapper,
props: {
headerTitle: 'CAPTAIN_SETTINGS.TITLE',
icon: 'i-lucide-bot',
showNewButton: false,
},
children: [
{
path: '',
name: 'captain_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.ENTERPRISE,
INSTALLATION_TYPES.CLOUD,
],
},
},
],
},
],
};
@@ -0,0 +1,144 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import ModelDropdown from './ModelDropdown.vue';
const props = defineProps({
featureKey: {
type: String,
required: true,
},
isAllowed: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['change', 'modelChange']);
const { t } = useI18n();
const captainConfigStore = useCaptainConfigStore();
const { features } = storeToRefs(captainConfigStore);
const availableModels = computed(() =>
captainConfigStore.getModelsForFeature(props.featureKey)
);
const isEnabled = ref(false);
const featureConfig = computed(() => features.value[props.featureKey]);
const hasMultipleModels = computed(() => {
return availableModels.value && availableModels.value.length > 1;
});
const showModelSelector = computed(() => {
return isEnabled.value && hasMultipleModels.value;
});
const title = computed(() => {
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.TITLE');
}
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
}
return '';
});
const description = computed(() => {
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
}
return '';
});
const modelTitle = computed(() => {
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_TITLE');
}
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
}
return '';
});
const modelDescription = computed(() => {
if (props.featureKey.toUpperCase() === 'AUDIO_TRANSCRIPTION') {
return t('CAPTAIN_SETTINGS.FEATURES.AUDIO_TRANSCRIPTION.MODEL_DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
}
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
}
return '';
});
watch(
featureConfig,
newConfig => {
if (newConfig !== undefined) {
isEnabled.value = !!newConfig.enabled;
}
},
{ immediate: true }
);
const toggleFeature = () => {
emit('change', { feature: props.featureKey, enabled: isEnabled.value });
};
const handleModelChange = ({ feature, model }) => {
emit('modelChange', { feature, model });
};
</script>
<template>
<div
class="p-4 rounded-xl border border-n-weak bg-n-solid-1 flex"
:class="{
'flex-col gap-3': showModelSelector,
'items-center justify-between gap-4': !showModelSelector,
'opacity-60 pointer-events-none': !isAllowed,
}"
>
<div class="flex items-center justify-between gap-4 flex-1">
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">{{ title }}</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
</div>
<div v-if="isAllowed" class="flex-shrink-0">
<Switch v-model="isEnabled" @change="toggleFeature" />
</div>
</div>
<div
v-if="showModelSelector && isAllowed"
class="flex gap-2 ps-8 relative before:content-[''] before:absolute before:w-0.5 before:h-1/2 before:top-0 before:start-3 before:bg-n-weak after:content-[''] after:absolute after:w-2.5 after:h-3 after:top-[calc(50%-6px)] after:start-3 after:border-b-[0.125rem] after:border-s-[0.125rem] after:rounded-es after:border-n-weak"
>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">{{ modelTitle }}</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ modelDescription }}</p>
</div>
<div class="flex justify-end">
<ModelDropdown :feature-key="featureKey" @change="handleModelChange" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptainConfigStore } from 'dashboard/store/captain/preferences';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownBody from 'dashboard/components-next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'dashboard/components-next/dropdown-menu/base/DropdownItem.vue';
import { provideDropdownContext } from 'dashboard/components-next/dropdown-menu/base/provider.js';
const props = defineProps({
featureKey: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const { isOnChatwootCloud } = useAccount();
const PROVIDER_ICONS = {
openai: 'i-ri-openai-fill',
anthropic: 'i-ri-anthropic-line',
mistral: 'i-logos-mistral-icon',
gemini: 'i-woot-gemini',
};
const iconForModel = model => {
return PROVIDER_ICONS[model.provider];
};
const { t } = useI18n();
const captainConfigStore = useCaptainConfigStore();
const isOpen = ref(false);
const availableModels = computed(() =>
captainConfigStore.getModelsForFeature(props.featureKey)
);
const recommendedModelId = computed(() =>
captainConfigStore.getDefaultModelForFeature(props.featureKey)
);
const selectedModel = computed(() =>
captainConfigStore.getSelectedModelForFeature(props.featureKey)
);
const selectedModelId = ref(null);
watch(
selectedModel,
newSelected => {
if (newSelected) {
selectedModelId.value = newSelected;
}
},
{ immediate: true }
);
const selectedModelDetails = computed(() => {
if (!selectedModelId.value) return null;
return (
availableModels.value.find(m => m.id === selectedModelId.value) || null
);
});
const getCreditLabel = model => {
const multiplier = model.credit_multiplier || 1;
return t('CAPTAIN_SETTINGS.MODEL_CONFIG.CREDITS_PER_MESSAGE', {
credits: multiplier,
});
};
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const closeDropdown = () => {
isOpen.value = false;
};
provideDropdownContext({
isOpen,
toggle: () => toggleDropdown(),
closeMenu: closeDropdown,
});
const selectModel = model => {
selectedModelId.value = model.id;
emit('change', { feature: props.featureKey, model: model.id });
closeDropdown();
};
</script>
<template>
<div v-on-clickaway="closeDropdown" class="relative flex-shrink-0">
<button
type="button"
class="flex items-center gap-2 px-3 py-2 text-sm border rounded-lg border-n-weak dark:bg-n-solid-2 dark:hover:bg-n-solid-3 bg-n-alpha-2 hover:bg-n-alpha-1 min-w-[180px] justify-between"
@click="toggleDropdown"
>
<span v-if="selectedModelDetails" class="text-n-slate-12">
{{ selectedModelDetails.display_name }}
</span>
<span v-else class="text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.SELECT_MODEL') }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="size-4 text-n-slate-11 transition-transform"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<DropdownBody
v-if="isOpen"
class="absolute right-0 top-full mt-1 min-w-64 z-50 max-h-96 [&>ul]:max-h-96 [&>ul]:overflow-y-scroll"
>
<DropdownItem
v-for="model in availableModels"
:key="model.id"
:click="() => selectModel(model)"
class="rounded-lg dark:hover:bg-n-solid-3 hover:bg-n-alpha-1"
:class="{
'dark:bg-n-solid-3 bg-n-alpha-1': selectedModelId === model.id,
'pointer-events-none opacity-60': model.coming_soon,
}"
>
<div class="flex gap-2 w-full">
<Icon :icon="iconForModel(model)" class="size-4 flex-shrink-0" />
<div class="flex flex-col w-full text-left gap-1">
<div
class="text-sm w-full font-medium leading-none text-n-slate-12 flex items-baseline justify-between"
>
{{ model.display_name }}
<span
v-if="model.id === recommendedModelId"
class="text-[10px] uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.PREFERRED') }}
</span>
</div>
<span v-if="model.coming_soon" class="text-xs text-n-slate-11">
{{ t('CAPTAIN_SETTINGS.MODEL_CONFIG.COMING_SOON') }}
</span>
<span v-else-if="isOnChatwootCloud" class="text-xs text-n-slate-11">
{{ getCreditLabel(model) }}
</span>
</div>
</div>
</DropdownItem>
</DropdownBody>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import ModelDropdown from './ModelDropdown.vue';
defineProps({
featureKey: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isAllowed: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(['change']);
const handleModelChange = ({ feature, model }) => {
emit('change', { feature, model });
};
</script>
<template>
<div
class="flex items-center justify-between gap-4 p-4 rounded-xl border border-n-weak bg-n-solid-1"
:class="{ 'opacity-60 pointer-events-none relative': !isAllowed }"
>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-n-slate-12">
{{ title }}
</h4>
<p class="text-sm text-n-slate-11 mt-0.5">{{ description }}</p>
</div>
<ModelDropdown
v-if="isAllowed"
:feature-key="featureKey"
@change="handleModelChange"
/>
</div>
</template>
@@ -28,10 +28,15 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
props.inbox?.id
);
// Computed to check if it's any type of WhatsApp channel (Cloud or Twilio)
const isAnyWhatsAppChannel = computed(
() => isAWhatsAppChannel.value || isATwilioWhatsAppChannel.value
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => {
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
// Convert status to uppercase for consistency with statusMap keys
const normalizedStatus = templateStatus.value.status.toUpperCase();
return statusMap[normalizedStatus] || statusMap.PENDING;
}
// Default case - no template exists
@@ -155,7 +162,7 @@ const initializeState = () => {
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
if (isAnyWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
@@ -165,7 +172,7 @@ const initializeState = () => {
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
if (!isAnyWhatsAppChannel.value) return;
try {
templateLoading.value = true;
@@ -195,7 +202,7 @@ const checkTemplateStatus = async () => {
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
if (isAnyWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -225,7 +232,7 @@ const removeLabel = label => {
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
if (!isAnyWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
@@ -254,10 +261,28 @@ const shouldCreateTemplate = () => {
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
if (!hasExistingTemplate()) {
return null;
}
const { template_name, template_id, template, status } =
templateStatus.value || {};
if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp format - get from existing template config
const existingTemplate = props.inbox?.csat_config?.template;
return existingTemplate
? {
friendly_name: existingTemplate.friendly_name,
content_sid: existingTemplate.content_sid,
language: existingTemplate.language || state.templateLanguage,
status: existingTemplate.status || status,
}
: null;
}
// WhatsApp Cloud format
return {
name: template_name,
template_id,
@@ -273,11 +298,11 @@ const updateInbox = async attributes => {
...attributes,
};
return store.dispatch('inboxes/updateInbox', payload);
await store.dispatch('inboxes/updateInbox', payload);
};
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
if (!isAnyWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
@@ -298,7 +323,7 @@ const performSave = async () => {
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
isAnyWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
@@ -326,13 +351,25 @@ const performSave = async () => {
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp template format
csatConfig.template = {
friendly_name: newTemplateData.friendly_name,
content_sid: newTemplateData.content_sid,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
// WhatsApp Cloud template format
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
}
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
@@ -356,8 +393,9 @@ const performSave = async () => {
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
if (
isWhatsAppChannel.value &&
isAnyWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
@@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
v-if="!isAnyWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -400,7 +438,7 @@ const handleConfirmTemplateUpdate = async () => {
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<template v-if="isAnyWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isWhatsAppChannel
isAnyWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
@@ -76,14 +76,14 @@ export default {
businessHours,
};
},
downloadAgentReports() {
downloadConversationReports() {
const { from, to } = this;
const fileName = generateFileName({
type: 'agent',
type: 'conversation',
to,
businessHours: this.businessHours,
});
this.$store.dispatch('downloadAgentReports', {
this.$store.dispatch('downloadConversationsSummaryReports', {
from,
to,
fileName,
@@ -109,10 +109,10 @@ export default {
<template>
<ReportHeader :header-title="$t('REPORT.HEADER')">
<V4Button
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
:label="$t('REPORT.DOWNLOAD_CONVERSATION_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="downloadAgentReports"
@click="downloadConversationReports"
/>
</ReportHeader>
<div class="flex flex-col gap-3">
@@ -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 captain from './captain/captain.routes';
export default {
routes: [
@@ -63,5 +64,6 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...captain.routes,
],
};
@@ -0,0 +1,71 @@
import { defineStore } from 'pinia';
import CaptainPreferencesAPI from 'dashboard/api/captain/preferences';
export const useCaptainConfigStore = defineStore('captainConfig', {
state: () => ({
providers: {},
models: {},
features: {},
uiFlags: {
isFetching: false,
},
}),
getters: {
getProviders: state => state.providers,
getModels: state => state.models,
getFeatures: state => state.features,
getUIFlags: state => state.uiFlags,
getModelsForFeature: state => featureKey => {
const feature = state.features[featureKey];
const models = feature?.models || [];
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
return [...models].sort((a, b) => {
// Move coming_soon items to the end
if (a.coming_soon && !b.coming_soon) return 1;
if (!a.coming_soon && b.coming_soon) return -1;
// Sort by provider
const providerA = providerOrder[a.provider] ?? 999;
const providerB = providerOrder[b.provider] ?? 999;
if (providerA !== providerB) return providerA - providerB;
// Sort by credit_multiplier (highest first)
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
});
},
getDefaultModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.default || null;
},
getSelectedModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.selected || feature?.default || null;
},
},
actions: {
async fetch() {
this.uiFlags.isFetching = true;
try {
const response = await CaptainPreferencesAPI.get();
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
} catch (error) {
// Ignore error
} finally {
this.uiFlags.isFetching = false;
}
},
async updatePreferences(data) {
const response = await CaptainPreferencesAPI.updatePreferences(data);
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
},
},
});
@@ -78,7 +78,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
case 'team_id':
return conversation.meta?.team?.id;
case 'browser_language':
case 'country_code':
case 'referer':
return conversation.additional_attributes?.[attributeKey];
default:
@@ -234,6 +234,19 @@ export const actions = {
console.error(error);
});
},
downloadConversationsSummaryReports(_, reportObj) {
return Report.getConversationsSummaryReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'conversations_summary',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj)
.then(response => {
@@ -201,4 +201,24 @@ describe('#actions', () => {
);
});
});
describe('#downloadConversationsSummaryReports', () => {
it('open CSV download prompt if API is success', async () => {
const data = `Conversations,Messages received,Messages sent,Avg first response time,Avg resolution time,Resolution count,Avg customer waiting time
217,323,623,23 hours 22 minutes,179 days 18 hours,30,48 days 4 hours`;
axios.get.mockResolvedValue({ data });
const param = {
from: 1631039400,
to: 1635013800,
fileName: 'conversations-summary-report-24-10-2021.csv',
};
actions.downloadConversationsSummaryReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
);
});
});
});
@@ -1,3 +1,7 @@
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
export const formatBytes = (bytes, decimals = 2) => {
@@ -31,3 +35,55 @@ export const resolveMaximumFileUploadSize = value => {
return parsedValue;
};
/**
* Validates if a file type is allowed for a specific channel
* @param {File} file - The file to validate
* @param {Object} options - Validation options
* @param {string} options.channelType - The channel type
* @param {string} options.medium - The channel medium
* @param {string} options.conversationType - The conversation type (for Instagram DM detection)
* @param {boolean} options.isInstagramChannel - Whether it's an Instagram channel
* @param {boolean} options.isOnPrivateNote - Whether composing a private note (uses broader file type list)
* @returns {boolean} - True if file type is allowed, false otherwise
*/
export const isFileTypeAllowedForChannel = (file, options = {}) => {
if (!file || file.size === 0) return false;
const {
channelType: originalChannelType,
medium,
conversationType,
isInstagramChannel,
isOnPrivateNote,
} = options;
// Use broader file types for private notes (matches file picker behavior)
const allowedFileTypes = isOnPrivateNote
? ALLOWED_FILE_TYPES
: getAllowedFileTypesByChannel({
channelType:
isInstagramChannel || conversationType === 'instagram_direct_message'
? INBOX_TYPES.INSTAGRAM
: originalChannelType,
medium,
});
// Convert to array and validate
const allowedTypesArray = allowedFileTypes.split(',').map(t => t.trim());
const fileExtension = `.${file.name.split('.').pop()}`;
return allowedTypesArray.some(allowedType => {
// Check for exact file extension match
if (allowedType === fileExtension) return true;
// Check for wildcard MIME type (e.g., image/*)
if (allowedType.endsWith('/*')) {
const prefix = allowedType.slice(0, -2); // Remove '/*'
return file.type.startsWith(prefix + '/');
}
// Check for exact MIME type match
return allowedType === file.type;
});
};
@@ -4,6 +4,7 @@ import {
fileSizeInMegaBytes,
checkFileSizeLimit,
resolveMaximumFileUploadSize,
isFileTypeAllowedForChannel,
} from '../FileHelper';
describe('#File Helpers', () => {
@@ -61,4 +62,187 @@ describe('#File Helpers', () => {
expect(resolveMaximumFileUploadSize(75)).toBe(75);
});
});
describe('isFileTypeAllowedForChannel', () => {
describe('edge cases', () => {
it('should return false for null file', () => {
expect(isFileTypeAllowedForChannel(null)).toBe(false);
});
it('should return false for undefined file', () => {
expect(isFileTypeAllowedForChannel(undefined)).toBe(false);
});
it('should return false for file with zero size', () => {
const file = { name: 'test.png', type: 'image/png', size: 0 };
expect(isFileTypeAllowedForChannel(file)).toBe(false);
});
});
describe('wildcard MIME types', () => {
it('should allow image/png when image/* is allowed', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow image/jpeg when image/* is allowed', () => {
const file = { name: 'test.jpg', type: 'image/jpeg', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow audio/mp3 when audio/* is allowed', () => {
const file = { name: 'test.mp3', type: 'audio/mp3', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow video/mp4 when video/* is allowed', () => {
const file = { name: 'test.mp4', type: 'video/mp4', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('exact MIME types', () => {
it('should allow application/pdf when explicitly allowed', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
it('should allow text/plain when explicitly allowed', () => {
const file = { name: 'test.txt', type: 'text/plain', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('file extensions', () => {
it('should allow .3gpp extension when explicitly allowed', () => {
const file = { name: 'test.3gpp', type: '', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(true);
});
});
describe('Instagram special handling', () => {
it('should use Instagram rules when isInstagramChannel is true', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
isInstagramChannel: true,
})
).toBe(true);
});
it('should use Instagram rules when conversationType is instagram_direct_message', () => {
const file = { name: 'test.png', type: 'image/png', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
conversationType: 'instagram_direct_message',
})
).toBe(true);
});
});
describe('disallowed file types', () => {
it('should reject executable files', () => {
const file = {
name: 'malware.exe',
type: 'application/x-msdownload',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(false);
});
it('should reject unsupported file types', () => {
const file = {
name: 'test.xyz',
type: 'application/x-unknown',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::WebWidget',
})
).toBe(false);
});
});
describe('channel-specific rules', () => {
it('should allow WhatsApp-specific file types', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Whatsapp',
})
).toBe(true);
});
it('should allow Twilio WhatsApp-specific file types', () => {
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::TwilioSms',
medium: 'whatsapp',
})
).toBe(true);
});
});
describe('private note file types', () => {
it('should allow broader file types for private notes', () => {
const file = {
name: 'test.pdf',
type: 'application/pdf',
size: 1000,
};
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Line',
isOnPrivateNote: true,
})
).toBe(true);
});
it('should allow CSV files in private notes', () => {
const file = { name: 'data.csv', type: 'text/csv', size: 1000 };
expect(
isFileTypeAllowedForChannel(file, {
channelType: 'Channel::Line',
isOnPrivateNote: true,
})
).toBe(true);
});
});
});
});
+9
View File
@@ -82,6 +82,7 @@ export default {
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
this.setLocale(locale);
this.setWidgetColor(widgetColor);
this.setWidgetColorVariable(widgetColor);
setHeader(window.authToken);
if (this.isIFrame) {
this.registerListeners();
@@ -114,6 +115,14 @@ export default {
'resetCampaign',
]),
...mapActions('agent', ['fetchAvailableAgents']),
setWidgetColorVariable(widgetColor) {
if (widgetColor) {
document.documentElement.style.setProperty(
'--widget-color',
widgetColor
);
}
},
scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap');
container.scrollTop = container.scrollHeight;
@@ -130,7 +130,7 @@ export default {
<div
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
:class="{
'!shadow-n-brand dark:!shadow-n-brand': isFocused,
'!shadow-[var(--widget-color,#2781f6)]': isFocused,
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
}"
@keydown.esc="hideEmojiPicker"
+5 -2
View File
@@ -159,8 +159,11 @@ class ActionCableListener < BaseListener
end
def contact_deleted(event)
contact, account = extract_contact_and_account(event)
broadcast(account, [account_token(account)], CONTACT_DELETED, contact.push_event_data)
contact_data = event.data[:contact_data]
account = Account.find_by(id: contact_data[:account_id])
return if account.blank?
broadcast(account, [account_token(account)], CONTACT_DELETED, contact_data)
end
def conversation_mentioned(event)
+27
View File
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
include Reportable
include Featurable
include CacheKeys
include CaptainFeaturable
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
@@ -41,6 +42,30 @@ class Account < ApplicationRecord
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
},
'captain_models': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[string null] },
'assistant': { 'type': %w[string null] },
'copilot': { 'type': %w[string null] },
'label_suggestion': { 'type': %w[string null] },
'audio_transcription': { 'type': %w[string null] },
'help_center_search': { 'type': %w[string null] }
},
'additionalProperties': false
},
'captain_features': {
'type': %w[object null],
'properties': {
'editor': { 'type': %w[boolean null] },
'assistant': { 'type': %w[boolean null] },
'copilot': { 'type': %w[boolean null] },
'label_suggestion': { 'type': %w[boolean null] },
'audio_transcription': { 'type': %w[boolean null] },
'help_center_search': { 'type': %w[boolean null] }
},
'additionalProperties': false
}
},
'required': [],
@@ -59,7 +84,9 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
store_accessor :settings, :captain_models, :captain_features
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async
+62
View File
@@ -0,0 +1,62 @@
# frozen_string_literal: true
module CaptainFeaturable
extend ActiveSupport::Concern
included do
validate :validate_captain_models
# Dynamically define accessor methods for each captain feature
Llm::Models.feature_keys.each do |feature_key|
# Define enabled? methods (e.g., captain_editor_enabled?)
define_method("captain_#{feature_key}_enabled?") do
captain_features_with_defaults[feature_key]
end
# Define model accessor methods (e.g., captain_editor_model)
define_method("captain_#{feature_key}_model") do
captain_models_with_defaults[feature_key]
end
end
end
def captain_preferences
{
models: captain_models_with_defaults,
features: captain_features_with_defaults
}.with_indifferent_access
end
private
def captain_models_with_defaults
stored_models = captain_models || {}
Llm::Models.feature_keys.each_with_object({}) do |feature_key, result|
stored_value = stored_models[feature_key]
result[feature_key] = if stored_value.present? && Llm::Models.valid_model_for?(feature_key, stored_value)
stored_value
else
Llm::Models.default_model_for(feature_key)
end
end
end
def captain_features_with_defaults
stored_features = captain_features || {}
Llm::Models.feature_keys.index_with do |feature_key|
stored_features[feature_key] == true
end
end
def validate_captain_models
return if captain_models.blank?
captain_models.each do |feature_key, model_name|
next if model_name.blank?
next if Llm::Models.valid_model_for?(feature_key, model_name)
allowed_models = Llm::Models.models_for(feature_key)
errors.add(:captain_models, "'#{model_name}' is not a valid model for #{feature_key}. Allowed: #{allowed_models.join(', ')}")
end
end
end
+10 -6
View File
@@ -179,11 +179,9 @@ class Contact < ApplicationRecord
end
def self.resolved_contacts(use_crm_v2: false)
if use_crm_v2
where(contact_type: 'lead')
else
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
return where(contact_type: 'lead') if use_crm_v2
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
@@ -243,7 +241,13 @@ class Contact < ApplicationRecord
end
def dispatch_destroy_event
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
# Pass serialized data instead of ActiveRecord object to avoid DeserializationError
# when the async EventDispatcherJob runs after the contact has been deleted
Rails.configuration.dispatcher.dispatch(
CONTACT_DELETED,
Time.zone.now,
contact_data: push_event_data.merge(account_id: account_id)
)
end
end
Contact.include_mod_with('Concerns::Contact')
+4
View File
@@ -158,6 +158,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Whatsapp'
end
def twilio_whatsapp?
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
+40 -2
View File
@@ -6,6 +6,8 @@ class CsatSurveyService
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
send_twilio_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
@@ -45,7 +47,7 @@ class CsatSurveyService
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
@@ -55,9 +57,25 @@ class CsatSurveyService
false
end
def twilio_template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
content_sid = template_config['content_sid']
return false unless content_sid
template_service = Twilio::CsatTemplateService.new(inbox.channel)
status_result = template_service.get_template_status(content_sid)
status_result[:success] && status_result[:template][:status] == 'approved'
rescue StandardError => e
Rails.logger.error "Error checking Twilio CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
@@ -95,6 +113,26 @@ class CsatSurveyService
)
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
phone_number = conversation.contact_inbox.source_id
content_variables = { '1' => conversation.uuid }
message = build_csat_message
send_service = Twilio::SendOnTwilioService.new(message: message)
result = send_service.send_csat_template_message(
phone_number: phone_number,
content_sid: content_sid,
content_variables: content_variables
)
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
rescue StandardError => e
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -0,0 +1,196 @@
class CsatTemplateManagementService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
def initialize(inbox)
@inbox = inbox
end
def template_status
template = @inbox.csat_config&.dig('template')
return { template_exists: false } unless template
if @inbox.twilio_whatsapp?
get_twilio_template_status(template)
else
get_whatsapp_template_status(template)
end
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
{ service_error: e.message }
end
def create_template(template_params)
validate_template_params!(template_params)
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
update_inbox_csat_config(result) if result[:success]
result
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
{ success: false, service_error: 'Template creation failed' }
end
private
def validate_template_params!(template_params)
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
end
def create_template_via_provider(template_params)
if @inbox.twilio_whatsapp?
create_twilio_template(template_params)
else
create_whatsapp_template(template_params)
end
end
def create_twilio_template(template_params)
template_config = build_template_config(template_params)
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
template_service.create_template(template_config)
end
def create_whatsapp_template(template_params)
template_config = build_template_config(template_params)
Whatsapp::CsatTemplateService.new(@inbox.channel).create_template(template_config)
end
def build_template_config(template_params)
{
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: CsatTemplateNameService.csat_template_name(@inbox.id)
}
end
def update_inbox_csat_config(result)
current_config = @inbox.csat_config || {}
template_data = build_template_data_from_result(result)
updated_config = current_config.merge('template' => template_data)
@inbox.update!(csat_config: updated_config)
end
def build_template_data_from_result(result)
if @inbox.twilio_whatsapp?
build_twilio_template_data(result)
else
build_whatsapp_cloud_template_data(result)
end
end
def build_twilio_template_data(result)
{
'friendly_name' => result[:friendly_name],
'content_sid' => result[:content_sid],
'approval_sid' => result[:approval_sid],
'language' => result[:language],
'status' => result[:whatsapp_status] || result[:status],
'created_at' => Time.current.iso8601
}.compact
end
def build_whatsapp_cloud_template_data(result)
{
'name' => result[:template_name],
'template_id' => result[:template_id],
'language' => result[:language],
'created_at' => Time.current.iso8601
}
end
def get_twilio_template_status(template)
content_sid = template['content_sid']
return { template_exists: false } unless content_sid
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
status_result = template_service.get_template_status(content_sid)
if status_result[:success]
{
template_exists: true,
friendly_name: template['friendly_name'],
content_sid: template['content_sid'],
status: status_result[:template][:status],
language: template['language']
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def get_whatsapp_template_status(template)
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
if status_result[:success]
{
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
if @inbox.twilio_whatsapp?
delete_existing_twilio_template(template)
else
delete_existing_whatsapp_template(template)
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def delete_existing_twilio_template(template)
content_sid = template['content_sid']
return true if content_sid.blank?
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
deletion_result = template_service.delete_template(nil, content_sid)
if deletion_result[:success]
Rails.logger.info "Deleted existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
end
def delete_existing_whatsapp_template(template)
template_name = template['name']
return true if template_name.blank?
csat_template_service = Whatsapp::CsatTemplateService.new(@inbox.channel)
template_status = csat_template_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = csat_template_service.delete_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
end
end
@@ -1,4 +1,4 @@
class Whatsapp::CsatTemplateNameService
class CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
@@ -0,0 +1,68 @@
class Twilio::CsatTemplateApiClient
def initialize(twilio_channel)
@twilio_channel = twilio_channel
end
def create_template(request_body)
HTTParty.post(
"#{api_base_path}/v1/Content",
headers: api_headers,
body: request_body.to_json
)
end
def submit_for_approval(approval_url, template_name, category)
request_body = {
name: template_name,
category: category
}
HTTParty.post(
approval_url,
headers: api_headers,
body: request_body.to_json
)
end
def delete_template(content_sid)
HTTParty.delete(
"#{api_base_path}/v1/Content/#{content_sid}",
headers: api_headers
)
end
def fetch_template(content_sid)
HTTParty.get(
"#{api_base_path}/v1/Content/#{content_sid}",
headers: api_headers
)
end
def fetch_approval_status(content_sid)
HTTParty.get(
"#{api_base_path}/v1/Content/#{content_sid}/ApprovalRequests",
headers: api_headers
)
end
private
def api_headers
{
'Authorization' => "Basic #{encoded_credentials}",
'Content-Type' => 'application/json'
}
end
def encoded_credentials
if @twilio_channel.api_key_sid.present?
Base64.strict_encode64("#{@twilio_channel.api_key_sid}:#{@twilio_channel.auth_token}")
else
Base64.strict_encode64("#{@twilio_channel.account_sid}:#{@twilio_channel.auth_token}")
end
end
def api_base_path
'https://content.twilio.com'
end
end
@@ -0,0 +1,204 @@
class Twilio::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
TEMPLATE_CATEGORY = 'UTILITY'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
TEMPLATE_CONTENT_TYPE = 'twilio/call-to-action'.freeze
def initialize(twilio_channel)
@twilio_channel = twilio_channel
@api_client = Twilio::CsatTemplateApiClient.new(twilio_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)
# Step 1: Create template
response = @api_client.create_template(request_body)
return process_template_creation_response(response, template_config_with_name) unless response.success? && response['sid']
# Step 2: Submit for WhatsApp approval using the approval_create URL
approval_url = response.dig('links', 'approval_create')
if approval_url.present?
approval_response = submit_for_whatsapp_approval(approval_url, template_config_with_name[:template_name])
process_approval_response(approval_response, response, template_config_with_name)
else
Rails.logger.warn 'No approval_create URL provided in template creation response'
# Fallback if no approval URL provided
process_template_creation_response(response, template_config_with_name)
end
end
def delete_template(_template_name = nil, content_sid = nil)
content_sid ||= current_template_sid_from_config
return { success: false, error: 'No template to delete' } unless content_sid
response = @api_client.delete_template(content_sid)
{ success: response.success?, response_body: response.body }
end
def get_template_status(content_sid)
return { success: false, error: 'No content SID provided' } unless content_sid
template_response = fetch_template_details(content_sid)
return template_response unless template_response[:success]
approval_response = fetch_approval_status(content_sid)
build_template_status_response(content_sid, template_response[:data], approval_response)
rescue StandardError => e
Rails.logger.error "Error fetching Twilio template status: #{e.message}"
{ success: false, error: e.message }
end
private
def fetch_template_details(content_sid)
response = @api_client.fetch_template(content_sid)
if response.success?
{ success: true, data: response }
else
Rails.logger.error "Failed to get template details: #{response.code} - #{response.body}"
{ success: false, error: 'Template not found' }
end
end
def fetch_approval_status(content_sid)
@api_client.fetch_approval_status(content_sid)
end
def build_template_status_response(content_sid, template_response, approval_response)
if approval_response.success? && approval_response['whatsapp']
build_approved_template_response(content_sid, template_response, approval_response['whatsapp'])
else
build_pending_template_response(content_sid, template_response)
end
end
def build_approved_template_response(content_sid, template_response, whatsapp_data)
{
success: true,
template: {
content_sid: content_sid,
friendly_name: whatsapp_data['name'] || template_response['friendly_name'],
status: whatsapp_data['status'] || 'pending',
language: template_response['language'] || 'en'
}
}
end
def build_pending_template_response(content_sid, template_response)
{
success: true,
template: {
content_sid: content_sid,
friendly_name: template_response['friendly_name'],
status: 'pending',
language: template_response['language'] || 'en'
}
}
end
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
CsatTemplateNameService.generate_next_template_name(base_name, @twilio_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@twilio_channel.inbox.csat_config&.dig('template', 'friendly_name')
end
def current_template_sid_from_config
@twilio_channel.inbox.csat_config&.dig('template', 'content_sid')
end
def template_exists_in_config?
content_sid = current_template_sid_from_config
friendly_name = current_template_name_from_config
content_sid.present? && friendly_name.present?
end
def build_template_request_body(template_config)
{
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
variables: {
'1' => '12345' # Example conversation UUID
},
types: {
TEMPLATE_CONTENT_TYPE => {
body: template_config[:message],
actions: [
{
type: 'URL',
title: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}"
}
]
}
}
}
end
def submit_for_whatsapp_approval(approval_url, template_name)
@api_client.submit_for_approval(approval_url, template_name, TEMPLATE_CATEGORY)
end
def process_template_creation_response(response, template_config = {})
if response.success? && response['sid']
{
success: true,
content_sid: response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "Twilio template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def process_approval_response(approval_response, creation_response, template_config)
if approval_response.success?
build_successful_approval_response(approval_response, creation_response, template_config)
else
build_failed_approval_response(approval_response, creation_response, template_config)
end
end
def build_successful_approval_response(approval_response, creation_response, template_config)
approval_data = approval_response.parsed_response
{
success: true,
content_sid: creation_response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING,
approval_sid: approval_data['sid'],
whatsapp_status: approval_data.dig('whatsapp', 'status') || TEMPLATE_STATUS_PENDING
}
end
def build_failed_approval_response(approval_response, creation_response, template_config)
Rails.logger.error "Twilio template approval submission failed: #{approval_response.code} - #{approval_response.body}"
{
success: true,
content_sid: creation_response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: 'created'
}
end
end
@@ -1,4 +1,24 @@
class Twilio::SendOnTwilioService < Base::SendOnChannelService
def send_csat_template_message(phone_number:, content_sid:, content_variables: {})
send_params = {
to: phone_number,
content_sid: content_sid
}
send_params[:content_variables] = content_variables.to_json if content_variables.present?
send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true)
# Add messaging service or from number
send_params = send_params.merge(channel.send(:send_message_from))
twilio_message = channel.send(:client).messages.create(**send_params)
{ success: true, message_id: twilio_message.sid }
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
Rails.logger.error "Failed to send Twilio template message: #{e.message}"
{ success: false, error: e.message }
end
private
def channel_class
@@ -19,7 +19,7 @@ class Whatsapp::CsatTemplateService
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
template_name ||= CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
@@ -51,7 +51,7 @@ class Whatsapp::CsatTemplateService
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)
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@@ -67,7 +67,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
@@ -20,7 +20,9 @@ class Whatsapp::TemplateProcessorService
def find_template
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
t['name'] == template_params['name'] &&
t['language']&.downcase == template_params['language']&.downcase &&
t['status']&.downcase == 'approved'
end
end
@@ -0,0 +1,16 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.conversation_csv.conversations_count'),
I18n.t('reports.conversation_csv.incoming_messages_count'),
I18n.t('reports.conversation_csv.outgoing_messages_count'),
I18n.t('reports.conversation_csv.avg_first_response_time'),
I18n.t('reports.conversation_csv.avg_resolution_time'),
I18n.t('reports.conversation_csv.resolution_count'),
I18n.t('reports.conversation_csv.avg_customer_waiting_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>