mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-07-10 21:17:01 +08:00
Voice calling is now a capability on the existing TwilioSms rather than a separate Voice model. A single Twilio phone number handles both SMS and voice calls through one inbox. Fixes https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms **What changed** - Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms - Added voice_enabled, twiml_app_sid, api_key_secret columns to channel_twilio_sms table - Dropped channel_voice table (no production data) - All voice logic lives in Enterprise layer via prepend_mod_with('Channel::TwilioSms') - Added Voice settings tab on Twilio SMS inbox settings to enable/disable voice - Validates Twilio number voice capability before provisioning - Teardown service cleans up TwiML app and credentials when voice is disabled - Frontend voice detection uses isVoiceCallEnabled() / getVoiceCallProvider() helpers — extensible to future providers - Gated by channel_voice feature flag **How to test** 1. Enable feature flag: Account.find(<id>).enable_features('channel_voice') 2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials → verify incoming/outgoing calls and SMS work 3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox → Voice tab → toggle on → provide API key credentials → verify calls work 4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted, credentials cleared, SMS still works 5. Re-enable voice: Toggle on again → must provide api_key_secret again → new TwiML app provisioned --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
145 lines
4.1 KiB
Vue
145 lines
4.1 KiB
Vue
<script setup>
|
|
import { computed, ref, useAttrs } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
|
import { useCallsStore } from 'dashboard/stores/calls';
|
|
|
|
import Button from 'dashboard/components-next/button/Button.vue';
|
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
|
|
|
const props = defineProps({
|
|
phone: { type: String, default: '' },
|
|
contactId: { type: [String, Number], required: true },
|
|
label: { type: String, default: '' },
|
|
icon: { type: [String, Object, Function], default: '' },
|
|
size: { type: String, default: 'sm' },
|
|
tooltipLabel: { type: String, default: '' },
|
|
});
|
|
|
|
defineOptions({ inheritAttrs: false });
|
|
const attrs = useAttrs();
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const store = useStore();
|
|
|
|
const { t } = useI18n();
|
|
|
|
const dialogRef = ref(null);
|
|
|
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
|
|
|
const voiceInboxes = computed(() =>
|
|
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
|
);
|
|
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|
|
|
// Unified behavior: hide when no phone
|
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
|
|
|
const isInitiatingCall = computed(() => {
|
|
return contactsUiFlags.value?.isInitiatingCall || false;
|
|
});
|
|
|
|
const navigateToConversation = conversationId => {
|
|
const accountId = route.params.accountId;
|
|
if (conversationId && accountId) {
|
|
const path = frontendURL(
|
|
conversationUrl({
|
|
accountId,
|
|
id: conversationId,
|
|
})
|
|
);
|
|
router.push({ path });
|
|
}
|
|
};
|
|
|
|
const startCall = async inboxId => {
|
|
if (isInitiatingCall.value) return;
|
|
|
|
try {
|
|
const response = await store.dispatch('contacts/initiateCall', {
|
|
contactId: props.contactId,
|
|
inboxId,
|
|
});
|
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
|
|
|
// Add call to store immediately so widget shows
|
|
const callsStore = useCallsStore();
|
|
callsStore.addCall({
|
|
callSid,
|
|
conversationId,
|
|
inboxId,
|
|
callDirection: 'outbound',
|
|
});
|
|
|
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
|
navigateToConversation(response?.conversation_id);
|
|
} catch (error) {
|
|
const apiError = error?.message;
|
|
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
|
}
|
|
};
|
|
|
|
const onClick = async () => {
|
|
if (voiceInboxes.value.length > 1) {
|
|
dialogRef.value?.open();
|
|
return;
|
|
}
|
|
const [inbox] = voiceInboxes.value;
|
|
await startCall(inbox.id);
|
|
};
|
|
|
|
const onPickInbox = async inbox => {
|
|
dialogRef.value?.close();
|
|
await startCall(inbox.id);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<span class="contents">
|
|
<Button
|
|
v-if="shouldRender"
|
|
v-tooltip.top-end="tooltipLabel || null"
|
|
v-bind="attrs"
|
|
:disabled="isInitiatingCall"
|
|
:is-loading="isInitiatingCall"
|
|
:label="label"
|
|
:icon="icon"
|
|
:size="size"
|
|
@click="onClick"
|
|
/>
|
|
|
|
<Dialog
|
|
v-if="shouldRender && voiceInboxes.length > 1"
|
|
ref="dialogRef"
|
|
:title="$t('CONTACT_PANEL.VOICE_INBOX_PICKER.TITLE')"
|
|
show-cancel-button
|
|
:show-confirm-button="false"
|
|
width="md"
|
|
>
|
|
<div class="flex flex-col gap-2">
|
|
<button
|
|
v-for="inbox in voiceInboxes"
|
|
:key="inbox.id"
|
|
type="button"
|
|
class="flex items-center justify-between w-full px-4 py-2 text-left rounded-lg hover:bg-n-alpha-2"
|
|
@click="onPickInbox(inbox)"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<span class="i-ri-phone-fill text-n-slate-10" />
|
|
<span class="text-sm text-n-slate-12">{{ inbox.name }}</span>
|
|
</div>
|
|
<span v-if="inbox.phone_number" class="text-xs text-n-slate-10">
|
|
{{ inbox.phone_number }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</Dialog>
|
|
</span>
|
|
</template>
|