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:
Sojan
2025-06-25 16:04:27 -07:00
49 changed files with 858 additions and 438 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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">

View File

@@ -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 = {

View File

@@ -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' };

View File

@@ -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="[

View File

@@ -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"
> >

View File

@@ -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>

View File

@@ -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);
}, },
}, },

View File

@@ -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.",

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>

View File

@@ -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 =>

View File

@@ -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(),
}); });

View File

@@ -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 {

View File

@@ -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;
}
},
};

View File

@@ -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] });

View File

@@ -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,

View File

@@ -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>

View 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;
};

View 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();
});
});

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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?

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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) }

View File

@@ -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

View 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

View File

@@ -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',

View File

@@ -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

View File

@@ -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