chore: cleanup

This commit is contained in:
Sojan Jose
2025-09-14 18:11:33 +05:30
parent b8feb14633
commit cfd194874f
29 changed files with 854 additions and 542 deletions

View File

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

View File

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

View File

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

View File

@@ -518,7 +518,7 @@ provideMessageContext({
}"
@contextmenu="openContextMenu($event)"
>
<Component :is="componentToRender" :message="props" />
<Component :is="componentToRender" />
</div>
<MessageError
v-if="contentAttributes.externalError"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View 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

View File

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