feat: Add voice channel support for Chatwoot
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_contact
|
||||
|
||||
def create
|
||||
# Find a Voice channel
|
||||
voice_inbox = Current.account.inboxes.find_by(channel_type: 'Channel::Voice')
|
||||
|
||||
if voice_inbox.blank?
|
||||
render json: { error: 'No Voice channel found' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
if @contact.phone_number.blank?
|
||||
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Initiate the call using the channel's implementation
|
||||
voice_inbox.channel.initiate_call(to: @contact.phone_number)
|
||||
|
||||
# Create a new conversation for this call if needed
|
||||
conversation = find_or_create_conversation(voice_inbox)
|
||||
|
||||
render json: conversation
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.find(params[:contact_id])
|
||||
end
|
||||
|
||||
def find_or_create_conversation(inbox)
|
||||
conversation = inbox.conversations.where(contact_id: @contact.id).last
|
||||
|
||||
if conversation.nil? || !conversation.open?
|
||||
# Find or create a contact_inbox for this contact and inbox
|
||||
contact_inbox = ContactInbox.find_or_create_by!(
|
||||
contact_id: @contact.id,
|
||||
inbox_id: inbox.id
|
||||
)
|
||||
|
||||
conversation = ::Conversation.create!(
|
||||
account_id: Current.account.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
status: :open
|
||||
)
|
||||
|
||||
# Add a note about the call being initiated
|
||||
Messages::MessageBuilder.new(
|
||||
user: Current.user,
|
||||
conversation: conversation,
|
||||
message_type: :activity,
|
||||
content: "Voice call initiated to #{@contact.phone_number}"
|
||||
).perform
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
end
|
||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
|
||||
return unless %w[web_widget api email line telegram whatsapp sms voice].include?(permitted_params[:channel][:type])
|
||||
|
||||
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
||||
end
|
||||
@@ -145,7 +145,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
'line' => Channel::Line,
|
||||
'telegram' => Channel::Telegram,
|
||||
'whatsapp' => Channel::Whatsapp,
|
||||
'sms' => Channel::Sms
|
||||
'sms' => Channel::Sms,
|
||||
'voice' => Channel::Voice
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ module Api::V1::InboxesHelper
|
||||
'line' => Current.account.line_channels,
|
||||
'telegram' => Current.account.telegram_channels,
|
||||
'whatsapp' => Current.account.whatsapp_channels,
|
||||
'sms' => Current.account.sms_channels
|
||||
'sms' => Current.account.sms_channels,
|
||||
'voice' => Current.account.voice_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module Webhooks
|
||||
# This job handles voice transcriptions from Twilio when a voice channel is configured
|
||||
class VoiceTranscriptionJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(params)
|
||||
call_sid = params['CallSid']
|
||||
transcription_text = params['TranscriptionText']
|
||||
|
||||
return if call_sid.blank? || transcription_text.blank?
|
||||
|
||||
# Find the conversation based on the call_sid stored in message metadata
|
||||
message = Message.find_by('additional_attributes @> ?', { call_sid: call_sid }.to_json)
|
||||
return if message.blank?
|
||||
|
||||
conversation = message.conversation
|
||||
|
||||
Message.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
conversation_id: conversation.id,
|
||||
message_type: :incoming,
|
||||
content: transcription_text,
|
||||
sender: conversation.contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -71,6 +71,7 @@ class Account < ApplicationRecord
|
||||
has_many :telegram_bots, dependent: :destroy_async
|
||||
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'
|
||||
has_many :twilio_sms, dependent: :destroy_async, class_name: '::Channel::TwilioSms'
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
|
||||
has_many :users, through: :account_users
|
||||
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
class Channel::Voice < ApplicationRecord
|
||||
include Channelable
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
self.table_name = 'channel_voice'
|
||||
|
||||
validates :phone_number, presence: true, uniqueness: true
|
||||
validates :provider, presence: true
|
||||
|
||||
# Provider-specific configs stored in JSON
|
||||
validates :provider_config, presence: true
|
||||
|
||||
EDITABLE_ATTRS = [:phone_number, :provider, :provider_config].freeze
|
||||
|
||||
def name
|
||||
"#{provider.capitalize} Voice"
|
||||
end
|
||||
|
||||
def initiate_call(to:)
|
||||
case provider
|
||||
when 'twilio'
|
||||
initiate_twilio_call(to)
|
||||
# Add more providers as needed
|
||||
# when 'other_provider'
|
||||
# initiate_other_provider_call(to)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initiate_twilio_call(to)
|
||||
config = provider_config_hash
|
||||
callback_url = Rails.application.routes.url_helpers.twiml_twilio_voice_url(host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
|
||||
params = { from: phone_number, to: to, url: callback_url }
|
||||
twilio_client(config).calls.create(**params)
|
||||
end
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
provider_config
|
||||
else
|
||||
JSON.parse(provider_config.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -31,6 +31,33 @@
|
||||
|
||||
en:
|
||||
hello: 'Hello world'
|
||||
INBOX_MGMT:
|
||||
ADD:
|
||||
TWILIO_VOICE:
|
||||
TITLE: 'Add Twilio Voice Channel'
|
||||
DESC: 'Integrate Twilio Voice to accept and make voice calls through Chatwoot.'
|
||||
PHONE_NUMBER:
|
||||
LABEL: 'Phone Number'
|
||||
PLACEHOLDER: '+1234567890'
|
||||
ERROR: 'Please enter a valid phone number'
|
||||
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'
|
||||
SUBMIT_BUTTON: 'Create Channel'
|
||||
API:
|
||||
ERROR_MESSAGE: 'Failed to create Twilio Voice channel'
|
||||
CONVERSATION:
|
||||
VOICE_CALL: 'Voice Call'
|
||||
CALL_ERROR: 'Failed to initiate voice call'
|
||||
CALL_INITIATED: 'Voice call initiated successfully'
|
||||
CONTACT_PANEL:
|
||||
NEW_MESSAGE: 'New Message'
|
||||
MERGE_CONTACT: 'Merge Contact'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
|
||||
@@ -104,6 +104,7 @@ Rails.application.routes.draw do
|
||||
post :retry
|
||||
end
|
||||
end
|
||||
post :call, on: :member
|
||||
resources :assignments, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resource :participants, only: [:show, :create, :update, :destroy]
|
||||
@@ -152,6 +153,7 @@ Rails.application.routes.draw do
|
||||
resources :contact_inboxes, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resources :notes
|
||||
post :call, to: 'calls#create'
|
||||
end
|
||||
end
|
||||
resources :csat_survey_responses, only: [:index] do
|
||||
@@ -477,6 +479,10 @@ Rails.application.routes.draw do
|
||||
namespace :twilio do
|
||||
resources :callback, only: [:create]
|
||||
resources :delivery_status, only: [:create]
|
||||
resource :voice, only: [] do
|
||||
get :twiml
|
||||
post :transcription_callback
|
||||
end
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
|
||||
t.index :phone_number, unique: true
|
||||
end
|
||||
end
|
||||
end
|
||||
+12
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
|
||||
ActiveRecord::Schema[7.0].define(version: 2025_04_26_140000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -442,6 +442,17 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
|
||||
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_voice", force: :cascade do |t|
|
||||
t.string "phone_number", null: false
|
||||
t.string "provider", default: "twilio", null: false
|
||||
t.jsonb "provider_config", null: false
|
||||
t.integer "account_id", null: false
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
|
||||
t.string "website_url"
|
||||
t.integer "account_id"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Reference in New Issue
Block a user