mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-06 12:55:26 +00:00
Merge develop into feat/voice-channel
Resolved conflicts: - Voice.vue: Keep modern composition API version from develop - ChannelItem.vue: Add voice channel feature flag check - inboxes_controller.rb: Use allowed_channel_types method - schema.rb: Add account_id index for voice channels - inboxMgmt.json: Fix voice channel translations - Note: Voice channel logic in base files removed in develop (moved to enterprise) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
12
Gemfile.lock
12
Gemfile.lock
@@ -172,6 +172,8 @@ GEM
|
|||||||
bundler (>= 1.2.0, < 3)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
|
childprocess (5.1.0)
|
||||||
|
logger (~> 1.5)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@@ -435,10 +437,12 @@ GEM
|
|||||||
json (>= 1.8)
|
json (>= 1.8)
|
||||||
rexml
|
rexml
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
launchy (2.5.2)
|
launchy (3.1.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
letter_opener (1.8.1)
|
childprocess (~> 5.0)
|
||||||
launchy (>= 2.2, < 3)
|
logger (~> 1.6)
|
||||||
|
letter_opener (1.10.0)
|
||||||
|
launchy (>= 2.2, < 4)
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
liquid (5.4.0)
|
liquid (5.4.0)
|
||||||
@@ -565,7 +569,7 @@ GEM
|
|||||||
method_source (~> 1.0)
|
method_source (~> 1.0)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (6.0.0)
|
public_suffix (6.0.2)
|
||||||
puma (6.4.3)
|
puma (6.4.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.0)
|
pundit (2.3.0)
|
||||||
|
|||||||
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_channel
|
def create_channel
|
||||||
return unless %w[web_widget api email line telegram whatsapp sms voice].include?(permitted_params[:channel][:type])
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||||
|
|
||||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_channel_types
|
||||||
|
%w[web_widget api email line telegram whatsapp sms]
|
||||||
|
end
|
||||||
|
|
||||||
def update_inbox_working_hours
|
def update_inbox_working_hours
|
||||||
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@articles = @portal.articles.published.includes(:category, :author)
|
@articles = @portal.articles.published.includes(:category, :author)
|
||||||
|
|
||||||
|
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||||
|
|
||||||
@articles_count = @articles.count
|
@articles_count = @articles.count
|
||||||
|
|
||||||
search_articles
|
search_articles
|
||||||
order_by_sort_param
|
order_by_sort_param
|
||||||
limit_results
|
limit_results
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
|
|||||||
@mouseenter="emit('hover', true)"
|
@mouseenter="emit('hover', true)"
|
||||||
@mouseleave="emit('hover', false)"
|
@mouseleave="emit('hover', false)"
|
||||||
>
|
>
|
||||||
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
|
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
|
||||||
<Checkbox v-model="modelValue" />
|
<Checkbox v-model="modelValue" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex relative justify-between w-full gap-1">
|
<div class="flex relative justify-between w-full gap-1">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::WebWidget': 'i-ri-global-fill',
|
'Channel::WebWidget': 'i-ri-global-fill',
|
||||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||||
'Channel::Instagram': 'i-ri-instagram-fill',
|
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||||
|
'Channel::Voice': 'i-ri-phone-fill',
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-ri-whatsapp-fill');
|
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Voice channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Voice' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-phone-fill');
|
||||||
|
});
|
||||||
|
|
||||||
describe('Email channel', () => {
|
describe('Email channel', () => {
|
||||||
it('returns mail icon for generic email channel', () => {
|
it('returns mail icon for generic email channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Email' };
|
const inbox = { channel_type: 'Channel::Email' };
|
||||||
|
|||||||
@@ -1,51 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: { type: [String, Number], default: '' },
|
||||||
type: [String, Number],
|
type: { type: String, default: 'text' },
|
||||||
default: '',
|
customInputClass: { type: [String, Object, Array], default: '' },
|
||||||
},
|
placeholder: { type: String, default: '' },
|
||||||
type: {
|
label: { type: String, default: '' },
|
||||||
type: String,
|
id: { type: String, default: '' },
|
||||||
default: 'text',
|
message: { type: String, default: '' },
|
||||||
},
|
disabled: { type: Boolean, default: false },
|
||||||
customInputClass: {
|
|
||||||
type: [String, Object, Array],
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
messageType: {
|
messageType: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'info',
|
default: 'info',
|
||||||
validator: value => ['info', 'error', 'success'].includes(value),
|
validator: value => ['info', 'error', 'success'].includes(value),
|
||||||
},
|
},
|
||||||
min: {
|
min: { type: String, default: '' },
|
||||||
type: String,
|
autofocus: { type: Boolean, default: false },
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
autofocus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -56,6 +26,10 @@ const emit = defineEmits([
|
|||||||
'enter',
|
'enter',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Generate a unique ID per component instance when `id` prop is not provided.
|
||||||
|
const { uid } = getCurrentInstance();
|
||||||
|
const uniqueId = computed(() => props.id || `input-${uid}`);
|
||||||
|
|
||||||
const isFocused = ref(false);
|
const isFocused = ref(false);
|
||||||
const inputRef = ref(null);
|
const inputRef = ref(null);
|
||||||
|
|
||||||
@@ -111,7 +85,7 @@ onMounted(() => {
|
|||||||
<div class="relative flex flex-col min-w-0 gap-1">
|
<div class="relative flex flex-col min-w-0 gap-1">
|
||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="id"
|
:for="uniqueId"
|
||||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -119,7 +93,7 @@ onMounted(() => {
|
|||||||
<!-- Added prefix slot to allow adding icons to the input -->
|
<!-- Added prefix slot to allow adding icons to the input -->
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="uniqueId"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:class="[
|
:class="[
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
<button
|
<button
|
||||||
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
|
||||||
<h3
|
<h3
|
||||||
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const props = defineProps({
|
|||||||
const isRelaxed = computed(() => props.type === 'relaxed');
|
const isRelaxed = computed(() => props.type === 'relaxed');
|
||||||
const headerClass = computed(() =>
|
const headerClass = computed(() =>
|
||||||
isRelaxed.value
|
isRelaxed.value
|
||||||
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
|
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export default {
|
|||||||
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'voice') {
|
||||||
|
return this.enabledFeatures.channel_voice;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'website',
|
'website',
|
||||||
'twilio',
|
'twilio',
|
||||||
@@ -50,6 +55,7 @@ export default {
|
|||||||
'telegram',
|
'telegram',
|
||||||
'line',
|
'line',
|
||||||
'instagram',
|
'instagram',
|
||||||
|
'voice',
|
||||||
].includes(key);
|
].includes(key);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -299,6 +299,46 @@
|
|||||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"VOICE": {
|
||||||
|
"TITLE": "Voice Channel",
|
||||||
|
"DESC": "Integrate Twilio Voice and start supporting your customers via phone calls.",
|
||||||
|
"PHONE_NUMBER": {
|
||||||
|
"LABEL": "Phone Number",
|
||||||
|
"PLACEHOLDER": "Enter your phone number (e.g. +1234567890)",
|
||||||
|
"ERROR": "Please provide a valid phone number in E.164 format (e.g. +1234567890)"
|
||||||
|
},
|
||||||
|
"TWILIO": {
|
||||||
|
"ACCOUNT_SID": {
|
||||||
|
"LABEL": "Account SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio Account SID",
|
||||||
|
"REQUIRED": "Account SID is required"
|
||||||
|
},
|
||||||
|
"AUTH_TOKEN": {
|
||||||
|
"LABEL": "Auth Token",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio Auth Token",
|
||||||
|
"REQUIRED": "Auth Token is required"
|
||||||
|
},
|
||||||
|
"API_KEY_SID": {
|
||||||
|
"LABEL": "API Key SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio API Key SID",
|
||||||
|
"REQUIRED": "API Key SID is required"
|
||||||
|
},
|
||||||
|
"API_KEY_SECRET": {
|
||||||
|
"LABEL": "API Key Secret",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||||
|
"REQUIRED": "API Key Secret is required"
|
||||||
|
},
|
||||||
|
"TWIML_APP_SID": {
|
||||||
|
"LABEL": "TwiML App SID",
|
||||||
|
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||||
|
"REQUIRED": "TwiML App SID is required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||||
|
"API": {
|
||||||
|
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"API_CHANNEL": {
|
"API_CHANNEL": {
|
||||||
"TITLE": "API Channel",
|
"TITLE": "API Channel",
|
||||||
"DESC": "Integrate with API channel and start supporting your customers.",
|
"DESC": "Integrate with API channel and start supporting your customers.",
|
||||||
|
|||||||
@@ -537,6 +537,8 @@
|
|||||||
"CONVERSATION": "Conversation #{id}"
|
"CONVERSATION": "Conversation #{id}"
|
||||||
},
|
},
|
||||||
"SELECTED": "{count} selected",
|
"SELECTED": "{count} selected",
|
||||||
|
"SELECT_ALL": "Select all ({count})",
|
||||||
|
"UNSELECT_ALL": "Unselect all ({count})",
|
||||||
"BULK_APPROVE_BUTTON": "Approve",
|
"BULK_APPROVE_BUTTON": "Approve",
|
||||||
"BULK_DELETE_BUTTON": "Delete",
|
"BULK_DELETE_BUTTON": "Delete",
|
||||||
"BULK_APPROVE": {
|
"BULK_APPROVE": {
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ const bulkCheckbox = computed({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const buildSelectedCountLabel = computed(() => {
|
||||||
|
const count = responses.value?.length || 0;
|
||||||
|
return bulkSelectionState.value.allSelected
|
||||||
|
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||||
|
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||||
|
});
|
||||||
|
|
||||||
const handleCardHover = (isHovered, id) => {
|
const handleCardHover = (isHovered, id) => {
|
||||||
hoveredCard.value = isHovered ? id : null;
|
hoveredCard.value = isHovered ? id : null;
|
||||||
};
|
};
|
||||||
@@ -270,7 +277,11 @@ onMounted(() => {
|
|||||||
<template #controls>
|
<template #controls>
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowDropdown"
|
v-if="shouldShowDropdown"
|
||||||
class="mb-4 -mt-3 flex justify-between items-center"
|
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
|
||||||
|
:class="{
|
||||||
|
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
|
||||||
|
bulkSelectionState.hasSelected,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
||||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||||
@@ -306,13 +317,18 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="bulkSelectionState.hasSelected"
|
v-if="bulkSelectionState.hasSelected"
|
||||||
class="flex items-center gap-3 ltr:pl-4 rtl:pr-4"
|
class="flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-3">
|
||||||
<Checkbox
|
<div class="flex items-center gap-1.5">
|
||||||
v-model="bulkCheckbox"
|
<Checkbox
|
||||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
v-model="bulkCheckbox"
|
||||||
/>
|
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||||
|
{{ buildSelectedCountLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||||
{{
|
{{
|
||||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||||
@@ -322,17 +338,23 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-3 items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||||
sm
|
sm
|
||||||
slate
|
ghost
|
||||||
|
icon="i-lucide-check"
|
||||||
|
class="!px-1.5"
|
||||||
@click="handleBulkApprove"
|
@click="handleBulkApprove"
|
||||||
/>
|
/>
|
||||||
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
<Button
|
<Button
|
||||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||||
sm
|
sm
|
||||||
slate
|
ruby
|
||||||
|
ghost
|
||||||
|
class="!px-1.5"
|
||||||
|
icon="i-lucide-trash"
|
||||||
@click="bulkDeleteDialog.dialogRef.open()"
|
@click="bulkDeleteDialog.dialogRef.open()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
url: {
|
url: {
|
||||||
@@ -11,6 +12,8 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['back', 'insert']);
|
const emit = defineEmits(['back', 'insert']);
|
||||||
|
|
||||||
|
const isRTL = useMapGetter('accounts/isRTL');
|
||||||
|
|
||||||
const onBack = e => {
|
const onBack = e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
emit('back');
|
emit('back');
|
||||||
@@ -35,7 +38,7 @@ const onInsert = e => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="-ml-4 h-full overflow-y-auto">
|
<div class="-ml-4 h-full overflow-y-auto">
|
||||||
<div class="w-full h-full min-h-0">
|
<div class="w-full h-full min-h-0">
|
||||||
<IframeLoader :url="url" />
|
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default {
|
|||||||
{ key: 'telegram', name: 'Telegram' },
|
{ key: 'telegram', name: 'Telegram' },
|
||||||
{ key: 'line', name: 'Line' },
|
{ key: 'line', name: 'Line' },
|
||||||
{ key: 'instagram', name: 'Instagram' },
|
{ key: 'instagram', name: 'Instagram' },
|
||||||
|
{ key: 'voice', name: 'Voice' },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
|
|||||||
@@ -1,278 +1,182 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { reactive, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { mapGetters } from 'vuex';
|
import { isPhoneE164 } from 'shared/helpers/Validators';
|
||||||
import router from '../../../../index';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
// Using a regex validator for phone numbers
|
|
||||||
const validPhoneNumber = value => {
|
const { t } = useI18n();
|
||||||
if (!value) return true;
|
const store = useStore();
|
||||||
return /^\+[1-9]\d{1,14}$/.test(value);
|
const router = useRouter();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
phoneNumber: '',
|
||||||
|
accountSid: '',
|
||||||
|
authToken: '',
|
||||||
|
apiKeySid: '',
|
||||||
|
apiKeySecret: '',
|
||||||
|
twimlAppSid: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
phoneNumber: { required, isPhoneE164 },
|
||||||
|
accountSid: { required },
|
||||||
|
authToken: { required },
|
||||||
|
apiKeySid: { required },
|
||||||
|
apiKeySecret: { required },
|
||||||
|
twimlAppSid: { required },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
const v$ = useVuelidate(validationRules, state);
|
||||||
components: {
|
const isSubmitDisabled = computed(() => v$.value.$invalid);
|
||||||
PageHeader,
|
|
||||||
NextButton,
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
return { v$: useVuelidate() };
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
provider: 'twilio',
|
|
||||||
phoneNumber: '',
|
|
||||||
accountSid: '',
|
|
||||||
authToken: '',
|
|
||||||
apiKeySid: '',
|
|
||||||
apiKeySecret: '',
|
|
||||||
twimlAppSid: '',
|
|
||||||
providerOptions: [
|
|
||||||
{ value: 'twilio', label: 'Twilio' },
|
|
||||||
// Add more providers as needed
|
|
||||||
// { value: 'other_provider', label: 'Other Provider' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
uiFlags: 'inboxes/getUIFlags',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
phoneNumber: {
|
|
||||||
required,
|
|
||||||
validPhoneNumber,
|
|
||||||
},
|
|
||||||
accountSid: {
|
|
||||||
required: this.provider === 'twilio',
|
|
||||||
},
|
|
||||||
authToken: {
|
|
||||||
required: this.provider === 'twilio',
|
|
||||||
},
|
|
||||||
apiKeySid: {
|
|
||||||
required: this.provider === 'twilio',
|
|
||||||
},
|
|
||||||
apiKeySecret: {
|
|
||||||
required: this.provider === 'twilio',
|
|
||||||
},
|
|
||||||
// TwiML App SID is not required, but if provided it must follow Twilio's format
|
|
||||||
twimlAppSid: {
|
|
||||||
// Optional - will not be required
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onProviderChange() {
|
|
||||||
// Reset fields when provider changes
|
|
||||||
this.v$.$reset();
|
|
||||||
},
|
|
||||||
getProviderConfig() {
|
|
||||||
if (this.provider === 'twilio') {
|
|
||||||
const config = {
|
|
||||||
account_sid: this.accountSid,
|
|
||||||
auth_token: this.authToken,
|
|
||||||
api_key_sid: this.apiKeySid,
|
|
||||||
api_key_secret: this.apiKeySecret,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add the TwiML App SID if provided
|
|
||||||
if (this.twimlAppSid) {
|
|
||||||
config.outgoing_application_sid = this.twimlAppSid;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
// Add handler for other providers here
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
async createChannel() {
|
|
||||||
this.v$.$touch();
|
|
||||||
if (this.v$.$invalid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const formErrors = computed(() => ({
|
||||||
const providerConfig = this.getProviderConfig();
|
phoneNumber: v$.value.phoneNumber?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR')
|
||||||
|
: '',
|
||||||
|
accountSid: v$.value.accountSid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
authToken: v$.value.authToken?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED')
|
||||||
|
: '',
|
||||||
|
apiKeySid: v$.value.apiKeySid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
||||||
|
: '',
|
||||||
|
twimlAppSid: v$.value.twimlAppSid?.$error
|
||||||
|
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
|
||||||
|
: '',
|
||||||
|
}));
|
||||||
|
|
||||||
const channel = await this.$store.dispatch(
|
function getProviderConfig() {
|
||||||
'inboxes/createVoiceChannel',
|
const config = {
|
||||||
{
|
account_sid: state.accountSid,
|
||||||
voice: {
|
auth_token: state.authToken,
|
||||||
name: `Voice (${this.phoneNumber})`,
|
api_key_sid: state.apiKeySid,
|
||||||
phone_number: this.phoneNumber,
|
api_key_secret: state.apiKeySecret,
|
||||||
provider: this.provider,
|
};
|
||||||
provider_config: JSON.stringify(providerConfig),
|
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
||||||
},
|
return config;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
router.replace({
|
async function createChannel() {
|
||||||
name: 'settings_inboxes_add_agents',
|
const isFormValid = await v$.value.$validate();
|
||||||
params: {
|
if (!isFormValid) return;
|
||||||
page: 'new',
|
|
||||||
inbox_id: channel.id,
|
try {
|
||||||
},
|
const channel = await store.dispatch('inboxes/createVoiceChannel', {
|
||||||
});
|
name: `Voice (${state.phoneNumber})`,
|
||||||
} catch (error) {
|
voice: {
|
||||||
useAlert(
|
phone_number: state.phoneNumber,
|
||||||
error.response?.data?.message ||
|
provider: 'twilio',
|
||||||
this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
provider_config: getProviderConfig(),
|
||||||
);
|
},
|
||||||
}
|
});
|
||||||
},
|
|
||||||
},
|
router.replace({
|
||||||
};
|
name: 'settings_inboxes_add_agents',
|
||||||
|
params: { page: 'new', inbox_id: channel.id },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error.response?.data?.message ||
|
||||||
|
t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div
|
||||||
|
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
:header-title="t('INBOX_MGMT.ADD.VOICE.TITLE')"
|
||||||
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
|
:header-content="t('INBOX_MGMT.ADD.VOICE.DESC')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="flex flex-wrap flex-col gap-4 p-2"
|
class="flex flex-col gap-4 flex-wrap mx-0"
|
||||||
@submit.prevent="createChannel"
|
@submit.prevent="createChannel"
|
||||||
>
|
>
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<Input
|
||||||
<label>
|
v-model="state.phoneNumber"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
|
:label="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL')"
|
||||||
<select
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
||||||
v-model="provider"
|
:message="formErrors.phoneNumber"
|
||||||
class="p-2 bg-white border border-n-blue-100 rounded"
|
:message-type="formErrors.phoneNumber ? 'error' : 'info'"
|
||||||
@change="onProviderChange"
|
@blur="v$.phoneNumber?.$touch"
|
||||||
>
|
/>
|
||||||
<option
|
|
||||||
v-for="option in providerOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Twilio Provider Config -->
|
<Input
|
||||||
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
|
v-model="state.accountSid"
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL')"
|
||||||
<label :class="{ error: v$.phoneNumber.$error }">
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
|
:message="formErrors.accountSid"
|
||||||
<input
|
:message-type="formErrors.accountSid ? 'error' : 'info'"
|
||||||
v-model.trim="phoneNumber"
|
@blur="v$.accountSid?.$touch"
|
||||||
type="text"
|
/>
|
||||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.PLACEHOLDER')"
|
|
||||||
@blur="v$.phoneNumber.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.phoneNumber.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.ERROR') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<Input
|
||||||
<label :class="{ error: v$.accountSid.$error }">
|
v-model="state.authToken"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
|
type="password"
|
||||||
<input
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL')"
|
||||||
v-model.trim="accountSid"
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
|
||||||
type="text"
|
:message="formErrors.authToken"
|
||||||
:placeholder="
|
:message-type="formErrors.authToken ? 'error' : 'info'"
|
||||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')
|
@blur="v$.authToken?.$touch"
|
||||||
"
|
/>
|
||||||
@blur="v$.accountSid.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.accountSid.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.REQUIRED') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<Input
|
||||||
<label :class="{ error: v$.authToken.$error }">
|
v-model="state.apiKeySid"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
|
||||||
<input
|
:placeholder="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
|
||||||
v-model.trim="authToken"
|
:message="formErrors.apiKeySid"
|
||||||
type="text"
|
:message-type="formErrors.apiKeySid ? 'error' : 'info'"
|
||||||
:placeholder="
|
@blur="v$.apiKeySid?.$touch"
|
||||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.PLACEHOLDER')
|
/>
|
||||||
"
|
|
||||||
@blur="v$.authToken.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.authToken.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.REQUIRED') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<Input
|
||||||
<label :class="{ error: v$.apiKeySid.$error }">
|
v-model="state.apiKeySecret"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL', 'API Key SID') }}
|
type="password"
|
||||||
<input
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
|
||||||
v-model.trim="apiKeySid"
|
:placeholder="
|
||||||
type="text"
|
t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
|
||||||
:placeholder="
|
"
|
||||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER', 'Enter your Twilio API Key SID')
|
:message="formErrors.apiKeySecret"
|
||||||
"
|
:message-type="formErrors.apiKeySecret ? 'error' : 'info'"
|
||||||
@blur="v$.apiKeySid.$touch"
|
@blur="v$.apiKeySecret?.$touch"
|
||||||
/>
|
/>
|
||||||
<span v-if="v$.apiKeySid.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.REQUIRED', 'API Key SID is required') }}
|
|
||||||
</span>
|
|
||||||
<span class="help-text">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.HELP', 'You can create API keys in the Twilio Console') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
<Input
|
||||||
<label :class="{ error: v$.apiKeySecret.$error }">
|
v-model="state.twimlAppSid"
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL', 'API Key Secret') }}
|
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
||||||
<input
|
:placeholder="
|
||||||
v-model.trim="apiKeySecret"
|
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
||||||
type="text"
|
"
|
||||||
:placeholder="
|
:message="formErrors.twimlAppSid"
|
||||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER', 'Enter your Twilio API Key Secret')
|
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
||||||
"
|
@blur="v$.twimlAppSid?.$touch"
|
||||||
@blur="v$.apiKeySecret.$touch"
|
/>
|
||||||
/>
|
|
||||||
<span v-if="v$.apiKeySecret.$error" class="message">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED', 'API Key Secret is required') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-shrink-0 flex-grow-0">
|
|
||||||
<label>
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL', 'TwiML App SID (Recommended)') }}
|
|
||||||
<input
|
|
||||||
v-model.trim="twimlAppSid"
|
|
||||||
type="text"
|
|
||||||
:placeholder="
|
|
||||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER', 'Enter your Twilio TwiML App SID (starts with AP)')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span class="help-text">
|
|
||||||
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.HELP', 'Required for browser-based calling. Create a TwiML App in the Twilio Console with Voice URLs pointing to your Chatwoot instance.') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add other provider configs here -->
|
<div>
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<NextButton
|
<NextButton
|
||||||
:is-loading="uiFlags.isCreating"
|
:is-loading="uiFlags.isCreating"
|
||||||
:is-disabled="v$.$invalid"
|
:disabled="isSubmitDisabled"
|
||||||
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
:label="t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
|
||||||
type="submit"
|
type="submit"
|
||||||
color="blue"
|
|
||||||
@click="createChannel"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -75,10 +75,34 @@ export default {
|
|||||||
onRegistrationSuccess() {
|
onRegistrationSuccess() {
|
||||||
this.hasEnabledPushPermissions = true;
|
this.hasEnabledPushPermissions = true;
|
||||||
},
|
},
|
||||||
onRequestPermissions() {
|
onRequestPermissions(value) {
|
||||||
requestPushPermissions({
|
if (value) {
|
||||||
onSuccess: this.onRegistrationSuccess,
|
// Enable / re-enable push notifications
|
||||||
});
|
requestPushPermissions({
|
||||||
|
onSuccess: this.onRegistrationSuccess,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Disable push notifications
|
||||||
|
this.disablePushPermissions();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disablePushPermissions() {
|
||||||
|
verifyServiceWorkerExistence(registration =>
|
||||||
|
registration.pushManager
|
||||||
|
.getSubscription()
|
||||||
|
.then(subscription => {
|
||||||
|
if (subscription) {
|
||||||
|
return subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.hasEnabledPushPermissions = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// error
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
getPushSubscription() {
|
getPushSubscription() {
|
||||||
verifyServiceWorkerExistence(registration =>
|
verifyServiceWorkerExistence(registration =>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const defaulSpanRender = cellProps =>
|
|||||||
cellProps.getValue()
|
cellProps.getValue()
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = [
|
const columns = computed(() => [
|
||||||
columnHelper.accessor('name', {
|
columnHelper.accessor('name', {
|
||||||
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
|
header: t(`SUMMARY_REPORTS.${props.type.toUpperCase()}`),
|
||||||
width: 300,
|
width: 300,
|
||||||
@@ -90,7 +90,7 @@ const columns = [
|
|||||||
width: 200,
|
width: 200,
|
||||||
cell: defaulSpanRender,
|
cell: defaulSpanRender,
|
||||||
}),
|
}),
|
||||||
];
|
]);
|
||||||
|
|
||||||
const renderAvgTime = value => (value ? formatTime(value) : '--');
|
const renderAvgTime = value => (value ? formatTime(value) : '--');
|
||||||
|
|
||||||
@@ -142,7 +142,9 @@ const table = useVueTable({
|
|||||||
get data() {
|
get data() {
|
||||||
return tableData.value;
|
return tableData.value;
|
||||||
},
|
},
|
||||||
columns,
|
get columns() {
|
||||||
|
return columns.value;
|
||||||
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
|
|||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
|
import { channelActions, buildInboxData } from './inboxes/channelActions';
|
||||||
const buildInboxData = inboxParams => {
|
|
||||||
const formData = new FormData();
|
|
||||||
const { channel = {}, ...inboxProperties } = inboxParams;
|
|
||||||
Object.keys(inboxProperties).forEach(key => {
|
|
||||||
formData.append(key, inboxProperties[key]);
|
|
||||||
});
|
|
||||||
const { selectedFeatureFlags, ...channelParams } = channel;
|
|
||||||
// selectedFeatureFlags needs to be empty when creating a website channel
|
|
||||||
if (selectedFeatureFlags) {
|
|
||||||
if (selectedFeatureFlags.length) {
|
|
||||||
selectedFeatureFlags.forEach(featureFlag => {
|
|
||||||
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
formData.append('channel[selected_feature_flags][]', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Object.keys(channelParams).forEach(key => {
|
|
||||||
formData.append(`channel[${key}]`, channel[key]);
|
|
||||||
});
|
|
||||||
return formData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
records: [],
|
records: [],
|
||||||
@@ -253,6 +231,12 @@ export const actions = {
|
|||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
...channelActions,
|
||||||
|
// TODO: Extract other create channel methods to separate files to reduce file size
|
||||||
|
// - createChannel
|
||||||
|
// - createWebsiteChannel
|
||||||
|
// - createTwilioChannel
|
||||||
|
// - createFBChannel
|
||||||
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
||||||
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as types from '../../mutation-types';
|
||||||
|
import InboxesAPI from '../../../api/inboxes';
|
||||||
|
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
||||||
|
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
|
export const buildInboxData = inboxParams => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const { channel = {}, ...inboxProperties } = inboxParams;
|
||||||
|
Object.keys(inboxProperties).forEach(key => {
|
||||||
|
formData.append(key, inboxProperties[key]);
|
||||||
|
});
|
||||||
|
const { selectedFeatureFlags, ...channelParams } = channel;
|
||||||
|
// selectedFeatureFlags needs to be empty when creating a website channel
|
||||||
|
if (selectedFeatureFlags) {
|
||||||
|
if (selectedFeatureFlags.length) {
|
||||||
|
selectedFeatureFlags.forEach(featureFlag => {
|
||||||
|
formData.append(`channel[selected_feature_flags][]`, featureFlag);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
formData.append('channel[selected_feature_flags][]', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.keys(channelParams).forEach(key => {
|
||||||
|
formData.append(`channel[${key}]`, channel[key]);
|
||||||
|
});
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendAnalyticsEvent = channelType => {
|
||||||
|
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
|
||||||
|
channelType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelActions = {
|
||||||
|
createVoiceChannel: async ({ commit }, params) => {
|
||||||
|
try {
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||||
|
const response = await InboxesAPI.create({
|
||||||
|
name: params.name,
|
||||||
|
channel: { ...params.voice, type: 'voice' },
|
||||||
|
});
|
||||||
|
commit(types.default.ADD_INBOXES, response.data);
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||||
|
sendAnalyticsEvent('voice');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -62,6 +62,28 @@ describe('#actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#createVoiceChannel', () => {
|
||||||
|
it('sends correct actions if API is success', async () => {
|
||||||
|
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||||
|
await actions.createVoiceChannel({ commit }, inboxList[0]);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||||
|
[types.default.ADD_INBOXES, inboxList[0]],
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('sends correct actions if API is error', async () => {
|
||||||
|
axios.post.mockRejectedValue({ message: 'Incorrect header' });
|
||||||
|
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
|
||||||
|
Error
|
||||||
|
);
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
|
||||||
|
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#createFBChannel', () => {
|
describe('#createFBChannel', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.post.mockResolvedValue({ data: inboxList[0] });
|
axios.post.mockResolvedValue({ data: inboxList[0] });
|
||||||
|
|||||||
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setDirectionAttribute: () => {
|
setDirectionAttribute: () => {
|
||||||
const portalElement = document.getElementById('portal');
|
const htmlElement = document.querySelector('html');
|
||||||
if (!portalElement) return;
|
// If direction is already applied through props, do not apply again (iframe case)
|
||||||
|
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
|
||||||
|
if (!htmlElement || hasDirApplied) return;
|
||||||
|
|
||||||
const locale = document.querySelector('.locale-switcher')?.value;
|
const localeFromHtml = htmlElement.lang;
|
||||||
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
htmlElement.dir =
|
||||||
|
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
|
||||||
},
|
},
|
||||||
|
|
||||||
initializeThemesInPortal: initializeTheme,
|
initializeThemesInPortal: initializeTheme,
|
||||||
|
|||||||
@@ -1,64 +1,56 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, useTemplateRef, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'IframeLoader',
|
url: {
|
||||||
components: {
|
type: String,
|
||||||
ArticleSkeletonLoader,
|
default: '',
|
||||||
},
|
},
|
||||||
props: {
|
isRtl: {
|
||||||
url: {
|
type: Boolean,
|
||||||
type: String,
|
default: false,
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isRtl: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
data() {
|
isDirApplied: {
|
||||||
return {
|
type: Boolean,
|
||||||
isLoading: true,
|
default: false,
|
||||||
showEmptyState: !this.url,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
watch: {
|
});
|
||||||
isRtl: {
|
|
||||||
immediate: true,
|
|
||||||
handler(value) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const iframeElement = this.$el.querySelector('iframe');
|
|
||||||
if (iframeElement) {
|
|
||||||
iframeElement.onload = () => {
|
|
||||||
try {
|
|
||||||
const iframeDocument =
|
|
||||||
iframeElement.contentDocument ||
|
|
||||||
(iframeElement.contentWindow &&
|
|
||||||
iframeElement.contentWindow.document);
|
|
||||||
|
|
||||||
if (iframeDocument) {
|
const { t } = useI18n();
|
||||||
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
|
|
||||||
}
|
const iframe = useTemplateRef('iframe');
|
||||||
} catch (e) {
|
const isLoading = ref(true);
|
||||||
// error
|
const showEmptyState = ref(!props.url);
|
||||||
}
|
|
||||||
};
|
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
|
||||||
}
|
|
||||||
});
|
const applyDirection = () => {
|
||||||
},
|
if (!iframe.value) return;
|
||||||
},
|
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
|
||||||
},
|
try {
|
||||||
methods: {
|
const doc =
|
||||||
handleIframeLoad() {
|
iframe.value.contentDocument || iframe.value.contentWindow?.document;
|
||||||
// Once loaded, the loading state is hidden
|
if (doc?.documentElement) {
|
||||||
this.isLoading = false;
|
doc.documentElement.dir = direction.value;
|
||||||
},
|
doc.documentElement.setAttribute('data-dir-applied', 'true');
|
||||||
handleIframeError() {
|
}
|
||||||
// Hide the loading state and show the empty state when an error occurs
|
} catch (e) {
|
||||||
this.isLoading = false;
|
// error
|
||||||
this.showEmptyState = true;
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
|
watch(() => props.isRtl, applyDirection);
|
||||||
|
|
||||||
|
const handleIframeLoad = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
applyDirection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIframeError = () => {
|
||||||
|
isLoading.value = false;
|
||||||
|
showEmptyState.value = true;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,6 +58,7 @@ export default {
|
|||||||
<div class="relative overflow-hidden pb-1/2 h-full">
|
<div class="relative overflow-hidden pb-1/2 h-full">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="url"
|
v-if="url"
|
||||||
|
ref="iframe"
|
||||||
:src="url"
|
:src="url"
|
||||||
class="absolute w-full h-full top-0 left-0"
|
class="absolute w-full h-full top-0 left-0"
|
||||||
@load="handleIframeLoad"
|
@load="handleIframeLoad"
|
||||||
@@ -79,7 +72,7 @@ export default {
|
|||||||
v-if="showEmptyState"
|
v-if="showEmptyState"
|
||||||
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
|
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
|
||||||
>
|
>
|
||||||
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
|
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
40
app/javascript/shared/helpers/portalHelper.js
Normal file
40
app/javascript/shared/helpers/portalHelper.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Determine the best-matching locale from the list of locales allowed by the portal.
|
||||||
|
*
|
||||||
|
* The matching happens in the following order:
|
||||||
|
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
|
||||||
|
* (e.g., `fr` ➜ `fr`).
|
||||||
|
* 2. Base language match – the base part of a compound locale (before the underscore)
|
||||||
|
* matches (e.g., `fr_CA` ➜ `fr`).
|
||||||
|
* 3. Variant match – when the base language is selected but a regional variant exists
|
||||||
|
* in the portal list (e.g., `fr` ➜ `fr_BE`).
|
||||||
|
*
|
||||||
|
* If none of these rules find a match, the function returns `null`,
|
||||||
|
* Don't show popular articles if locale doesn't match with allowed locales
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
|
||||||
|
* @param {string[]} allowedLocales Array of locales enabled for the portal.
|
||||||
|
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
|
||||||
|
*/
|
||||||
|
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||||
|
// Ensure inputs are valid
|
||||||
|
if (
|
||||||
|
!selectedLocale ||
|
||||||
|
!Array.isArray(allowedLocales) ||
|
||||||
|
!allowedLocales.length
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lang] = selectedLocale.split('_');
|
||||||
|
|
||||||
|
const priorityMatches = [
|
||||||
|
selectedLocale, // exact match
|
||||||
|
lang, // base language match
|
||||||
|
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
|
||||||
|
];
|
||||||
|
|
||||||
|
// Return the first match that exists in the allowed list, or null
|
||||||
|
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||||
|
};
|
||||||
28
app/javascript/shared/helpers/specs/portalHelper.spec.js
Normal file
28
app/javascript/shared/helpers/specs/portalHelper.spec.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
|
describe('portalHelper - getMatchingLocale', () => {
|
||||||
|
it('returns exact match when present', () => {
|
||||||
|
const result = getMatchingLocale('fr', ['en', 'fr']);
|
||||||
|
expect(result).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns base language match when exact variant not present', () => {
|
||||||
|
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
|
||||||
|
expect(result).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns variant match when base language not present', () => {
|
||||||
|
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
|
||||||
|
expect(result).toBe('fr_BE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no match found', () => {
|
||||||
|
const result = getMatchingLocale('de', ['en', 'fr']);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid inputs', () => {
|
||||||
|
expect(getMatchingLocale('', [])).toBeNull();
|
||||||
|
expect(getMatchingLocale(null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||||
|
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
|
|||||||
|
|
||||||
const locale = computed(() => {
|
const locale = computed(() => {
|
||||||
const { locale: selectedLocale } = i18n;
|
const { locale: selectedLocale } = i18n;
|
||||||
const {
|
const { allowed_locales: allowedLocales } = portal.value.config;
|
||||||
allowed_locales: allowedLocales,
|
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||||
default_locale: defaultLocale = 'en',
|
|
||||||
} = portal.value.config;
|
|
||||||
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
|
|
||||||
// If the exact match of a locale is available in the list of portal locales, return it
|
|
||||||
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
|
|
||||||
if (allowedLocales.includes(selectedLocale)) {
|
|
||||||
return locale;
|
|
||||||
}
|
|
||||||
return defaultLocale;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchArticles = () => {
|
const fetchArticles = () => {
|
||||||
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
show_plain_layout: 'true',
|
show_plain_layout: 'true',
|
||||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||||
|
...(locale.value && { locale: locale.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine link with query parameters
|
// Combine link with query parameters
|
||||||
@@ -64,7 +57,8 @@ const hasArticles = computed(
|
|||||||
() =>
|
() =>
|
||||||
!articleUiFlags.value.isFetching &&
|
!articleUiFlags.value.isFetching &&
|
||||||
!articleUiFlags.value.isError &&
|
!articleUiFlags.value.isError &&
|
||||||
!!popularArticles.value.length
|
!!popularArticles.value.length &&
|
||||||
|
!!locale.value
|
||||||
);
|
);
|
||||||
onMounted(() => fetchArticles());
|
onMounted(() => fetchArticles());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const actions = {
|
|||||||
commit('setError', false);
|
commit('setError', false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!locale) return;
|
||||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
commit('setArticles', cachedData);
|
commit('setArticles', cachedData);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ArticleViewer',
|
name: 'ArticleViewer',
|
||||||
components: {
|
components: {
|
||||||
IframeLoader,
|
IframeLoader,
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
isRTL() {
|
|
||||||
return this.$root.$i18n.locale
|
|
||||||
? getLanguageDirection(this.$root.$i18n.locale)
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white h-full">
|
<div class="bg-white h-full">
|
||||||
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
|
<IframeLoader :url="$route.query.link" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,22 +5,34 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
|||||||
sections << "Channel: #{@record.inbox.channel.name}"
|
sections << "Channel: #{@record.inbox.channel.name}"
|
||||||
sections << 'Message History:'
|
sections << 'Message History:'
|
||||||
sections << if @record.messages.any?
|
sections << if @record.messages.any?
|
||||||
build_messages
|
build_messages(config)
|
||||||
else
|
else
|
||||||
'No messages in this conversation'
|
'No messages in this conversation'
|
||||||
end
|
end
|
||||||
|
|
||||||
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
|
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
|
||||||
|
|
||||||
|
attributes = build_attributes
|
||||||
|
if attributes.present?
|
||||||
|
sections << 'Conversation Attributes:'
|
||||||
|
sections << attributes
|
||||||
|
end
|
||||||
|
|
||||||
sections.join("\n")
|
sections.join("\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def build_messages
|
def build_messages(config = {})
|
||||||
return "No messages in this conversation\n" if @record.messages.empty?
|
return "No messages in this conversation\n" if @record.messages.empty?
|
||||||
|
|
||||||
message_text = ''
|
message_text = ''
|
||||||
@record.messages.chat.order(created_at: :asc).each do |message|
|
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
|
||||||
|
|
||||||
|
messages.each do |message|
|
||||||
|
# Skip private messages unless explicitly included in config
|
||||||
|
next if message.private? && !config[:include_private_messages]
|
||||||
|
|
||||||
message_text << format_message(message)
|
message_text << format_message(message)
|
||||||
end
|
end
|
||||||
message_text
|
message_text
|
||||||
@@ -28,6 +40,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
|||||||
|
|
||||||
def format_message(message)
|
def format_message(message)
|
||||||
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
||||||
|
sender = "[Private Note] #{sender}" if message.private?
|
||||||
"#{sender}: #{message.content}\n"
|
"#{sender}: #{message.content}\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_attributes
|
||||||
|
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
|
||||||
|
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
|
||||||
|
end
|
||||||
|
attributes.join("\n")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
|
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
|
||||||
<strong>Account ID:</strong> {{ meta.account_id }}<br>
|
<strong>Account ID:</strong> {{ meta.account_id }}<br>
|
||||||
<strong>Account Name:</strong> {{ meta.account_name }}<br>
|
<strong>Account Name:</strong> {{ meta.account_name }}<br>
|
||||||
|
<strong>Deletion due at:</strong> {{ meta.marked_for_deletion_at }}<br>
|
||||||
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
|
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
|
||||||
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
|
|
||||||
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
|
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<% if @message.content %>
|
<% if @message.content %>
|
||||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
|
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @large_attachments.present? %>
|
<% if @large_attachments.present? %>
|
||||||
<p>Attachments:</p>
|
<p>Attachments:</p>
|
||||||
|
|||||||
@@ -168,4 +168,8 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
- name: crm_integration
|
- name: crm_integration
|
||||||
display_name: CRM Integration
|
display_name: CRM Integration
|
||||||
enabled: false
|
enabled: false
|
||||||
|
- name: channel_voice
|
||||||
|
display_name: Voice Channel
|
||||||
|
enabled: false
|
||||||
|
chatwoot_internal: true
|
||||||
|
|||||||
16
db/migrate/20250620120000_create_channel_voice.rb
Normal file
16
db/migrate/20250620120000_create_channel_voice.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class CreateChannelVoice < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :channel_voice do |t|
|
||||||
|
t.string :phone_number, null: false
|
||||||
|
t.string :provider, null: false, default: 'twilio'
|
||||||
|
t.jsonb :provider_config, null: false
|
||||||
|
t.integer :account_id, null: false
|
||||||
|
t.jsonb :additional_attributes, default: {}
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :channel_voice, :phone_number, unique: true
|
||||||
|
add_index :channel_voice, :account_id
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
ActiveRecord::Schema[7.1].define(version: 2025_06_20_120000) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -451,6 +451,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
|||||||
t.jsonb "additional_attributes", default: {}
|
t.jsonb "additional_attributes", default: {}
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id"], name: "index_channel_voice_on_account_id"
|
||||||
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
|
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
|||||||
def ee_inbox_attributes
|
def ee_inbox_attributes
|
||||||
[auto_assignment_config: [:max_assignment_limit]]
|
[auto_assignment_config: [:max_assignment_limit]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def allowed_channel_types
|
||||||
|
super + ['voice']
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel_type_from_params
|
||||||
|
case permitted_params[:channel][:type]
|
||||||
|
when 'voice'
|
||||||
|
Channel::Voice
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_channels_method
|
||||||
|
case permitted_params[:channel][:type]
|
||||||
|
when 'voice'
|
||||||
|
Current.account.voice_channels
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
generate_and_process_response
|
generate_and_process_response
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
raise e if e.is_a?(ActiveJob::FileNotFoundError)
|
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
|
||||||
|
|
||||||
handle_error(e)
|
handle_error(e)
|
||||||
ensure
|
ensure
|
||||||
|
|||||||
64
enterprise/app/models/channel/voice.rb
Normal file
64
enterprise/app/models/channel/voice.rb
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: channel_voice
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# additional_attributes :jsonb
|
||||||
|
# phone_number :string not null
|
||||||
|
# provider :string default("twilio"), not null
|
||||||
|
# provider_config :jsonb not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :integer not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_channel_voice_on_account_id (account_id)
|
||||||
|
# index_channel_voice_on_phone_number (phone_number) UNIQUE
|
||||||
|
#
|
||||||
|
class Channel::Voice < ApplicationRecord
|
||||||
|
include Channelable
|
||||||
|
|
||||||
|
self.table_name = 'channel_voice'
|
||||||
|
|
||||||
|
validates :phone_number, presence: true, uniqueness: true
|
||||||
|
validates :provider, presence: true
|
||||||
|
validates :provider_config, presence: true
|
||||||
|
|
||||||
|
# Validate phone number format (E.164 format)
|
||||||
|
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
|
||||||
|
|
||||||
|
# Provider-specific configs stored in JSON
|
||||||
|
validate :validate_provider_config
|
||||||
|
|
||||||
|
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||||
|
|
||||||
|
def name
|
||||||
|
"Voice (#{phone_number})"
|
||||||
|
end
|
||||||
|
|
||||||
|
def messaging_window_enabled?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_provider_config
|
||||||
|
return if provider_config.blank?
|
||||||
|
|
||||||
|
case provider
|
||||||
|
when 'twilio'
|
||||||
|
validate_twilio_config
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_twilio_config
|
||||||
|
config = provider_config.with_indifferent_access
|
||||||
|
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
|
||||||
|
|
||||||
|
required_keys.each do |key|
|
||||||
|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
|
|||||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||||
|
|
||||||
has_many :copilot_threads, dependent: :destroy_async
|
has_many :copilot_threads, dependent: :destroy_async
|
||||||
|
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseServ
|
|||||||
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
|
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
|
||||||
return 'Conversation not found' if conversation.blank?
|
return 'Conversation not found' if conversation.blank?
|
||||||
|
|
||||||
conversation.to_llm_text
|
conversation.to_llm_text(include_private_messages: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
|||||||
private
|
private
|
||||||
|
|
||||||
def can_transcribe?
|
def can_transcribe?
|
||||||
return false if account.feature_enabled?('captain_integration')
|
return false unless account.feature_enabled?('captain_integration')
|
||||||
return false if account.audio_transcriptions.blank?
|
return false if account.audio_transcriptions.blank?
|
||||||
|
|
||||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
||||||
expect(response_data.length).to eq(2)
|
expect(response_data.length).to eq(2)
|
||||||
|
# Only count articles in the current locale (category.locale is 'en')
|
||||||
|
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns articles count from all locales when locale parameter is not present' do
|
||||||
|
get "/hc/#{portal.slug}/articles.json"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
|
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates a voice inbox when administrator' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { name: 'Voice Inbox',
|
||||||
|
channel: { type: 'voice', phone_number: '+15551234567',
|
||||||
|
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
|
auth_token: SecureRandom.hex(16),
|
||||||
|
api_key_sid: SecureRandom.hex(8),
|
||||||
|
api_key_secret: SecureRandom.hex(16) } } },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.body).to include('Voice Inbox')
|
||||||
|
expect(response.body).to include('+15551234567')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
60
spec/enterprise/models/channel/voice_spec.rb
Normal file
60
spec/enterprise/models/channel/voice_spec.rb
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Channel::Voice do
|
||||||
|
let(:channel) { create(:channel_voice) }
|
||||||
|
|
||||||
|
it 'has a valid factory' do
|
||||||
|
expect(channel).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it 'validates presence of provider_config' do
|
||||||
|
channel.provider_config = nil
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include("can't be blank")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of account_sid in provider_config' do
|
||||||
|
channel.provider_config = { auth_token: 'token' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of auth_token in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of api_key_sid in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates presence of api_key_secret in provider_config' do
|
||||||
|
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
|
||||||
|
expect(channel).not_to be_valid
|
||||||
|
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is valid with all required provider_config fields' do
|
||||||
|
channel.provider_config = {
|
||||||
|
account_sid: 'test_sid',
|
||||||
|
auth_token: 'test_token',
|
||||||
|
api_key_sid: 'test_key',
|
||||||
|
api_key_secret: 'test_secret'
|
||||||
|
}
|
||||||
|
expect(channel).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#name' do
|
||||||
|
it 'returns Voice with phone number' do
|
||||||
|
expect(channel.name).to include('Voice')
|
||||||
|
expect(channel.name).to include(channel.phone_number)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -128,6 +128,29 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
|
|||||||
expect(result).to eq(conversation.to_llm_text)
|
expect(result).to eq(conversation.to_llm_text)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'includes private messages in the llm text format' do
|
||||||
|
# Create a regular message
|
||||||
|
create(:message,
|
||||||
|
conversation: conversation,
|
||||||
|
message_type: 'outgoing',
|
||||||
|
content: 'Regular message',
|
||||||
|
private: false)
|
||||||
|
|
||||||
|
# Create a private message
|
||||||
|
create(:message,
|
||||||
|
conversation: conversation,
|
||||||
|
message_type: 'outgoing',
|
||||||
|
content: 'Private note content',
|
||||||
|
private: true)
|
||||||
|
|
||||||
|
result = service.execute({ 'conversation_id' => conversation.display_id })
|
||||||
|
|
||||||
|
# Verify that the result includes both regular and private messages
|
||||||
|
expect(result).to include('Regular message')
|
||||||
|
expect(result).to include('Private note content')
|
||||||
|
expect(result).to include('[Private Note]')
|
||||||
|
end
|
||||||
|
|
||||||
context 'when conversation belongs to different account' do
|
context 'when conversation belongs to different account' do
|
||||||
let(:other_account) { create(:account) }
|
let(:other_account) { create(:account) }
|
||||||
let(:other_inbox) { create(:inbox, account: other_account) }
|
let(:other_inbox) { create(:inbox, account: other_account) }
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
|
|||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
let(:service) { described_class.new(attachment) }
|
let(:service) { described_class.new(attachment) }
|
||||||
|
|
||||||
|
context 'when captain_integration feature is not enabled' do
|
||||||
|
before do
|
||||||
|
account.disable_features!('captain_integration')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns transcription limit exceeded' do
|
||||||
|
expect(service.perform).to eq({ error: 'Transcription limit exceeded' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when transcription is successful' do
|
context 'when transcription is successful' do
|
||||||
before do
|
before do
|
||||||
# Mock can_transcribe? to return true and transcribe_audio method
|
# Mock can_transcribe? to return true and transcribe_audio method
|
||||||
|
|||||||
20
spec/factories/channel/channel_voice.rb
Normal file
20
spec/factories/channel/channel_voice.rb
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :channel_voice, class: 'Channel::Voice' do
|
||||||
|
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
|
||||||
|
provider_config do
|
||||||
|
{
|
||||||
|
account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
|
auth_token: SecureRandom.hex(16),
|
||||||
|
api_key_sid: SecureRandom.hex(8),
|
||||||
|
api_key_secret: SecureRandom.hex(16)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
account
|
||||||
|
|
||||||
|
after(:create) do |channel_voice|
|
||||||
|
create(:inbox, channel: channel_voice, account: channel_voice.account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -154,6 +154,27 @@ RSpec.describe ConversationReplyMailer do
|
|||||||
expect(mail.message_id).to eq message.source_id
|
expect(mail.message_id).to eq message.source_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when message is a CSAT survey' do
|
||||||
|
let(:csat_message) do
|
||||||
|
create(:message, conversation: conversation, account: account, message_type: 'template',
|
||||||
|
content_type: 'input_csat', content: 'How would you rate our support?', sender: agent)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes CSAT survey URL in outgoing_content' do
|
||||||
|
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||||
|
mail = described_class.email_reply(csat_message).deliver_now
|
||||||
|
expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses outgoing_content for CSAT message body' do
|
||||||
|
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||||
|
mail = described_class.email_reply(csat_message).deliver_now
|
||||||
|
expect(mail.decoded).to include csat_message.outgoing_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'with email attachments' do
|
context 'with email attachments' do
|
||||||
it 'includes small attachments as email attachments' do
|
it 'includes small attachments as email attachments' do
|
||||||
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
|
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
|
||||||
|
|||||||
@@ -34,20 +34,21 @@ RSpec.describe MessageContentPresenter do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
||||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
|
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
|
||||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||||
allow(I18n).to receive(:t).with('conversations.survey.response', link: expected_url)
|
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||||
.and_return("Please rate this conversation, #{expected_url}")
|
expect(presenter.outgoing_content).to include(expected_url)
|
||||||
expect(presenter.outgoing_content).to eq("Please rate this conversation, #{expected_url}")
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
|
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
|
||||||
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
|
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
|
||||||
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
|
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||||
|
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -61,5 +61,30 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
|||||||
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
|
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when conversation has custom attributes' do
|
||||||
|
it 'includes formatted custom attributes in the output' do
|
||||||
|
create(
|
||||||
|
:custom_attribute_definition,
|
||||||
|
account: account,
|
||||||
|
attribute_display_name: 'Order ID',
|
||||||
|
attribute_key: 'order_id',
|
||||||
|
attribute_model: :conversation_attribute
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation.update(custom_attributes: { 'order_id' => '12345' })
|
||||||
|
|
||||||
|
expected_output = [
|
||||||
|
"Conversation ID: ##{conversation.display_id}",
|
||||||
|
"Channel: #{conversation.inbox.channel.name}",
|
||||||
|
'Message History:',
|
||||||
|
'No messages in this conversation',
|
||||||
|
'Conversation Attributes:',
|
||||||
|
'Order ID: 12345'
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
expect(formatter.format).to eq(expected_output)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user