mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
chore: cleanup
This commit is contained in:
@@ -15,6 +15,7 @@ import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import { useRingtone } from 'dashboard/composables/useRingtone';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -46,6 +47,7 @@ export default {
|
||||
// Use the font size composable (it automatically sets up the watcher)
|
||||
const { currentFontSize } = useFontSize();
|
||||
const { uiSettings } = useUISettings();
|
||||
const { start: startRingTone, stop: stopRingTone } = useRingtone();
|
||||
|
||||
return {
|
||||
router,
|
||||
@@ -53,6 +55,8 @@ export default {
|
||||
currentAccountId: accountId,
|
||||
currentFontSize,
|
||||
uiSettings,
|
||||
startRingTone,
|
||||
stopRingTone,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -87,6 +91,18 @@ export default {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
hasIncomingCall(newVal) {
|
||||
// Drive ringtone globally based on incoming state; widget does not show for incoming per UX
|
||||
try {
|
||||
if (newVal) {
|
||||
this.startRingTone();
|
||||
} else {
|
||||
this.stopRingTone();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore ringtone errors
|
||||
}
|
||||
},
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
@@ -104,8 +120,8 @@ export default {
|
||||
this.uiSettings?.locale || window.chatwootConfig.selectedLocale
|
||||
);
|
||||
|
||||
// Prepare dashboard ringtone; requires a user gesture once to unlock AudioContext
|
||||
window.playAudioAlert = () => {};
|
||||
// Prepare dashboard ringtone on first user gesture to unlock AudioContext
|
||||
window.playAudioAlert = window.playAudioAlert || (() => {});
|
||||
const setupDashboardAudio = () => {
|
||||
getAlertAudio('', { type: 'dashboard', alertTone: 'call-ring' }).then(
|
||||
() => {
|
||||
@@ -123,6 +139,7 @@ export default {
|
||||
if (this.reconnectService) {
|
||||
this.reconnectService.disconnect();
|
||||
}
|
||||
try { this.stopRingTone(); } catch (e) {}
|
||||
},
|
||||
methods: {
|
||||
initializeColorTheme() {
|
||||
@@ -183,7 +200,7 @@ export default {
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
<!-- Floating call widget (Vuex-driven) -->
|
||||
<!-- Floating call widget (shows for incoming and active) -->
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
</div>
|
||||
<LoadingState v-else />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
import ContactsAPI from '../contacts';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
@@ -12,52 +13,40 @@ class VoiceAPI extends ApiClient {
|
||||
|
||||
// ------------------- Server APIs -------------------
|
||||
initiateCall(contactId, inboxId) {
|
||||
if (!contactId)
|
||||
throw new Error('Contact ID is required to initiate a call');
|
||||
const payload = {};
|
||||
if (inboxId) payload.inbox_id = inboxId;
|
||||
// The endpoint is defined in the contacts namespace, not voice namespace
|
||||
return axios.post(
|
||||
`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`,
|
||||
payload
|
||||
).then(r => r.data);
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
endCall(callSid, conversationId) {
|
||||
leaveConference(inboxId, conversationId) {
|
||||
if (!inboxId) throw new Error('Inbox ID is required to leave a conference');
|
||||
if (!conversationId)
|
||||
throw new Error('Conversation ID is required to end a call');
|
||||
if (!callSid) throw new Error('Call SID is required to end a call');
|
||||
return axios.post(`${this.url}/end_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
id: conversationId,
|
||||
}).then(r => r.data);
|
||||
throw new Error('Conversation ID is required to leave a conference');
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { params: { conversation_id: conversationId } })
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
joinCall(params) {
|
||||
joinConference(params) {
|
||||
const conversationId = params.conversation_id || params.conversationId;
|
||||
const inboxId = params.inbox_id || params.inboxId;
|
||||
const callSid = params.call_sid || params.callSid;
|
||||
const payload = { call_sid: callSid, conversation_id: conversationId };
|
||||
if (!inboxId) throw new Error('Inbox ID is required to join a call');
|
||||
if (!conversationId)
|
||||
throw new Error('Conversation ID is required to join a call');
|
||||
if (!callSid) throw new Error('Call SID is required to join a call');
|
||||
if (params.account_id) payload.account_id = params.account_id;
|
||||
return axios.post(`${this.url}/join_call`, payload).then(r => r.data);
|
||||
return axios
|
||||
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
rejectCall(callSid, conversationId) {
|
||||
if (!conversationId)
|
||||
throw new Error('Conversation ID is required to reject a call');
|
||||
if (!callSid) throw new Error('Call SID is required to reject a call');
|
||||
return axios.post(`${this.url}/reject_call`, {
|
||||
call_sid: callSid,
|
||||
conversation_id: conversationId,
|
||||
}).then(r => r.data);
|
||||
}
|
||||
// Reject is handled client-side by not joining and clearing state
|
||||
|
||||
getToken(inboxId) {
|
||||
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||
return axios.post(`${this.url}/token`, { inbox_id: inboxId }).then(r => r.data);
|
||||
return axios
|
||||
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference_token`)
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
// ------------------- Client (Twilio) APIs -------------------
|
||||
@@ -136,7 +125,21 @@ class VoiceAPI extends ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
joinClientCall({ To }) {
|
||||
destroyDevice() {
|
||||
try {
|
||||
if (this.device) {
|
||||
this.device.destroy?.();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
this.activeConnection = null;
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
joinClientCall({ To, conversationId }) {
|
||||
if (!this.device || !this.initialized) throw new Error('Twilio not ready');
|
||||
if (!To) throw new Error('Missing To');
|
||||
|
||||
@@ -152,9 +155,9 @@ class VoiceAPI extends ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
const connection = this.device.connect({
|
||||
params: { To: String(To), is_agent: 'true' },
|
||||
});
|
||||
const params = { To: String(To), is_agent: 'true' };
|
||||
if (conversationId) params.conversation_id = String(conversationId);
|
||||
const connection = this.device.connect({ params });
|
||||
this.activeConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
initiateCall(contactId, inboxId) {
|
||||
if (!contactId) throw new Error('Contact ID is required');
|
||||
const payload = {};
|
||||
if (inboxId) payload.inbox_id = inboxId;
|
||||
return axios.post(`${this.url}/${contactId}/call`, payload);
|
||||
}
|
||||
|
||||
updateContactLabels(contactId, labels) {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ provideMessageContext({
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" :message="props" />
|
||||
<Component :is="componentToRender" />
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import { useStore } from 'vuex';
|
||||
import { MESSAGE_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
const store = useStore();
|
||||
const { contentAttributes, conversationId, inboxId, messageType } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
|
||||
const status = computed(() => data.value?.status);
|
||||
const direction = computed(() => data.value?.call_direction);
|
||||
const direction = computed(() => {
|
||||
const dir = data.value?.call_direction;
|
||||
if (dir) return dir;
|
||||
const mt = messageType?.value;
|
||||
if (mt === MESSAGE_TYPES.OUTGOING) return 'outbound';
|
||||
if (mt === MESSAGE_TYPES.INCOMING) return 'inbound';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
useVoiceCallStatus(status, direction);
|
||||
@@ -17,13 +28,61 @@ const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
const containerRingClass = computed(() => {
|
||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||
});
|
||||
|
||||
// Make join available whenever the call is ringing
|
||||
const isJoinable = computed(() => status.value === 'ringing');
|
||||
const isJoining = ref(false);
|
||||
|
||||
const joinConference = async () => {
|
||||
if (!isJoinable.value || isJoining.value) return;
|
||||
try {
|
||||
isJoining.value = true;
|
||||
await VoiceAPI.initializeDevice(inboxId.value, { store });
|
||||
const res = await VoiceAPI.joinConference({
|
||||
inbox_id: inboxId.value,
|
||||
conversation_id: conversationId.value,
|
||||
call_sid: contentAttributes.value?.data?.call_sid,
|
||||
});
|
||||
const conferenceSid = res?.conference_sid;
|
||||
if (conferenceSid) {
|
||||
VoiceAPI.joinClientCall({ To: conferenceSid, conversationId: conversationId.value });
|
||||
|
||||
const currentIncoming = store.getters['calls/getIncomingCall'];
|
||||
const callSidToUse = currentIncoming?.callSid || contentAttributes.value?.data?.call_sid;
|
||||
|
||||
if (callSidToUse) {
|
||||
store.dispatch('calls/setActiveCall', {
|
||||
callSid: callSidToUse,
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
// Clear any incoming banner and stop ringtone if present
|
||||
try { store.dispatch('calls/clearIncomingCall'); } catch (_) {}
|
||||
try { if (typeof window.stopAudioAlert === 'function') window.stopAudioAlert(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore join errors here; UI will remain joinable
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
:class="containerRingClass"
|
||||
:class="[containerRingClass, { 'cursor-pointer': isJoinable }]"
|
||||
:tabindex="isJoinable ? 0 : -1"
|
||||
:role="isJoinable ? 'button' : 'article'"
|
||||
:aria-label="isJoinable ? $t('CONVERSATION.VOICE_CALL.JOIN_CALL') : ''"
|
||||
:title="isJoinable ? $t('CONVERSATION.VOICE_CALL.JOIN_CALL') : ''"
|
||||
@click.prevent.stop="joinConference"
|
||||
@keydown.enter.prevent.stop="joinConference"
|
||||
@keydown.space.prevent.stop="joinConference"
|
||||
>
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import { useRingtone } from 'dashboard/composables/useRingtone';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = ref(null);
|
||||
@@ -63,7 +66,7 @@ const stopDurationTimer = () => {
|
||||
|
||||
const isJoining = ref(false);
|
||||
|
||||
const joinCall = async () => {
|
||||
const joinConference = async () => {
|
||||
const callData = callInfo.value;
|
||||
if (!callData) return;
|
||||
if (isJoined.value || isJoining.value) return;
|
||||
@@ -74,32 +77,31 @@ const joinCall = async () => {
|
||||
await VoiceAPI.initializeDevice(callData.inboxId, { store });
|
||||
|
||||
// Join the call on server and use returned conference_sid
|
||||
const joinRes = await VoiceAPI.joinCall({
|
||||
const joinRes = await VoiceAPI.joinConference({
|
||||
conversation_id: callData.conversationId,
|
||||
inbox_id: callData.inboxId,
|
||||
call_sid: callData.callSid,
|
||||
account_id: store.getters.getCurrentAccountId,
|
||||
});
|
||||
|
||||
const conferenceSid = joinRes?.conference_sid || `conf_account_${store.getters.getCurrentAccountId}_conv_${callData.conversationId}`;
|
||||
const conferenceSid = joinRes.conference_sid;
|
||||
|
||||
// Join client call using server-provided conference sid when available
|
||||
VoiceAPI.joinClientCall({ To: conferenceSid, account_id: store.getters.getCurrentAccountId });
|
||||
VoiceAPI.joinClientCall({ To: conferenceSid, conversationId: callData.conversationId });
|
||||
|
||||
// Move from incoming to active call for outbound calls
|
||||
if (incomingCall.value?.isOutbound) {
|
||||
// Navigate to the conversation now that we're joining
|
||||
try {
|
||||
const path = `/app/accounts/${route.params.accountId}/conversations/${callData.conversationId}`;
|
||||
router.push({ path });
|
||||
} catch (_) {}
|
||||
|
||||
// Promote to active and clear any incoming ringing state
|
||||
store.dispatch('calls/setActiveCall', {
|
||||
...callData,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
if (hasIncomingCall.value) {
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
store.dispatch('calls/setActiveCall', {
|
||||
...callData,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// Mark as joined for regular active calls
|
||||
store.dispatch('calls/setActiveCall', {
|
||||
...callData,
|
||||
isJoined: true,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
startDurationTimer();
|
||||
@@ -120,11 +122,11 @@ const endCall = async () => {
|
||||
stopRingTone();
|
||||
|
||||
// End server call first to terminate on Twilio's side
|
||||
if (callInfo.value.callSid && callInfo.value.callSid !== 'pending' && callInfo.value.conversationId) {
|
||||
if (callInfo.value.inboxId && callInfo.value.conversationId) {
|
||||
try {
|
||||
await VoiceAPI.endCall(callInfo.value.callSid, callInfo.value.conversationId);
|
||||
await VoiceAPI.leaveConference(callInfo.value.inboxId, callInfo.value.conversationId);
|
||||
} catch (serverError) {
|
||||
// Server call end failed, but continue with client cleanup
|
||||
// Server leave failed, continue client cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,37 +155,24 @@ const endCall = async () => {
|
||||
};
|
||||
|
||||
const acceptCall = async () => {
|
||||
// Reuse join logic; acceptIncomingCall will be triggered via joinCall flow
|
||||
await joinCall();
|
||||
await joinConference();
|
||||
};
|
||||
|
||||
const rejectCall = async () => {
|
||||
if (!incomingCall.value) return;
|
||||
|
||||
try {
|
||||
// End any WebRTC connection first to disconnect customer immediately
|
||||
VoiceAPI.endClientCall();
|
||||
|
||||
// Then call server API to reject the call
|
||||
await VoiceAPI.rejectCall(incomingCall.value.callSid, incomingCall.value.conversationId);
|
||||
|
||||
// Clear state
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
stopRingTone();
|
||||
|
||||
} catch (error) {
|
||||
// Even if reject API fails, still clean up WebRTC and state
|
||||
VoiceAPI.endClientCall();
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
stopRingTone();
|
||||
}
|
||||
// End any WebRTC connection first
|
||||
VoiceAPI.endClientCall();
|
||||
// Clear state; server-side webhook will mark no-answer on conference end
|
||||
store.dispatch('calls/clearIncomingCall');
|
||||
stopRingTone();
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch([isOutgoing, incomingCall], ([newIsOutgoing, newIncomingCall]) => {
|
||||
// Auto-join outgoing calls when they appear
|
||||
if (newIsOutgoing && newIncomingCall?.isOutbound && !isJoined.value) {
|
||||
joinCall();
|
||||
joinConference();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -225,7 +214,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasIncomingCall || hasActiveCall"
|
||||
v-if="hasActiveCall || hasIncomingCall"
|
||||
class="fixed bottom-4 right-4 z-50 w-80 rounded-xl bg-white shadow-2xl border border-slate-200 dark:bg-slate-800 dark:border-slate-700"
|
||||
>
|
||||
<!-- Header -->
|
||||
|
||||
@@ -90,8 +90,11 @@ const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
const {
|
||||
labelKey: voiceLabelKey,
|
||||
listIconColor: voiceIconColor,
|
||||
bubbleIconName: voiceIconName,
|
||||
} = useVoiceCallStatus(callStatus, callDirection);
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
@@ -324,8 +327,8 @@ const deleteConversation = () => {
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
class="inline-block -mt-0.5 align-middle text-[16px]"
|
||||
:class="[voiceIconColor, voiceIconName]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
|
||||
@@ -53,67 +53,26 @@ export default {
|
||||
// Simple check: Is this a voice channel conversation?
|
||||
isVoiceChannel() {
|
||||
return (
|
||||
this.conversation?.channel === 'Channel::Voice' ||
|
||||
this.conversation?.meta?.channel === 'Channel::Voice' ||
|
||||
this.conversation?.meta?.inbox?.channel_type === 'Channel::Voice'
|
||||
);
|
||||
},
|
||||
// Check if this is a voice call message
|
||||
isVoiceCall() {
|
||||
return this.message?.content_type === CONTENT_TYPES.VOICE_CALL;
|
||||
const ct = this.message?.content_type;
|
||||
return ct === CONTENT_TYPES.VOICE_CALL || ct === 12;
|
||||
},
|
||||
// Get call direction for voice calls
|
||||
// Get call direction for voice calls (authoritative: conversation-level)
|
||||
isIncomingCall() {
|
||||
if (!this.isVoiceChannel) return false;
|
||||
|
||||
// Prefer conversation-level call direction
|
||||
const convDir = this.conversation?.additional_attributes?.call_direction;
|
||||
if (convDir) return convDir === 'inbound';
|
||||
|
||||
// Fallback to last message direction if present
|
||||
const msgDir = this.message?.content_attributes?.data?.call_direction;
|
||||
if (msgDir) return msgDir === 'inbound';
|
||||
|
||||
return false;
|
||||
return convDir === 'inbound';
|
||||
},
|
||||
// Get call status (Twilio-native)
|
||||
callStatus() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
// Prefer conversation-level status
|
||||
const convStatus = this.conversation?.additional_attributes?.call_status;
|
||||
if (convStatus) return convStatus;
|
||||
|
||||
// Fallback to last message status if available
|
||||
const msgStatus = this.message?.content_attributes?.data?.status;
|
||||
if (msgStatus) return msgStatus;
|
||||
|
||||
return null;
|
||||
},
|
||||
// Voice call icon based on status
|
||||
voiceCallIcon() {
|
||||
if (!this.isVoiceChannel) return null;
|
||||
|
||||
const status = this.callStatus;
|
||||
const isIncoming = this.isIncomingCall;
|
||||
|
||||
if (status === 'no-answer' || status === 'busy' || status === 'failed') {
|
||||
return 'phone-missed-call';
|
||||
}
|
||||
|
||||
if (status === 'in-progress') {
|
||||
return 'phone-in-talk';
|
||||
}
|
||||
|
||||
if (status === 'completed' || status === 'canceled') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
if (status === 'ringing') {
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
}
|
||||
|
||||
// Default based on direction
|
||||
return isIncoming ? 'phone-incoming' : 'phone-outgoing';
|
||||
return this.message?.content_attributes?.data?.status || null;
|
||||
},
|
||||
parsedLastMessage() {
|
||||
// For voice calls, return status text
|
||||
|
||||
@@ -525,7 +525,6 @@ export default {
|
||||
</div>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
:is-private-note-only="isAVoiceChannel"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
import { getAlertAudio } from 'shared/helpers/AudioNotificationHelper';
|
||||
|
||||
export function useRingtone(intervalMs = 2500) {
|
||||
const timer = ref(null);
|
||||
@@ -22,8 +23,15 @@ export function useRingtone(intervalMs = 2500) {
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const start = async () => {
|
||||
stop();
|
||||
try {
|
||||
if (typeof window.playAudioAlert !== 'function') {
|
||||
await getAlertAudio('', { type: 'dashboard', alertTone: 'call-ring' });
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore preload errors
|
||||
}
|
||||
play();
|
||||
timer.value = setInterval(() => {
|
||||
play();
|
||||
|
||||
@@ -99,18 +99,16 @@ export function useVoiceCallStatus(statusRef, directionRef) {
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
? 'i-ph-phone-call'
|
||||
: 'i-ph-phone-call';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'i-ph-phone-x-fill';
|
||||
return 'i-ph-phone-x';
|
||||
}
|
||||
|
||||
// For ringing/completed/canceled show direction when possible
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bubbleIconBg = computed(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import AuthAPI from '../api/auth';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
@@ -52,6 +54,64 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onMessageUpdated = data => {
|
||||
this.app.$store.dispatch('updateMessage', data);
|
||||
|
||||
// Sync call status when voice_call message gets updated
|
||||
try {
|
||||
const isVoiceCallMessage =
|
||||
data?.content_type === 12 || data?.content_type === 'voice_call';
|
||||
const status = data?.content_attributes?.data?.status;
|
||||
const callSid = data?.content_attributes?.data?.call_sid;
|
||||
const conversationId = data?.conversation_id;
|
||||
const inboxId = data?.inbox_id;
|
||||
|
||||
if (isVoiceCallMessage && callSid && status) {
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid,
|
||||
status,
|
||||
});
|
||||
// Reflect it on conversation list item state
|
||||
try {
|
||||
this.app.$store.commit('UPDATE_CONVERSATION_CALL_STATUS', {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore commit errors
|
||||
}
|
||||
|
||||
// Backfill incoming call payload only when ringing and no active/incoming
|
||||
const hasIncoming = this.app.$store.getters['calls/hasIncomingCall'];
|
||||
const hasActive = this.app.$store.getters['calls/hasActiveCall'];
|
||||
if (status === 'ringing' && !hasIncoming && !hasActive) {
|
||||
const conv = this.app.$store.getters[
|
||||
'conversations/getConversationById'
|
||||
]?.(conversationId);
|
||||
const inboxFromStore = this.app.$store.getters['inboxes/getInbox']?.(
|
||||
conv?.inbox_id || inboxId
|
||||
);
|
||||
const payload = ActionCableConnector.buildIncomingCallPayload(
|
||||
{
|
||||
...data,
|
||||
display_id: conversationId,
|
||||
inbox_id: conv?.inbox_id || inboxId,
|
||||
account_id: conv?.account_id || data?.account_id,
|
||||
meta: {
|
||||
inbox: conv?.meta?.inbox || {
|
||||
name: inboxFromStore?.name,
|
||||
avatar_url: inboxFromStore?.avatar_url,
|
||||
phone_number: inboxFromStore?.phone_number,
|
||||
},
|
||||
sender: conv?.meta?.sender,
|
||||
},
|
||||
},
|
||||
inboxFromStore
|
||||
);
|
||||
this.app.$store.dispatch('calls/setIncomingCall', payload);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore voice call sync errors
|
||||
}
|
||||
};
|
||||
|
||||
onPresenceUpdate = data => {
|
||||
@@ -82,21 +142,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onConversationCreated = data => {
|
||||
this.app.$store.dispatch('addConversation', data);
|
||||
|
||||
// Check if this is a voice channel conversation (incoming call)
|
||||
if (this.constructor.isVoiceChannel(data)) {
|
||||
if (data.additional_attributes?.call_sid) {
|
||||
const inboxFromStore = this.app.$store.getters['inboxes/getInbox']?.(
|
||||
data.inbox_id
|
||||
);
|
||||
const payload = this.constructor.buildIncomingCallPayload(
|
||||
data,
|
||||
inboxFromStore
|
||||
);
|
||||
this.app.$store.dispatch('calls/setIncomingCall', payload);
|
||||
}
|
||||
}
|
||||
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
@@ -105,18 +150,72 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onLogout = () => AuthAPI.logout();
|
||||
onLogout = () => {
|
||||
try {
|
||||
VoiceAPI.endClientCall();
|
||||
VoiceAPI.destroyDevice();
|
||||
} catch (_) {}
|
||||
AuthAPI.logout();
|
||||
};
|
||||
|
||||
onMessageCreated = data => {
|
||||
const {
|
||||
conversation: { last_activity_at: lastActivityAt },
|
||||
conversation_id: conversationId,
|
||||
} = data;
|
||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||
this.app.$store.dispatch('addMessage', data);
|
||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||
lastActivityAt,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
// Voice call messages bootstrap call state across clients
|
||||
try {
|
||||
const isVoiceCallMessage =
|
||||
data?.content_type === 12 || data?.content_type === 'voice_call';
|
||||
if (isVoiceCallMessage) {
|
||||
const status = data?.content_attributes?.data?.status;
|
||||
const callSid = data?.content_attributes?.data?.call_sid;
|
||||
if (callSid) {
|
||||
// Compose payload using conversation details from store
|
||||
const conv = this.app.$store.getters[
|
||||
'conversations/getConversationById'
|
||||
]?.(conversationId);
|
||||
const inboxFromStore = this.app.$store.getters['inboxes/getInbox']?.(
|
||||
conv?.inbox_id || data?.inbox_id
|
||||
);
|
||||
const payload = ActionCableConnector.buildIncomingCallPayload(
|
||||
{
|
||||
...data,
|
||||
display_id: conversationId,
|
||||
inbox_id: conv?.inbox_id || data?.inbox_id,
|
||||
account_id: conv?.account_id || data?.account_id,
|
||||
meta: {
|
||||
inbox: conv?.meta?.inbox || {
|
||||
name: inboxFromStore?.name,
|
||||
avatar_url: inboxFromStore?.avatar_url,
|
||||
phone_number: inboxFromStore?.phone_number,
|
||||
},
|
||||
sender: conv?.meta?.sender,
|
||||
},
|
||||
},
|
||||
inboxFromStore
|
||||
);
|
||||
|
||||
this.app.$store.dispatch('calls/setIncomingCall', payload);
|
||||
|
||||
if (status) {
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// non-fatal; ignore voice bootstrap errors
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
@@ -129,53 +228,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
onConversationUpdated = data => {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
|
||||
// Check if this conversation update includes call status changes
|
||||
if (
|
||||
data.additional_attributes?.call_status &&
|
||||
data.additional_attributes?.call_sid
|
||||
) {
|
||||
this.app.$store.dispatch('calls/handleCallStatusChanged', {
|
||||
callSid: data.additional_attributes.call_sid,
|
||||
status: data.additional_attributes.call_status,
|
||||
conversationId: data.display_id,
|
||||
inboxId: data.inbox_id,
|
||||
});
|
||||
|
||||
// Reflect call status onto the latest voice call message in the store
|
||||
try {
|
||||
this.app.$store.commit('UPDATE_CONVERSATION_CALL_STATUS', {
|
||||
conversationId: data.display_id,
|
||||
callStatus: data.additional_attributes.call_status,
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore store commit failures
|
||||
}
|
||||
|
||||
// Backfill: if status indicates a live call and we missed creation
|
||||
if (['ringing', 'in-progress'].includes(data.additional_attributes.call_status)) {
|
||||
const hasIncoming = this.app.$store.getters['calls/hasIncomingCall'];
|
||||
const hasActive = this.app.$store.getters['calls/hasActiveCall'];
|
||||
const currentIncoming =
|
||||
this.app.$store.getters['calls/getIncomingCall'];
|
||||
if (
|
||||
!hasIncoming &&
|
||||
!hasActive &&
|
||||
(!currentIncoming ||
|
||||
currentIncoming.callSid !== data.additional_attributes.call_sid)
|
||||
) {
|
||||
const inboxFromStore = this.app.$store.getters['inboxes/getInbox']?.(
|
||||
data.inbox_id
|
||||
);
|
||||
const payload = this.constructor.buildIncomingCallPayload(
|
||||
data,
|
||||
inboxFromStore
|
||||
);
|
||||
this.app.$store.dispatch('calls/setIncomingCall', payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.fetchConversationStats();
|
||||
};
|
||||
|
||||
@@ -191,7 +243,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
// Normalize an incoming call payload for Vuex calls module
|
||||
static buildIncomingCallPayload(data, inboxFromStore) {
|
||||
return {
|
||||
callSid: data.additional_attributes?.call_sid,
|
||||
callSid: data.content_attributes?.data?.call_sid,
|
||||
conversationId: data.display_id || data.id,
|
||||
inboxId: data.inbox_id,
|
||||
inboxName: data.meta?.inbox?.name || inboxFromStore?.name,
|
||||
@@ -201,8 +253,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
contactName: data.meta?.sender?.name || 'Unknown Caller',
|
||||
contactId: data.meta?.sender?.id,
|
||||
accountId: data.account_id,
|
||||
isOutbound: data.additional_attributes?.call_direction === 'outbound',
|
||||
callDirection: data.additional_attributes?.call_direction,
|
||||
isOutbound: data.content_attributes?.data?.call_direction === 'outbound',
|
||||
callDirection: data.content_attributes?.data?.call_direction,
|
||||
phoneNumber: data.meta?.sender?.phone_number,
|
||||
avatarUrl: data.meta?.sender?.avatar_url,
|
||||
};
|
||||
|
||||
@@ -61,4 +61,17 @@ class InboxPolicy < ApplicationPolicy
|
||||
def sync_templates?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
# Voice conference endpoints authorization
|
||||
def conference_token?
|
||||
show?
|
||||
end
|
||||
|
||||
def conference_join?
|
||||
show?
|
||||
end
|
||||
|
||||
def conference_leave?
|
||||
show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,51 +31,6 @@
|
||||
|
||||
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'
|
||||
END_CALL: 'End call'
|
||||
CALL_ENDED: 'Call ended successfully'
|
||||
CALL_END_ERROR: 'Failed to end call. Please try again.'
|
||||
AUDIO_NOT_SUPPORTED: 'Your browser does not support audio playback'
|
||||
TRANSCRIPTION: 'Transcription'
|
||||
VOICE_CALL:
|
||||
RINGING: 'Incoming Call - Join'
|
||||
ACTIVE: 'Call in progress'
|
||||
MISSED: 'Missed Call'
|
||||
ENDED: 'Call Ended'
|
||||
INCOMING_CALL: 'Incoming Call'
|
||||
JOIN_CALL: 'Join'
|
||||
CALL_JOINED: 'Joining call...'
|
||||
JOIN_ERROR: 'Failed to join call. Please try again.'
|
||||
MISSED_CALL: 'Call was not answered'
|
||||
DURATION: 'Duration: %{duration}'
|
||||
INCOMING_FROM: 'Incoming call from %{name}'
|
||||
OUTGOING_FROM: 'Outgoing call from %{name}'
|
||||
CONTACT_PANEL:
|
||||
NEW_MESSAGE: 'New Message'
|
||||
MERGE_CONTACT: 'Merge Contact'
|
||||
inbox:
|
||||
reauthorization:
|
||||
success: 'Channel reauthorized successfully'
|
||||
|
||||
@@ -196,19 +196,14 @@ Rails.application.routes.draw do
|
||||
post :set_agent_bot, on: :member
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
end
|
||||
|
||||
# Voice call management (Enterprise-only)
|
||||
if ChatwootApp.enterprise?
|
||||
resource :voice, only: [], controller: 'voice' do
|
||||
collection do
|
||||
post :end_call
|
||||
post :join_call
|
||||
post :reject_call
|
||||
post :token
|
||||
end
|
||||
if ChatwootApp.enterprise?
|
||||
# Conference operations
|
||||
get :conference_token, on: :member, to: 'voice#conference_token'
|
||||
post :conference, on: :member, to: 'voice#conference_join'
|
||||
delete :conference, on: :member, to: 'voice#conference_leave'
|
||||
end
|
||||
end
|
||||
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
collection do
|
||||
delete :destroy
|
||||
@@ -546,6 +541,7 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
post 'call/:phone', action: :call_twiml
|
||||
post 'status/:phone', action: :status
|
||||
post 'conference_status/:phone', action: :conference_status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,28 +8,33 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user,
|
||||
inbox_id: params[:inbox_id]
|
||||
)
|
||||
|
||||
# Process the call - this handles all the steps
|
||||
conversation = service.process
|
||||
|
||||
# Assign to @conversation so jbuilder template can access it
|
||||
@conversation = conversation
|
||||
# Use the outgoing call service to handle the entire process
|
||||
service = Voice::OutgoingCallService.new(
|
||||
account: Current.account,
|
||||
contact: @contact,
|
||||
user: Current.user,
|
||||
inbox_id: params[:inbox_id]
|
||||
)
|
||||
|
||||
# Use the conversation jbuilder template to ensure consistent representation
|
||||
# This will ensure only display_id is used as the id, not the internal database id
|
||||
render 'api/v1/accounts/conversations/show'
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error initiating call: #{e.message}")
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
result = service.process
|
||||
conversation = Current.account.conversations.find_by!(display_id: result[:conversation_id])
|
||||
conference_sid = result[:conference_sid]
|
||||
call_sid = result[:call_sid]
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
conversation_id: result[:conversation_id],
|
||||
inbox_id: conversation.inbox_id,
|
||||
conference_sid: conference_sid,
|
||||
call_sid: call_sid
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'not_found', code: 'not_found', details: e.message }, status: :not_found
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("VOICE_CONTACT_CALL_ERROR #{e.class}: #{e.message}")
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
render json: { error: 'failed_to_initiate_call', code: 'initiate_error', details: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,98 +1,69 @@
|
||||
require 'twilio-ruby'
|
||||
|
||||
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
|
||||
before_action :set_voice_inbox, only: %i[token]
|
||||
before_action :set_voice_inbox_for_conference, only: %i[conference_token conference_join conference_leave]
|
||||
|
||||
def end_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found_error('No active call found') unless call_sid
|
||||
|
||||
begin
|
||||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||||
rescue StandardError
|
||||
# ignore provider errors
|
||||
end
|
||||
|
||||
render json: { status: 'success', message: 'Call successfully ended' }
|
||||
rescue StandardError => e
|
||||
render_internal_server_error("Failed to end call: #{e.message}")
|
||||
end
|
||||
|
||||
def join_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
outbound = convo_attr('requires_agent_join') == true
|
||||
|
||||
return render_not_found_error('No active call found') unless call_sid || outbound
|
||||
|
||||
conference_sid = convo_attr('conference_sid') || create_conference_sid!
|
||||
update_join_metadata!(call_sid)
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
message: 'Agent joining call via WebRTC',
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
conversation_id: @conversation.display_id,
|
||||
account_id: Current.account.id
|
||||
}
|
||||
rescue StandardError => e
|
||||
render_internal_server_error("Failed to join call: #{e.message}")
|
||||
end
|
||||
|
||||
def reject_call
|
||||
call_sid = params[:call_sid] || convo_attr('call_sid')
|
||||
return render_not_found_error('No active call found') unless call_sid
|
||||
|
||||
begin
|
||||
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
|
||||
rescue StandardError
|
||||
# ignore provider errors
|
||||
end
|
||||
|
||||
# Mark rejection metadata; status will be updated via Twilio webhooks
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge(
|
||||
'agent_rejected' => true,
|
||||
'rejected_at' => Time.current.to_i,
|
||||
'rejected_by' => user_meta
|
||||
))
|
||||
|
||||
render json: { status: 'success', message: 'Call rejected by agent' }
|
||||
end
|
||||
|
||||
|
||||
# Token for client SDK
|
||||
def token
|
||||
# GET /api/v1/accounts/:account_id/inboxes/:inbox_id/conference_token
|
||||
def conference_token
|
||||
payload = Voice::TokenService.new(
|
||||
inbox: @voice_inbox,
|
||||
user: Current.user,
|
||||
account: Current.account
|
||||
).generate
|
||||
|
||||
render json: payload
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Voice::Token error: #{e.class} - #{e.message}")
|
||||
render json: { error: 'Failed to generate token' }, status: :internal_server_error
|
||||
Rails.logger.error("VOICE_CONFERENCE_TOKEN_ERROR #{e.class}: #{e.message}")
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
render json: { error: 'failed_to_generate_token', code: 'token_error', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# POST /api/v1/accounts/:account_id/inboxes/:inbox_id/conference
|
||||
def conference_join
|
||||
conversation = fetch_conversation_by_display_id
|
||||
# Deterministic conference SID from account + call_sid
|
||||
call_sid = conversation.identifier
|
||||
if call_sid.blank?
|
||||
incoming_sid = params[:call_sid].to_s
|
||||
if incoming_sid.blank?
|
||||
return render json: { error: 'conference_not_ready', code: 'not_ready', details: 'call_sid missing' }, status: :conflict
|
||||
end
|
||||
call_sid = incoming_sid
|
||||
conversation.update!(identifier: call_sid)
|
||||
end
|
||||
conference_sid = "conf_account_#{Current.account.id}_call_#{call_sid}"
|
||||
@conversation = conversation
|
||||
update_join_metadata!(conversation.identifier)
|
||||
render json: {
|
||||
status: 'success',
|
||||
conversation_id: conversation.display_id,
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true,
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'conversation_not_found', code: 'not_found', details: e.message }, status: :not_found
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("VOICE_CONFERENCE_JOIN_ERROR #{e.class}: #{e.message}")
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
render json: { error: 'failed_to_join_conference', code: 'join_error', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/conference?conversation_id=:id
|
||||
def conference_leave
|
||||
conversation = fetch_conversation_by_display_id
|
||||
# End the conference when an agent leaves from the app
|
||||
Voice::ConferenceEndService.new(conversation: conversation).perform
|
||||
render json: { status: 'success', conversation_id: conversation.display_id }
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'conversation_not_found', code: 'not_found', details: e.message }, status: :not_found
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("VOICE_CONFERENCE_LEAVE_ERROR #{e.class}: #{e.message}")
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
render json: { error: 'failed_to_leave_conference', code: 'leave_error', details: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
||||
def fetch_conversation
|
||||
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= begin
|
||||
cfg = @conversation.inbox.channel.provider_config_hash
|
||||
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||||
end
|
||||
end
|
||||
|
||||
def in_progress?(call_sid)
|
||||
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
|
||||
end
|
||||
|
||||
def convo_attrs
|
||||
@conversation.additional_attributes || {}
|
||||
end
|
||||
@@ -105,12 +76,6 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
{ id: current_user.id, name: current_user.name }
|
||||
end
|
||||
|
||||
def create_conference_sid!
|
||||
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
|
||||
sid
|
||||
end
|
||||
|
||||
def update_join_metadata!(call_sid)
|
||||
attrs = convo_attrs.merge(
|
||||
'agent_joined' => true,
|
||||
@@ -121,10 +86,14 @@ class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
|
||||
@conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
@voice_inbox = Current.account.inboxes.find(params[:id] || params[:inbox_id])
|
||||
authorize @voice_inbox
|
||||
end
|
||||
|
||||
# ---- Tokens ---------------------------------------------------------------
|
||||
|
||||
def set_voice_inbox
|
||||
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
def fetch_conversation_by_display_id
|
||||
cid = params[:conversation_id] || params[:conversationId]
|
||||
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
|
||||
Current.account.conversations.find_by!(display_id: cid)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
class Twilio::VoiceController < ApplicationController
|
||||
require 'digest'
|
||||
before_action :set_inbox!
|
||||
before_action :validate_twilio_signature!, only: %i[status conference_status]
|
||||
|
||||
def status
|
||||
call_sid = params[:CallSid]
|
||||
call_status = params[:CallStatus]
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_STATUS account=#{@inbox.account_id} phone_param=#{params[:phone]} call_sid=#{call_sid} status=#{call_status}"
|
||||
)
|
||||
Voice::StatusUpdateService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: params[:CallSid],
|
||||
call_status: params[:CallStatus]
|
||||
call_sid: call_sid,
|
||||
call_status: call_status
|
||||
).perform
|
||||
head :no_content
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
"TWILIO_VOICE_STATUS_ERROR account=#{@inbox.account_id} call_sid=#{call_sid} error=#{e.class}: #{e.message}"
|
||||
)
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
@@ -15,15 +29,121 @@ class Twilio::VoiceController < ApplicationController
|
||||
call_sid = params[:CallSid]
|
||||
from_number = params[:From].to_s
|
||||
to_number = params[:To].to_s
|
||||
log_from = Digest::SHA1.hexdigest(from_number)[0, 8]
|
||||
log_to = to_number.start_with?('conf_account_') ? to_number : Digest::SHA1.hexdigest(to_number)[0, 8]
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_TWIML_REQUEST account=#{account.id} phone_param=#{params[:phone]} call_sid=#{call_sid} from_hash=#{log_from} to=#{log_to}"
|
||||
)
|
||||
|
||||
builder = Voice::InboundCallBuilder.new(
|
||||
account: account,
|
||||
inbox: @inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
render xml: builder.twiml_response
|
||||
# Ensure conversation exists to compute readable conference SID (display_id)
|
||||
agent_leg = params[:is_agent].to_s == 'true' || from_number.start_with?('client:') || to_number.start_with?('conf_account_')
|
||||
conversation = nil
|
||||
if agent_leg
|
||||
# Prefer explicit conversation_id param if present (from WebRTC Client)
|
||||
if params[:conversation_id].present?
|
||||
conversation = account.conversations.find_by(display_id: params[:conversation_id])
|
||||
elsif to_number =~ /^conf_account_\d+_conv_(\d+)$/
|
||||
display_id = Regexp.last_match(1)
|
||||
conversation = account.conversations.find_by(display_id: display_id)
|
||||
end
|
||||
else
|
||||
# PSTN leg: try to resolve an existing outbound conversation first to avoid
|
||||
# creating an inbound duplicate. Prefer identifier match, then latest outbound.
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
if conversation.nil?
|
||||
conversation = account.conversations
|
||||
.where(inbox_id: @inbox.id)
|
||||
.where("additional_attributes->>'call_direction' = ?", 'outbound')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
end
|
||||
# If still not found, this is a true inbound call; create conversation + message
|
||||
if conversation.nil?
|
||||
conversation = Voice::CallOrchestratorService.new(
|
||||
account: account,
|
||||
inbox: @inbox,
|
||||
direction: :inbound,
|
||||
phone_number: from_number,
|
||||
call_sid: call_sid
|
||||
).inbound!
|
||||
end
|
||||
end
|
||||
|
||||
unless conversation
|
||||
Rails.logger.error("TWILIO_VOICE_TWIML_ERROR conversation_not_found call_sid=#{call_sid}")
|
||||
fallback = Twilio::TwiML::VoiceResponse.new
|
||||
fallback.say(message: 'We are unable to connect your call at this time. Please try again later.')
|
||||
fallback.hangup
|
||||
return render xml: fallback.to_s
|
||||
end
|
||||
|
||||
conference_sid = "conf_account_#{account.id}_conv_#{conversation.display_id}"
|
||||
Rails.logger.info("TWILIO_VOICE_TWIML_CONFERENCE account=#{account.id} conference_sid=#{conference_sid}")
|
||||
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
host = ENV.fetch('FRONTEND_URL', '')
|
||||
phone_digits = @inbox.channel.phone_number.delete_prefix('+')
|
||||
conf_status_url = "#{host}/twilio/voice/conference_status/#{phone_digits}"
|
||||
# Note: agent_leg computed above
|
||||
response.say(message: 'Please wait while we connect you to an agent') unless agent_leg
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg,
|
||||
end_conference_on_exit: false,
|
||||
beep: 'on',
|
||||
status_callback: conf_status_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST'
|
||||
)
|
||||
end
|
||||
render xml: response.to_s
|
||||
end
|
||||
|
||||
# Twilio Conference Status webhook
|
||||
# Receives events: conference-start, conference-end, participant-join, participant-leave
|
||||
def conference_status
|
||||
account = @inbox.account
|
||||
event = params[:StatusCallbackEvent].to_s
|
||||
conference_sid = params[:ConferenceSid].to_s
|
||||
call_sid = params[:CallSid].to_s
|
||||
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_CONFERENCE_STATUS account=#{account.id} event=#{event} conference_sid=#{conference_sid} call_sid=#{call_sid}"
|
||||
)
|
||||
|
||||
# Map Twilio events to internal events
|
||||
mapped = case event
|
||||
when /conference-start/i then 'start'
|
||||
when /conference-end/i then 'end'
|
||||
when /participant-join/i then 'join'
|
||||
when /participant-leave/i then 'leave'
|
||||
else nil
|
||||
end
|
||||
|
||||
if mapped
|
||||
conversation = account.conversations
|
||||
.where("additional_attributes->>'conference_sid' = ?", conference_sid)
|
||||
.first
|
||||
if conversation
|
||||
Voice::ConferenceManagerService.new(
|
||||
conversation: conversation,
|
||||
event: mapped,
|
||||
call_sid: call_sid,
|
||||
participant_label: nil
|
||||
).process
|
||||
else
|
||||
Rails.logger.warn(
|
||||
"TWILIO_VOICE_CONFERENCE_STATUS_CONV_NOT_FOUND account=#{account.id} conf=#{conference_sid}"
|
||||
)
|
||||
end
|
||||
else
|
||||
Rails.logger.warn(
|
||||
"TWILIO_VOICE_CONFERENCE_STATUS_EVENT_UNHANDLED account=#{account.id} event=#{event}"
|
||||
)
|
||||
end
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
@@ -35,4 +155,21 @@ class Twilio::VoiceController < ApplicationController
|
||||
channel = Channel::Voice.find_by!(phone_number: e164)
|
||||
@inbox = channel.inbox
|
||||
end
|
||||
|
||||
def validate_twilio_signature!
|
||||
cfg = @inbox.channel.provider_config_hash
|
||||
token = cfg['auth_token']
|
||||
validator = ::Twilio::Security::RequestValidator.new(token)
|
||||
signature = request.headers['X-Twilio-Signature']
|
||||
url = request.url
|
||||
params = request.request_parameters.presence || request.query_parameters
|
||||
valid = validator.validate(url, params, signature)
|
||||
unless valid
|
||||
Rails.logger.warn("TWILIO_SIGNATURE_INVALID path=#{request.path}")
|
||||
head :forbidden
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("TWILIO_SIGNATURE_ERROR #{e.class}: #{e.message}")
|
||||
head :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
22
enterprise/app/jobs/voice/ensure_conversation_job.rb
Normal file
22
enterprise/app/jobs/voice/ensure_conversation_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Voice::EnsureConversationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account_id:, inbox_id:, from_number:, to_number:, call_sid:)
|
||||
account = Account.find(account_id)
|
||||
inbox = account.inboxes.find(inbox_id)
|
||||
|
||||
Voice::CallOrchestratorService.new(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
direction: :inbound,
|
||||
phone_number: from_number,
|
||||
call_sid: call_sid
|
||||
).inbound!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("VOICE_ENSURE_CONVERSATION_JOB_ERROR account=#{account_id} inbox=#{inbox_id} call_sid=#{call_sid} error=#{e.class}: #{e.message}")
|
||||
Sentry.capture_exception(e) if defined?(Sentry)
|
||||
Raven.capture_exception(e) if defined?(Raven)
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,7 +45,11 @@ class Channel::Voice < ApplicationRecord
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
case provider
|
||||
when 'twilio'
|
||||
initiate_twilio_call(to, conference_sid, agent_id)
|
||||
Voice::Provider::TwilioAdapter.new(self).initiate_call(
|
||||
to: to,
|
||||
conference_sid: conference_sid,
|
||||
agent_id: agent_id
|
||||
)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
end
|
||||
@@ -85,42 +89,7 @@ class Channel::Voice < ApplicationRecord
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
def initiate_twilio_call(to, conference_sid = nil, agent_id = nil)
|
||||
config = provider_config_hash
|
||||
|
||||
host = ENV.fetch('FRONTEND_URL')
|
||||
phone_digits = phone_number.delete_prefix('+')
|
||||
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
|
||||
|
||||
params = {
|
||||
from: phone_number,
|
||||
to: to,
|
||||
url: callback_url,
|
||||
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
|
||||
status_callback_event: %w[initiated ringing answered completed failed busy no-answer canceled],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
Rails.logger.info(
|
||||
"VOICE_OUTBOUND_CALL_PARAMS to=#{to} from=#{phone_number} conference=#{conference_sid}"
|
||||
)
|
||||
|
||||
call = twilio_client(config).calls.create(**params)
|
||||
|
||||
{
|
||||
provider: 'twilio',
|
||||
call_sid: call.sid,
|
||||
status: call.status,
|
||||
call_direction: 'outbound',
|
||||
requires_agent_join: true,
|
||||
agent_id: agent_id,
|
||||
conference_sid: conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
|
||||
@@ -4,6 +4,9 @@ module Voice
|
||||
|
||||
def inbound!
|
||||
raise 'From number is required' if phone_number.blank?
|
||||
Rails.logger.info(
|
||||
"VOICE_ORCH_INBOUND account=#{account.id} inbox=#{inbox.id} from=#{phone_number} call_sid=#{call_sid}"
|
||||
)
|
||||
|
||||
@conversation = Voice::ConversationFinderService.new(
|
||||
account: account,
|
||||
@@ -13,7 +16,11 @@ module Voice
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
|
||||
ensure_conference_sid!
|
||||
if call_sid.present? && @conversation.identifier != call_sid
|
||||
@conversation.update!(identifier: call_sid)
|
||||
end
|
||||
|
||||
ensure_conference_sid!(call_sid)
|
||||
|
||||
Voice::CallMessageBuilder.new(
|
||||
conversation: @conversation,
|
||||
@@ -26,60 +33,55 @@ module Voice
|
||||
).perform
|
||||
|
||||
|
||||
Rails.logger.info(
|
||||
"VOICE_ORCH_INBOUND_OK account=#{account.id} conv=#{@conversation.display_id} conf=#{@conference_sid}"
|
||||
)
|
||||
@conversation
|
||||
end
|
||||
|
||||
def outbound!
|
||||
raise 'Contact is required' if contact.blank?
|
||||
raise 'User is required' if user.blank?
|
||||
|
||||
@conversation = Voice::ConversationFinderService.new(
|
||||
account: account,
|
||||
phone_number: contact.phone_number,
|
||||
is_outbound: true,
|
||||
inbox: inbox,
|
||||
call_sid: nil
|
||||
).perform
|
||||
|
||||
ensure_conference_sid!
|
||||
def outbound!
|
||||
raise 'Contact is required' if contact.blank?
|
||||
raise 'User is required' if user.blank?
|
||||
Rails.logger.info(
|
||||
"VOICE_ORCH_OUTBOUND account=#{account.id} inbox=#{inbox.id} to=#{contact&.phone_number} user=#{user.id}"
|
||||
)
|
||||
|
||||
# Initiate call to fetch call_sid from provider
|
||||
call_details = inbox.channel.initiate_call(
|
||||
to: contact.phone_number,
|
||||
conference_sid: @conference_sid,
|
||||
conference_sid: nil,
|
||||
agent_id: user.id
|
||||
)
|
||||
|
||||
updated_attributes = @conversation.additional_attributes.merge({
|
||||
'call_sid' => call_details[:call_sid],
|
||||
'requires_agent_join' => true,
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => @conference_sid,
|
||||
'call_direction' => 'outbound'
|
||||
})
|
||||
@conversation.update!(additional_attributes: updated_attributes)
|
||||
|
||||
Voice::CallMessageBuilder.new(
|
||||
conversation: @conversation,
|
||||
direction: 'outbound',
|
||||
# Build conversation and first message via OutboundCallBuilder
|
||||
@conversation = Voice::OutboundCallBuilder.new(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact,
|
||||
call_sid: call_details[:call_sid],
|
||||
conference_sid: @conference_sid,
|
||||
from_number: inbox.channel&.phone_number || '',
|
||||
to_number: contact.phone_number,
|
||||
user: user
|
||||
to_number: contact.phone_number
|
||||
).perform
|
||||
|
||||
@conversation.update(last_activity_at: Time.current)
|
||||
conf_sid = @conversation.additional_attributes['conference_sid']
|
||||
Rails.logger.info(
|
||||
"VOICE_ORCH_OUTBOUND_OK account=#{account.id} conv=#{@conversation.display_id} call_sid=#{call_details[:call_sid]} conf=#{conf_sid}"
|
||||
)
|
||||
|
||||
|
||||
[@conversation, call_details]
|
||||
{
|
||||
conversation_id: @conversation.display_id,
|
||||
call_sid: call_details[:call_sid],
|
||||
conference_sid: conf_sid
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_conference_sid!
|
||||
def ensure_conference_sid!(_explicit_call_sid = nil)
|
||||
@conversation.reload
|
||||
@conference_sid = @conversation.additional_attributes['conference_sid']
|
||||
return if @conference_sid.present? && @conference_sid.match?(/^conf_account_\d+_conv_\d+$/)
|
||||
return if @conference_sid.present?
|
||||
|
||||
@conference_sid = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
@conversation.additional_attributes['conference_sid'] = @conference_sid
|
||||
|
||||
@@ -8,11 +8,27 @@ module Voice
|
||||
VALID_STATUSES = %w[queued initiated ringing in-progress completed busy failed no-answer canceled].freeze
|
||||
TERMINAL_STATUSES = %w[completed busy failed no-answer canceled].freeze
|
||||
|
||||
STATUS_ORDER = {
|
||||
'queued' => 0,
|
||||
'initiated' => 1,
|
||||
'ringing' => 2,
|
||||
'in-progress' => 3,
|
||||
'completed' => 4,
|
||||
'busy' => 4,
|
||||
'failed' => 4,
|
||||
'no-answer' => 4,
|
||||
'canceled' => 4,
|
||||
}.freeze
|
||||
|
||||
def process_status_update(status, duration = nil, is_first_response = false, custom_message = nil)
|
||||
normalized_status = status
|
||||
prev_status = conversation.additional_attributes&.dig('call_status')
|
||||
|
||||
return true if !is_first_response && prev_status == normalized_status
|
||||
if prev_status && STATUS_ORDER[normalized_status] && STATUS_ORDER[prev_status]
|
||||
# Ignore backward transitions
|
||||
return true if STATUS_ORDER[normalized_status] < STATUS_ORDER[prev_status]
|
||||
end
|
||||
|
||||
if duration.nil? && call_ended?(normalized_status) && conversation.additional_attributes['call_started_at']
|
||||
duration = Time.now.to_i - conversation.additional_attributes['call_started_at'].to_i
|
||||
@@ -60,8 +76,10 @@ module Voice
|
||||
def update_status(status, duration)
|
||||
conversation.additional_attributes ||= {}
|
||||
conversation.additional_attributes['call_status'] = status
|
||||
conversation.additional_attributes['last_provider_event_at'] = Time.now.to_i
|
||||
|
||||
if status == 'in-progress'
|
||||
# Guard against time skew and re-entrance
|
||||
conversation.additional_attributes['call_started_at'] ||= Time.now.to_i
|
||||
elsif call_ended?(status)
|
||||
conversation.additional_attributes['call_ended_at'] = Time.now.to_i
|
||||
|
||||
25
enterprise/app/services/voice/conference_end_service.rb
Normal file
25
enterprise/app/services/voice/conference_end_service.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module Voice
|
||||
class ConferenceEndService
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def perform
|
||||
# Compute conference friendly name from readable conversation ID
|
||||
name = "conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
|
||||
cfg = conversation.inbox.channel.provider_config_hash
|
||||
client = ::Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
|
||||
# Find all in-progress conferences matching this friendly name and end them
|
||||
client.conferences.list(friendly_name: name, status: 'in-progress').each do |conf|
|
||||
begin
|
||||
client.conferences(conf.sid).update(status: 'completed')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("VOICE_CONFERENCE_END_UPDATE_ERROR conf=#{conf.sid} error=#{e.class}: #{e.message}")
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
"VOICE_CONFERENCE_END_ERROR account=#{conversation.account_id} conversation=#{conversation.display_id} name=#{name} error=#{e.class}: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,9 +9,9 @@ module Voice
|
||||
when 'end'
|
||||
handle_conference_end
|
||||
when 'join'
|
||||
handle_participant_join
|
||||
handle_any_participant_join
|
||||
when 'leave'
|
||||
handle_participant_leave
|
||||
handle_any_participant_leave
|
||||
end
|
||||
|
||||
conversation.save!
|
||||
@@ -27,16 +27,14 @@ module Voice
|
||||
)
|
||||
end
|
||||
|
||||
def handle_conference_start
|
||||
def handle_conference_start
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
return if %w[in-progress completed].include?(current_status)
|
||||
|
||||
call_status_manager.process_status_update('ringing')
|
||||
end
|
||||
end
|
||||
|
||||
def handle_conference_end
|
||||
def handle_conference_end
|
||||
current_status = conversation.additional_attributes['call_status']
|
||||
|
||||
if current_status == 'in-progress'
|
||||
call_status_manager.process_status_update('completed')
|
||||
elsif current_status == 'ringing'
|
||||
@@ -44,66 +42,26 @@ module Voice
|
||||
else
|
||||
call_status_manager.process_status_update('completed')
|
||||
end
|
||||
end
|
||||
|
||||
def handle_participant_join
|
||||
if agent_participant?
|
||||
handle_agent_join
|
||||
elsif caller_participant?
|
||||
handle_caller_join
|
||||
end
|
||||
end
|
||||
|
||||
def handle_participant_leave
|
||||
if caller_participant? && call_in_progress?
|
||||
# Treat any participant join as moving to in-progress from ringing
|
||||
def handle_any_participant_join
|
||||
return if conversation.additional_attributes['call_ended_at'].present?
|
||||
return unless conversation.additional_attributes['call_status'] == 'ringing'
|
||||
call_status_manager.process_status_update('in-progress')
|
||||
end
|
||||
|
||||
# If a participant leaves while in-progress, mark completed; if still ringing, mark no-answer
|
||||
def handle_any_participant_leave
|
||||
status = conversation.additional_attributes['call_status']
|
||||
if status == 'in-progress'
|
||||
call_status_manager.process_status_update('completed')
|
||||
elsif caller_participant? && ringing_call? && !agent_joined?
|
||||
elsif status == 'ringing'
|
||||
call_status_manager.process_status_update('no-answer')
|
||||
end
|
||||
end
|
||||
|
||||
def handle_agent_join
|
||||
conversation.additional_attributes['agent_joined_at'] = Time.now.to_i
|
||||
|
||||
# Do not move to in_progress if call already ended
|
||||
return if conversation.additional_attributes['call_ended_at'].present?
|
||||
return unless ringing_call?
|
||||
|
||||
call_status_manager.process_status_update('in-progress')
|
||||
end
|
||||
|
||||
def handle_caller_join
|
||||
conversation.additional_attributes['caller_joined_at'] = Time.now.to_i
|
||||
|
||||
# Do not move to in_progress if call already ended
|
||||
return if conversation.additional_attributes['call_ended_at'].present?
|
||||
return unless outbound_call? && ringing_call?
|
||||
|
||||
call_status_manager.process_status_update('in-progress')
|
||||
end
|
||||
|
||||
def agent_participant?
|
||||
participant_label&.start_with?('agent')
|
||||
end
|
||||
|
||||
def caller_participant?
|
||||
participant_label&.start_with?('caller')
|
||||
end
|
||||
|
||||
def outbound_call?
|
||||
conversation.additional_attributes['call_direction'] == 'outbound'
|
||||
end
|
||||
|
||||
def ringing_call?
|
||||
conversation.additional_attributes['call_status'] == 'ringing'
|
||||
end
|
||||
|
||||
def call_in_progress?
|
||||
conversation.additional_attributes['call_status'] == 'in-progress'
|
||||
end
|
||||
|
||||
def agent_joined?
|
||||
conversation.additional_attributes['agent_joined_at'].present?
|
||||
# Proactively end the conference when any participant leaves
|
||||
Voice::ConferenceEndService.new(conversation: conversation).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,16 +2,34 @@ module Voice
|
||||
class ConversationFinderService
|
||||
pattr_initialize [:account!, :phone_number, :inbox, :call_sid, :is_outbound]
|
||||
|
||||
def perform
|
||||
# First try to find existing conversation by call_sid if available
|
||||
def perform
|
||||
# For outbound calls, always create a fresh conversation
|
||||
if is_outbound
|
||||
conv = create_new_conversation
|
||||
Rails.logger.info(
|
||||
"VOICE_CONV_FINDER_CREATED_OUTBOUND account=#{account.id} conv=#{conv.display_id} phone=#{phone_number} inbox=#{inbox.id}"
|
||||
)
|
||||
return conv
|
||||
end
|
||||
|
||||
# For inbound: try to find existing conversation by call_sid if available
|
||||
conversation = find_by_call_sid if call_sid.present?
|
||||
return conversation if conversation
|
||||
if conversation
|
||||
Rails.logger.info(
|
||||
"VOICE_CONV_FINDER_FOUND_BY_SID account=#{account.id} conv=#{conversation.display_id} call_sid=#{call_sid}"
|
||||
)
|
||||
return conversation
|
||||
end
|
||||
|
||||
# Ensure we have a phone number for new conversation creation
|
||||
validate_and_normalize_phone_number
|
||||
|
||||
# If not found, create a new conversation
|
||||
create_new_conversation
|
||||
conv = create_new_conversation
|
||||
Rails.logger.info(
|
||||
"VOICE_CONV_FINDER_CREATED account=#{account.id} conv=#{conv.display_id} phone=#{phone_number} inbox=#{inbox.id}"
|
||||
)
|
||||
conv
|
||||
end
|
||||
|
||||
private
|
||||
@@ -25,7 +43,7 @@ module Voice
|
||||
end
|
||||
|
||||
def find_by_call_sid
|
||||
account.conversations.where("additional_attributes->>'call_sid' = ?", call_sid).first
|
||||
account.conversations.find_by(identifier: call_sid)
|
||||
end
|
||||
|
||||
def find_or_create_contact
|
||||
@@ -39,13 +57,12 @@ module Voice
|
||||
# First ensure we have a contact
|
||||
contact = find_or_create_contact
|
||||
|
||||
# Find or initialize the contact inbox
|
||||
# Reuse existing contact_inbox (do not create new per outbound).
|
||||
# We still create a brand new Conversation below for every outbound call.
|
||||
contact_inbox = ContactInbox.find_or_initialize_by(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
)
|
||||
|
||||
# Set source_id if not set - needed for properly mapping the conversation
|
||||
contact_inbox.source_id ||= phone_number
|
||||
contact_inbox.save!
|
||||
|
||||
@@ -58,14 +75,14 @@ module Voice
|
||||
additional_attributes: initial_attributes
|
||||
)
|
||||
|
||||
# Ensure identifier set when call_sid available
|
||||
if call_sid.present?
|
||||
conversation.update!(identifier: call_sid)
|
||||
end
|
||||
|
||||
# Need to reload conversation to get the display_id populated by the database
|
||||
conversation.reload
|
||||
|
||||
# Add conference_sid to attributes
|
||||
conference_sid = generate_conference_sid(conversation)
|
||||
conversation.additional_attributes['conference_sid'] = conference_sid
|
||||
conversation.save!
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
@@ -74,8 +91,7 @@ module Voice
|
||||
'call_initiated_at' => Time.now.to_i
|
||||
}
|
||||
|
||||
# Add call_sid if available
|
||||
attributes['call_sid'] = call_sid if call_sid.present?
|
||||
|
||||
|
||||
# Set call direction based on is_outbound flag
|
||||
if is_outbound
|
||||
@@ -98,9 +114,6 @@ module Voice
|
||||
attributes
|
||||
end
|
||||
|
||||
def generate_conference_sid(conversation)
|
||||
"conf_account_#{account.id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
public :find_by_call_sid
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ class Voice::InboundCallBuilder
|
||||
contact_inbox = find_or_create_contact_inbox!(contact)
|
||||
@conversation = find_or_create_conversation!(contact, contact_inbox)
|
||||
create_call_message_if_needed!
|
||||
Rails.logger.info(
|
||||
"VOICE_INBOUND_BUILDER account=#{account.id} inbox=#{inbox.id} conv=#{@conversation.display_id} from=#{from_number} to=#{to_number}"
|
||||
)
|
||||
self
|
||||
end
|
||||
|
||||
|
||||
82
enterprise/app/services/voice/outbound_call_builder.rb
Normal file
82
enterprise/app/services/voice/outbound_call_builder.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
class Voice::OutboundCallBuilder
|
||||
pattr_initialize [:account!, :inbox!, :user!, :contact!, :call_sid!, :to_number!]
|
||||
|
||||
attr_reader :conversation
|
||||
|
||||
def perform
|
||||
contact_inbox = find_or_create_contact_inbox!
|
||||
@conversation = create_conversation!(contact_inbox)
|
||||
set_identifier_and_conference_sid!
|
||||
create_call_message!
|
||||
|
||||
Rails.logger.info(
|
||||
"VOICE_OUTBOUND_BUILDER account=#{account.id} inbox=#{inbox.id} conv=#{@conversation.display_id} to=#{to_number} call_sid=#{call_sid}"
|
||||
)
|
||||
@conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_contact_inbox!
|
||||
ContactInbox.find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
) { |ci| ci.source_id = contact.phone_number }
|
||||
end
|
||||
|
||||
def create_conversation!(contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
additional_attributes: outbound_attributes
|
||||
).tap(&:reload)
|
||||
end
|
||||
|
||||
def outbound_attributes
|
||||
{
|
||||
'call_direction' => 'outbound',
|
||||
'call_type' => 'outbound',
|
||||
'requires_agent_join' => true,
|
||||
'agent_id' => user.id,
|
||||
'meta' => { 'initiated_at' => Time.now.to_i },
|
||||
'call_status' => 'ringing'
|
||||
}
|
||||
end
|
||||
|
||||
def set_identifier_and_conference_sid!
|
||||
@conversation.update!(identifier: call_sid)
|
||||
conf = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
|
||||
attrs = @conversation.additional_attributes.merge('conference_sid' => conf)
|
||||
@conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def create_call_message!
|
||||
content_attrs = {
|
||||
data: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'outbound',
|
||||
from_number: inbox.channel&.phone_number || '',
|
||||
to_number: to_number,
|
||||
meta: {
|
||||
created_at: Time.current.to_i,
|
||||
ringing_at: Time.current.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
sender: user,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: content_attrs
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
45
enterprise/app/services/voice/provider/twilio_adapter.rb
Normal file
45
enterprise/app/services/voice/provider/twilio_adapter.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
module Voice
|
||||
module Provider
|
||||
class TwilioAdapter
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
cfg = @channel.provider_config_hash
|
||||
|
||||
host = ENV.fetch('FRONTEND_URL')
|
||||
phone_digits = @channel.phone_number.delete_prefix('+')
|
||||
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
|
||||
|
||||
params = {
|
||||
from: @channel.phone_number,
|
||||
to: to,
|
||||
url: callback_url,
|
||||
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
|
||||
status_callback_event: %w[initiated ringing answered completed failed busy no-answer canceled],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
call = twilio_client(cfg).calls.create(**params)
|
||||
|
||||
{
|
||||
provider: 'twilio',
|
||||
call_sid: call.sid,
|
||||
status: call.status,
|
||||
call_direction: 'outbound',
|
||||
requires_agent_join: true,
|
||||
agent_id: agent_id,
|
||||
conference_sid: conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,12 +2,18 @@ class Voice::StatusUpdateService
|
||||
pattr_initialize [:account!, :call_sid!, :call_status]
|
||||
|
||||
def perform
|
||||
Rails.logger.info(
|
||||
"VOICE_STATUS_UPDATE account=#{account.id} call_sid=#{call_sid} status=#{call_status}"
|
||||
)
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
return if call_status.to_s.strip.empty?
|
||||
|
||||
update_conversation!(conversation)
|
||||
update_last_call_message!(conversation)
|
||||
Rails.logger.info(
|
||||
"VOICE_STATUS_UPDATED account=#{account.id} conversation=#{conversation.display_id} status=#{call_status}"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
Reference in New Issue
Block a user