feat: Add voice channel support for Chatwoot

This commit is contained in:
Sojan
2025-04-27 00:48:05 -07:00
parent ef6949e32d
commit 8d660df4c4
24 changed files with 559 additions and 18 deletions
@@ -0,0 +1,21 @@
/* global axios */
import ApiClient from '../ApiClient';
class VoiceAPI extends ApiClient {
constructor() {
// Use empty string for resource to avoid duplicate 'accounts' in URL
super('', { accountScoped: true });
}
// Initiate a call to a contact
initiateCall(contactId) {
// Get the account ID from the current URL
const accountId = this.accountIdFromRoute;
// Make sure we have the right endpoint path
return axios.post(
`/api/v1/accounts/${accountId}/contacts/${contactId}/call`
);
}
}
export default new VoiceAPI();
@@ -40,10 +40,10 @@ export default {
this.enabledFeatures.channel_instagram && this.hasInstagramConfigured
);
}
return [
'website',
'twilio',
'voice',
'api',
'whatsapp',
'sms',
+9
View File
@@ -3,6 +3,7 @@ export const INBOX_TYPES = {
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
VOICE: 'Channel::Voice',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -69,6 +72,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.VOICE:
return 'voice';
case INBOX_TYPES.WHATSAPP:
return 'whatsapp';
@@ -105,6 +111,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
return phoneNumber?.startsWith('whatsapp')
? 'brand-whatsapp'
: 'brand-sms';
case INBOX_TYPES.VOICE:
return 'phone';
case INBOX_TYPES.WHATSAPP:
return 'brand-whatsapp';
@@ -379,6 +379,9 @@
"TWO": "{user} and {secondUser} are typing",
"MULTIPLE": "{user} and {count} others are typing"
},
"VOICE_CALL": "Call",
"CALL_ERROR": "Failed to initiate call. Please try again.",
"CALL_INITIATED": "Call initiated successfully.",
"COPILOT": {
"TRY_THESE_PROMPTS": "Try these prompts"
},
@@ -217,6 +217,37 @@
}
}
},
"VOICE": {
"TITLE": "Voice Channel",
"DESC": "Integrate voice calling capabilities with your preferred provider.",
"PROVIDER": {
"LABEL": "Provider",
"PLACEHOLDER": "Select a voice provider"
},
"PHONE_NUMBER": {
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter the phone number from which calls will be made.",
"REQUIRED": "This field is required",
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
"TWILIO": {
"ACCOUNT_SID": {
"LABEL": "Account SID",
"PLACEHOLDER": "Please enter your Twilio Account SID",
"REQUIRED": "This field is required"
},
"AUTH_TOKEN": {
"LABEL": "Auth Token",
"PLACEHOLDER": "Please enter your Twilio Auth Token",
"REQUIRED": "This field is required"
}
},
"SUBMIT_BUTTON": "Create Voice Channel",
"API": {
"SUCCESS_MESSAGE": "Voice channel registered successfully",
"ERROR_MESSAGE": "Unable to create voice channel. Please try again."
}
},
"WHATSAPP": {
"TITLE": "WhatsApp Channel",
"DESC": "Start supporting your customers via WhatsApp.",
@@ -758,6 +789,7 @@
"WEB_WIDGET": "Website",
"TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "Twilio SMS",
"VOICE": "Voice",
"WHATSAPP": "WhatsApp",
"SMS": "SMS",
"EMAIL": "Email",
@@ -4,6 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
@@ -11,6 +12,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceAPI from 'dashboard/api/channels/voice';
import {
isAConversationRoute,
@@ -29,6 +31,7 @@ export default {
SocialIcons,
ContactMergeModal,
},
mixins: [inboxMixin],
props: {
contact: {
type: Object,
@@ -51,6 +54,7 @@ export default {
showEditModal: false,
showMergeModal: false,
showDeleteModal: false,
isCallLoading: false,
};
},
computed: {
@@ -138,6 +142,20 @@ export default {
return '';
}
},
async initiateVoiceCall() {
if (!this.contact || !this.contact.id) return;
this.isCallLoading = true;
try {
const response = await VoiceAPI.initiateCall(this.contact.id);
useAlert('Call initiated successfully', 'success');
} catch (error) {
// Error handled with useAlert
useAlert('Failed to initiate call. Please try again.', 'error');
} finally {
this.isCallLoading = false;
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
@@ -276,6 +294,16 @@ export default {
/>
</template>
</ComposeConversation>
<NextButton
v-if="contact.phone_number"
v-tooltip.top-end="'Call'"
icon="i-ph-phone"
slate
faded
sm
:is-loading="isCallLoading"
@click.stop.prevent="initiateVoiceCall"
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -30,6 +30,10 @@ export default {
type: Boolean,
default: false,
},
buttonSlot: {
type: Boolean,
default: false,
},
},
methods: {
async onCopy(e) {
@@ -64,15 +68,18 @@ export default {
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<div class="flex items-center">
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
<slot v-if="buttonSlot" name="button"></slot>
</div>
</a>
<div v-else class="flex items-center gap-2 text-n-slate-11">
@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
facebook: Facebook,
@@ -22,6 +23,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
voice: Voice,
};
export default defineComponent({
@@ -28,6 +28,7 @@ export default {
{ key: 'whatsapp', name: 'WhatsApp' },
{ key: 'sms', name: 'SMS' },
{ key: 'email', name: 'Email' },
{ key: 'voice', name: 'Voice' },
{
key: 'api',
name: apiChannelName || 'API',
@@ -0,0 +1,195 @@
<template>
<div>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.VOICE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.VOICE.DESC')"
/>
<form class="flex flex-wrap flex-col gap-4 p-2" @submit.prevent="createChannel">
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.VOICE.PROVIDER.LABEL') }}
<select
v-model="provider"
class="p-2 bg-white border border-n-blue-100 rounded"
@change="onProviderChange"
>
<option
v-for="option in providerOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
</div>
<!-- Twilio Provider Config -->
<div v-if="provider === 'twilio'" class="flex-shrink-0 flex-grow-0">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.PHONE_NUMBER.LABEL') }}
<input
v-model.trim="phoneNumber"
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">
<label :class="{ error: v$.accountSid.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.LABEL') }}
<input
v-model.trim="accountSid"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.ACCOUNT_SID.PLACEHOLDER')"
@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">
<label :class="{ error: v$.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.VOICE.TWILIO.AUTH_TOKEN.LABEL') }}
<input
v-model.trim="authToken"
type="text"
:placeholder="$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>
<!-- Add other provider configs here -->
<div class="mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
:is-disabled="v$.$invalid"
:label="$t('INBOX_MGMT.ADD.VOICE.SUBMIT_BUTTON')"
type="submit"
color="blue"
@click="createChannel"
/>
</div>
</form>
</div>
</template>
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
// Using a regex validator for phone numbers
const validPhoneNumber = value => {
if (!value) return true;
return /^\+[1-9]\d{1,14}$/.test(value);
};
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
provider: 'twilio',
phoneNumber: '',
accountSid: '',
authToken: '',
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',
},
};
},
methods: {
onProviderChange() {
// Reset fields when provider changes
this.v$.$reset();
},
getProviderConfig() {
if (this.provider === 'twilio') {
return {
account_sid: this.accountSid,
auth_token: this.authToken,
};
}
// Add handler for other providers here
return {};
},
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
try {
const providerConfig = this.getProviderConfig();
const channel = await this.$store.dispatch(
'inboxes/createVoiceChannel',
{
voice: {
name: `Voice (${this.phoneNumber})`,
phone_number: this.phoneNumber,
provider: this.provider,
provider_config: JSON.stringify(providerConfig),
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: channel.id,
},
});
} catch (error) {
useAlert(error.response?.data?.message || this.$t('INBOX_MGMT.ADD.VOICE.API.ERROR_MESSAGE'));
}
},
},
};
</script>
@@ -22,6 +22,7 @@ const i18nMap = {
'Channel::WebWidget': 'WEB_WIDGET',
'Channel::TwitterProfile': 'TWITTER_PROFILE',
'Channel::TwilioSms': 'TWILIO_SMS',
'Channel::Voice': 'VOICE',
'Channel::Whatsapp': 'WHATSAPP',
'Channel::Sms': 'SMS',
'Channel::Email': 'EMAIL',
@@ -207,6 +207,33 @@ export const actions = {
throw error;
}
},
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
// Create a formatted payload for the voice channel
const inboxParams = {
name: params.voice.name || `Voice (${params.voice.phone_number})`,
channel: {
type: 'Channel::Voice',
phone_number: params.voice.phone_number,
provider: params.voice.provider,
provider_config: params.voice.provider_config,
},
};
// Use InboxesAPI to create the channel which handles authentication properly
const response = await InboxesAPI.create(inboxParams);
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;
}
},
createFBChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+11 -4
View File
@@ -3,6 +3,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
REPLY_TO_OUTGOING: 'replyToOutgoing',
VOICE_CALL: 'voiceCall',
};
// This is a single source of truth for inbox features
@@ -15,6 +16,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
INBOX_TYPES.VOICE,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
@@ -22,22 +24,24 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.API,
INBOX_TYPES.VOICE,
],
[INBOX_FEATURES.VOICE_CALL]: [INBOX_TYPES.VOICE],
};
export default {
computed: {
channelType() {
return this.inbox.channel_type;
return this.inbox?.channel_type || '';
},
whatsAppAPIProvider() {
return this.inbox.provider || '';
return this.inbox?.provider || '';
},
isAMicrosoftInbox() {
return this.isAnEmailChannel && this.inbox.provider === 'microsoft';
return this.isAnEmailChannel && this.inbox?.provider === 'microsoft';
},
isAGoogleInbox() {
return this.isAnEmailChannel && this.inbox.provider === 'google';
return this.isAnEmailChannel && this.inbox?.provider === 'google';
},
isAPIInbox() {
return this.channelType === INBOX_TYPES.API;
@@ -54,6 +58,9 @@ export default {
isATwilioChannel() {
return this.channelType === INBOX_TYPES.TWILIO;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},