feat: Voice Channel (#11602)
Enables agents to initiate outbound calls and receive incoming calls directly from the Chatwoot dashboard, with Twilio as the initial provider. Fixes: #11481 > This is an integration branch to ensure features works well and might be often broken on down merges, we will be extracting the functionalities via smaller PRs into develop - [x] https://github.com/chatwoot/chatwoot/pull/11775 - [x] https://github.com/chatwoot/chatwoot/pull/12218 - [x] https://github.com/chatwoot/chatwoot/pull/12243 - [x] https://github.com/chatwoot/chatwoot/pull/12268 - [x] https://github.com/chatwoot/chatwoot/pull/12361 - [x] https://github.com/chatwoot/chatwoot/pull/12782 - [x] #13064 - [ ] Ability for agents to join the inbound calls ( included in this PR ) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
import { Device } from '@twilio/voice-sdk';
|
||||||
|
import VoiceAPI from './voiceAPIClient';
|
||||||
|
|
||||||
|
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||||
|
|
||||||
|
class TwilioVoiceClient extends EventTarget {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.device = null;
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeDevice(inboxId) {
|
||||||
|
this.destroyDevice();
|
||||||
|
|
||||||
|
const response = await VoiceAPI.getToken(inboxId);
|
||||||
|
const { token, account_id } = response || {};
|
||||||
|
if (!token) throw new Error('Invalid token');
|
||||||
|
|
||||||
|
this.device = new Device(token, {
|
||||||
|
allowIncomingWhileBusy: true,
|
||||||
|
disableAudioContextSounds: true,
|
||||||
|
appParams: { account_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.removeAllListeners();
|
||||||
|
this.device.on('connect', conn => {
|
||||||
|
this.activeConnection = conn;
|
||||||
|
conn.on('disconnect', this.onDisconnect);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.device.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
this.device.on('tokenWillExpire', async () => {
|
||||||
|
const r = await VoiceAPI.getToken(this.inboxId);
|
||||||
|
if (r?.token) this.device.updateToken(r.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
this.inboxId = inboxId;
|
||||||
|
|
||||||
|
return this.device;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasActiveConnection() {
|
||||||
|
return !!this.activeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
endClientCall() {
|
||||||
|
if (this.activeConnection) {
|
||||||
|
this.activeConnection.disconnect();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
if (this.device) {
|
||||||
|
this.device.disconnectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyDevice() {
|
||||||
|
if (this.device) {
|
||||||
|
this.device.destroy();
|
||||||
|
}
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.device = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.inboxId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinClientCall({ to, conversationId }) {
|
||||||
|
if (!this.device || !this.initialized || !to) return null;
|
||||||
|
if (this.activeConnection) return this.activeConnection;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
To: to,
|
||||||
|
is_agent: 'true',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = await this.device.connect({ params });
|
||||||
|
this.activeConnection = connection;
|
||||||
|
|
||||||
|
connection.on('disconnect', this.onDisconnect);
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect = () => {
|
||||||
|
this.activeConnection = null;
|
||||||
|
this.dispatchEvent(createCallDisconnectedEvent());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TwilioVoiceClient();
|
||||||
40
app/javascript/dashboard/api/channel/voice/voiceAPIClient.js
Normal file
40
app/javascript/dashboard/api/channel/voice/voiceAPIClient.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../../ApiClient';
|
||||||
|
import ContactsAPI from '../../contacts';
|
||||||
|
|
||||||
|
class VoiceAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('voice', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveConference(inboxId, conversationId) {
|
||||||
|
return axios
|
||||||
|
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
params: { conversation_id: conversationId },
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
joinConference({ conversationId, inboxId, callSid }) {
|
||||||
|
return axios
|
||||||
|
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
call_sid: callSid,
|
||||||
|
})
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(inboxId) {
|
||||||
|
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||||
|
return axios
|
||||||
|
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||||
|
.then(r => r.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VoiceAPI();
|
||||||
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
|||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
|
|||||||
contactId: props.contactId,
|
contactId: props.contactId,
|
||||||
inboxId,
|
inboxId,
|
||||||
});
|
});
|
||||||
|
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||||
|
|
||||||
|
// Add call to store immediately so widget shows
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callDirection: 'outbound',
|
||||||
|
});
|
||||||
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
navigateToConversation(response?.conversation_id);
|
navigateToConversation(response?.conversation_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
|
|||||||
solid:
|
solid:
|
||||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||||
outline:
|
outline:
|
||||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||||
|
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
joinCall,
|
||||||
|
endCall: endCallSession,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
formattedCallDuration,
|
||||||
|
} = useCallSession();
|
||||||
|
|
||||||
|
const getCallInfo = call => {
|
||||||
|
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||||
|
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||||
|
const sender = conversation?.meta?.sender;
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
inbox,
|
||||||
|
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||||
|
inboxName: inbox?.name || 'Customer support',
|
||||||
|
avatar: sender?.avatar || sender?.thumbnail,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = async () => {
|
||||||
|
const call = activeCall.value;
|
||||||
|
if (!call) return;
|
||||||
|
|
||||||
|
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||||
|
if (!inboxId) return;
|
||||||
|
|
||||||
|
await endCallSession({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinCall = async call => {
|
||||||
|
const { conversation } = getCallInfo(call);
|
||||||
|
if (!call || !conversation || isJoining.value) return;
|
||||||
|
|
||||||
|
// End current active call before joining new one
|
||||||
|
if (hasActiveCall.value) {
|
||||||
|
await handleEndCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await joinCall({
|
||||||
|
conversationId: call.conversationId,
|
||||||
|
inboxId: conversation.inbox_id,
|
||||||
|
callSid: call.callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
router.push({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: { conversation_id: call.conversationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-join outbound calls when window is visible
|
||||||
|
watch(
|
||||||
|
() => incomingCalls.value[0],
|
||||||
|
call => {
|
||||||
|
if (
|
||||||
|
call?.callDirection === 'outbound' &&
|
||||||
|
!hasActiveCall.value &&
|
||||||
|
WindowVisibilityHelper.isWindowVisible()
|
||||||
|
) {
|
||||||
|
handleJoinCall(call);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="incomingCalls.length || hasActiveCall"
|
||||||
|
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||||
|
>
|
||||||
|
<!-- Incoming Calls (shown above active call) -->
|
||||||
|
<div
|
||||||
|
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||||
|
:key="call.callSid"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(call).avatar"
|
||||||
|
:name="getCallInfo(call).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(call).contactName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-n-slate-11 truncate">
|
||||||
|
{{ getCallInfo(call).inboxName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="dismissCall(call.callSid)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(call)"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Call Widget -->
|
||||||
|
<div
|
||||||
|
v-if="hasActiveCall || incomingCalls.length"
|
||||||
|
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||||
|
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||||
|
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||||
|
:size="40"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||||
|
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||||
|
</p>
|
||||||
|
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||||
|
{{ formattedCallDuration }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-n-slate-11">
|
||||||
|
{{
|
||||||
|
incomingCalls[0]?.callDirection === 'outbound'
|
||||||
|
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||||
|
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||||
|
@click="
|
||||||
|
hasActiveCall
|
||||||
|
? handleEndCall()
|
||||||
|
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!hasActiveCall"
|
||||||
|
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||||
|
@click="handleJoinCall(incomingCalls[0])"
|
||||||
|
>
|
||||||
|
<i class="text-lg text-white i-ph-phone-bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
110
app/javascript/dashboard/composables/useCallSession.js
Normal file
110
app/javascript/dashboard/composables/useCallSession.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import Timer from 'dashboard/helper/Timer';
|
||||||
|
|
||||||
|
export function useCallSession() {
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
const isJoining = ref(false);
|
||||||
|
const callDuration = ref(0);
|
||||||
|
const durationTimer = new Timer(elapsed => {
|
||||||
|
callDuration.value = elapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCall = computed(() => callsStore.activeCall);
|
||||||
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
hasActiveCall,
|
||||||
|
active => {
|
||||||
|
if (active) {
|
||||||
|
durationTimer.start();
|
||||||
|
} else {
|
||||||
|
durationTimer.stop();
|
||||||
|
callDuration.value = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
durationTimer.stop();
|
||||||
|
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endCall = async ({ conversationId, inboxId }) => {
|
||||||
|
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
durationTimer.stop();
|
||||||
|
callsStore.clearActiveCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
|
if (isJoining.value) return null;
|
||||||
|
|
||||||
|
isJoining.value = true;
|
||||||
|
try {
|
||||||
|
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||||
|
if (!device) return null;
|
||||||
|
|
||||||
|
const joinResponse = await VoiceAPI.joinConference({
|
||||||
|
conversationId,
|
||||||
|
inboxId,
|
||||||
|
callSid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TwilioVoiceClient.joinClientCall({
|
||||||
|
to: joinResponse?.conference_sid,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
callsStore.setCallActive(callSid);
|
||||||
|
durationTimer.start();
|
||||||
|
|
||||||
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Failed to join call:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
isJoining.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectIncomingCall = callSid => {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissCall = callSid => {
|
||||||
|
callsStore.dismissCall(callSid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedCallDuration = computed(() => {
|
||||||
|
const minutes = Math.floor(callDuration.value / 60);
|
||||||
|
const seconds = callDuration.value % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCall,
|
||||||
|
incomingCalls,
|
||||||
|
hasActiveCall,
|
||||||
|
isJoining,
|
||||||
|
formattedCallDuration,
|
||||||
|
joinCall,
|
||||||
|
endCall,
|
||||||
|
rejectIncomingCall,
|
||||||
|
dismissCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
app/javascript/dashboard/helper/Timer.js
Normal file
28
app/javascript/dashboard/helper/Timer.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default class Timer {
|
||||||
|
constructor(onTick = null) {
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = null;
|
||||||
|
this.onTick = onTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.elapsed += 1;
|
||||||
|
if (this.onTick) {
|
||||||
|
this.onTick(this.elapsed);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
this.elapsed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/javascript/dashboard/helper/specs/Timer.spec.js
Normal file
113
app/javascript/dashboard/helper/specs/Timer.spec.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Timer from '../Timer';
|
||||||
|
|
||||||
|
describe('Timer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('initializes with elapsed 0 and no interval', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an onTick callback', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
expect(timer.onTick).toBe(onTick);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('starts the timer and increments elapsed every second', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTick callback with elapsed value', () => {
|
||||||
|
const onTick = vi.fn();
|
||||||
|
const timer = new Timer(onTick);
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(onTick).toHaveBeenCalledWith(2);
|
||||||
|
|
||||||
|
expect(onTick).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets elapsed to 0 when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(5);
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(timer.elapsed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears previous interval when restarted', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
const firstIntervalId = timer.intervalId;
|
||||||
|
|
||||||
|
timer.start();
|
||||||
|
expect(timer.intervalId).not.toBe(firstIntervalId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stop', () => {
|
||||||
|
it('stops the timer and resets elapsed to 0', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(timer.elapsed).toBe(3);
|
||||||
|
|
||||||
|
timer.stop();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
expect(timer.intervalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents further increments after stopping', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.start();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
timer.stop();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stop when timer is not running', () => {
|
||||||
|
const timer = new Timer();
|
||||||
|
expect(() => timer.stop()).not.toThrow();
|
||||||
|
expect(timer.elapsed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
app/javascript/dashboard/helper/voice.js
Normal file
79
app/javascript/dashboard/helper/voice.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import types from 'dashboard/store/mutation-types';
|
||||||
|
|
||||||
|
export const TERMINAL_STATUSES = [
|
||||||
|
'completed',
|
||||||
|
'busy',
|
||||||
|
'failed',
|
||||||
|
'no-answer',
|
||||||
|
'canceled',
|
||||||
|
'missed',
|
||||||
|
'ended',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isInbound = direction => direction === 'inbound';
|
||||||
|
|
||||||
|
const isVoiceCallMessage = message => {
|
||||||
|
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||||
|
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractCallData(message) {
|
||||||
|
const contentData = message?.content_attributes?.data || {};
|
||||||
|
return {
|
||||||
|
callSid: contentData.call_sid,
|
||||||
|
status: contentData.status,
|
||||||
|
callDirection: contentData.call_direction,
|
||||||
|
conversationId: message?.conversation_id,
|
||||||
|
senderId: message?.sender?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleVoiceCallCreated(message, currentUserId) {
|
||||||
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
|
const { callSid, callDirection, conversationId, senderId } =
|
||||||
|
extractCallData(message);
|
||||||
|
|
||||||
|
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||||
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
|
const { callSid, status, callDirection, conversationId, senderId } =
|
||||||
|
extractCallData(message);
|
||||||
|
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
|
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||||
|
|
||||||
|
const callInfo = { conversationId, callStatus: status };
|
||||||
|
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||||
|
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||||
|
|
||||||
|
const isNewCall =
|
||||||
|
status === 'ringing' &&
|
||||||
|
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||||
|
|
||||||
|
if (isNewCall) {
|
||||||
|
callsStore.addCall({
|
||||||
|
callSid,
|
||||||
|
conversationId,
|
||||||
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,6 +275,16 @@
|
|||||||
"SIDEBAR": {
|
"SIDEBAR": {
|
||||||
"CONTACT": "Contact",
|
"CONTACT": "Contact",
|
||||||
"COPILOT": "Copilot"
|
"COPILOT": "Copilot"
|
||||||
|
},
|
||||||
|
"VOICE_WIDGET": {
|
||||||
|
"INCOMING_CALL": "Incoming call",
|
||||||
|
"OUTGOING_CALL": "Outgoing call",
|
||||||
|
"CALL_IN_PROGRESS": "Call in progress",
|
||||||
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
|
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||||
|
"REJECT_CALL": "Reject",
|
||||||
|
"JOIN_CALL": "Join call",
|
||||||
|
"END_CALL": "End call"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EMAIL_TRANSCRIPT": {
|
"EMAIL_TRANSCRIPT": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { defineAsyncComponent, ref } from 'vue';
|
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||||
|
|
||||||
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||||
@@ -16,10 +16,15 @@ const CommandBar = defineAsyncComponent(
|
|||||||
() => import('./commands/commandbar.vue')
|
() => import('./commands/commandbar.vue')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FloatingCallWidget = defineAsyncComponent(
|
||||||
|
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||||
|
);
|
||||||
|
|
||||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||||
|
|
||||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||||
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -30,6 +35,7 @@ export default {
|
|||||||
UpgradePage,
|
UpgradePage,
|
||||||
CopilotLauncher,
|
CopilotLauncher,
|
||||||
CopilotContainer,
|
CopilotContainer,
|
||||||
|
FloatingCallWidget,
|
||||||
MobileSidebarLauncher,
|
MobileSidebarLauncher,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
@@ -37,6 +43,7 @@ export default {
|
|||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
const { accountId } = useAccount();
|
const { accountId } = useAccount();
|
||||||
const { width: windowWidth } = useWindowSize();
|
const { width: windowWidth } = useWindowSize();
|
||||||
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -44,6 +51,8 @@ export default {
|
|||||||
accountId,
|
accountId,
|
||||||
upgradePageRef,
|
upgradePageRef,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
|
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||||
|
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -151,6 +160,7 @@ export default {
|
|||||||
@toggle="toggleMobileSidebar"
|
@toggle="toggleMobileSidebar"
|
||||||
/>
|
/>
|
||||||
<CopilotContainer />
|
<CopilotContainer />
|
||||||
|
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||||
</template>
|
</template>
|
||||||
<AddAccountModal
|
<AddAccountModal
|
||||||
:show="showCreateAccountModal"
|
:show="showCreateAccountModal"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
import messageReadActions from './actions/messageReadActions';
|
import messageReadActions from './actions/messageReadActions';
|
||||||
import messageTranslateActions from './actions/messageTranslateActions';
|
import messageTranslateActions from './actions/messageTranslateActions';
|
||||||
import * as Sentry from '@sentry/vue';
|
import * as Sentry from '@sentry/vue';
|
||||||
|
import {
|
||||||
|
handleVoiceCallCreated,
|
||||||
|
handleVoiceCallUpdated,
|
||||||
|
} from 'dashboard/helper/voice';
|
||||||
|
|
||||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||||
// This helper is used to check if the message has failed with an external error.
|
// This helper is used to check if the message has failed with an external error.
|
||||||
@@ -299,7 +303,7 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addMessage({ commit }, message) {
|
addMessage({ commit, rootGetters }, message) {
|
||||||
commit(types.ADD_MESSAGE, message);
|
commit(types.ADD_MESSAGE, message);
|
||||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||||
@@ -308,10 +312,12 @@ const actions = {
|
|||||||
});
|
});
|
||||||
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
||||||
}
|
}
|
||||||
|
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMessage({ commit }, message) {
|
updateMessage({ commit, rootGetters }, message) {
|
||||||
commit(types.ADD_MESSAGE, message);
|
commit(types.ADD_MESSAGE, message);
|
||||||
|
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMessage: async function deleteLabels(
|
deleteMessage: async function deleteLabels(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [],
|
allConversations: [],
|
||||||
@@ -24,6 +25,10 @@ const state = {
|
|||||||
copilotAssistant: {},
|
copilotAssistant: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getConversationById = _state => conversationId => {
|
||||||
|
return _state.allConversations.find(c => c.id === conversationId);
|
||||||
|
};
|
||||||
|
|
||||||
// mutations
|
// mutations
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
||||||
@@ -270,6 +275,36 @@ export const mutations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||||
|
_state,
|
||||||
|
{ conversationId, callStatus }
|
||||||
|
) {
|
||||||
|
const chat = getConversationById(_state)(conversationId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
chat.additional_attributes = {
|
||||||
|
...chat.additional_attributes,
|
||||||
|
call_status: callStatus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||||
|
const chat = getConversationById(_state)(conversationId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const lastCall = (chat.messages || []).findLast(
|
||||||
|
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lastCall) return;
|
||||||
|
|
||||||
|
lastCall.content_attributes ??= {};
|
||||||
|
lastCall.content_attributes.data = {
|
||||||
|
...lastCall.content_attributes.data,
|
||||||
|
status: callStatus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||||
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
|
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { mutations } from '../index';
|
||||||
|
import types from '../../../mutation-types';
|
||||||
|
|
||||||
|
describe('#mutations', () => {
|
||||||
|
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||||
|
it('does nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [] };
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates call_status preserving existing additional_attributes', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||||
|
other_attr: 'value',
|
||||||
|
call_status: 'in-progress',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates additional_attributes if it does not exist', () => {
|
||||||
|
const state = { allConversations: [{ id: 1 }] };
|
||||||
|
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'completed',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||||
|
call_status: 'completed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||||
|
it('does nothing if conversation is not found', () => {
|
||||||
|
const state = { allConversations: [] };
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if no voice call message exists', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].messages[0]).toEqual({
|
||||||
|
id: 1,
|
||||||
|
content_type: 'text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the last voice call message status', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { data: { status: 'ringing' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { data: { status: 'ringing' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data.status
|
||||||
|
).toBe('ringing');
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[1].content_attributes.data.status
|
||||||
|
).toBe('in-progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates content_attributes.data if it does not exist', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [{ id: 1, content_type: 'voice_call' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'completed',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data.status
|
||||||
|
).toBe('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing data in content_attributes.data', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: {
|
||||||
|
data: { call_sid: 'CA123', status: 'ringing' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'in-progress',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
state.allConversations[0].messages[0].content_attributes.data
|
||||||
|
).toEqual({
|
||||||
|
call_sid: 'CA123',
|
||||||
|
status: 'in-progress',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty messages array', () => {
|
||||||
|
const state = {
|
||||||
|
allConversations: [{ id: 1, messages: [] }],
|
||||||
|
};
|
||||||
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
|
conversationId: 1,
|
||||||
|
callStatus: 'ringing',
|
||||||
|
});
|
||||||
|
expect(state.allConversations[0].messages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,8 @@ export default {
|
|||||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||||
|
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||||
|
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||||
|
|
||||||
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
|
||||||
|
|||||||
59
app/javascript/dashboard/stores/calls.js
Normal file
59
app/javascript/dashboard/stores/calls.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
|
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
|
||||||
|
|
||||||
|
export const useCallsStore = defineStore('calls', {
|
||||||
|
state: () => ({
|
||||||
|
calls: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
activeCall: state => state.calls.find(call => call.isActive) || null,
|
||||||
|
hasActiveCall: state => state.calls.some(call => call.isActive),
|
||||||
|
incomingCalls: state => state.calls.filter(call => !call.isActive),
|
||||||
|
hasIncomingCall: state => state.calls.some(call => !call.isActive),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
handleCallStatusChanged({ callSid, status }) {
|
||||||
|
if (TERMINAL_STATUSES.includes(status)) {
|
||||||
|
this.removeCall(callSid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addCall(callData) {
|
||||||
|
if (!callData?.callSid) return;
|
||||||
|
const exists = this.calls.some(call => call.callSid === callData.callSid);
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
this.calls.push({
|
||||||
|
...callData,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeCall(callSid) {
|
||||||
|
const callToRemove = this.calls.find(c => c.callSid === callSid);
|
||||||
|
if (callToRemove?.isActive) {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
}
|
||||||
|
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCallActive(callSid) {
|
||||||
|
this.calls = this.calls.map(call => ({
|
||||||
|
...call,
|
||||||
|
isActive: call.callSid === callSid,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearActiveCall() {
|
||||||
|
TwilioVoiceClient.endClientCall();
|
||||||
|
this.calls = this.calls.filter(call => !call.isActive);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissCall(callSid) {
|
||||||
|
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
- name: channel_voice
|
- name: channel_voice
|
||||||
display_name: Voice Channel
|
display_name: Voice Channel
|
||||||
enabled: false
|
enabled: false
|
||||||
chatwoot_internal: true
|
premium: true
|
||||||
- name: notion_integration
|
- name: notion_integration
|
||||||
display_name: Notion Integration
|
display_name: Notion Integration
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"@sindresorhus/slugify": "2.2.1",
|
"@sindresorhus/slugify": "2.2.1",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tanstack/vue-table": "^8.20.5",
|
"@tanstack/vue-table": "^8.20.5",
|
||||||
|
"@twilio/voice-sdk": "^2.12.4",
|
||||||
"@vitejs/plugin-vue": "^5.1.4",
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
"@vue/compiler-sfc": "^3.5.8",
|
"@vue/compiler-sfc": "^3.5.8",
|
||||||
"@vuelidate/core": "^2.0.3",
|
"@vuelidate/core": "^2.0.3",
|
||||||
|
|||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -67,6 +67,9 @@ importers:
|
|||||||
'@tanstack/vue-table':
|
'@tanstack/vue-table':
|
||||||
specifier: ^8.20.5
|
specifier: ^8.20.5
|
||||||
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
version: 8.20.5(vue@3.5.12(typescript@5.6.2))
|
||||||
|
'@twilio/voice-sdk':
|
||||||
|
specifier: ^2.12.4
|
||||||
|
version: 2.17.0
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.1.4
|
specifier: ^5.1.4
|
||||||
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||||
@@ -1283,9 +1286,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
'@twilio/voice-errors@1.7.0':
|
||||||
|
resolution: {integrity: sha512-9TvniWpzU0iy6SYFAcDP+HG+/mNz2yAHSs7+m0DZk86lE+LoTB6J/ZONTPuxXrXWi4tso/DulSHuA0w7nIQtGg==}
|
||||||
|
|
||||||
|
'@twilio/voice-sdk@2.17.0':
|
||||||
|
resolution: {integrity: sha512-dAqAfQ59xexKdVi6U1TJAKlf6aDySAinjMvXrNdAFJDzSWJ5SNh49ITxdaKR2vaUdxTc7ncFgGVeI72W2dWHjg==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/events@3.0.3':
|
||||||
|
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
|
||||||
|
|
||||||
'@types/flexsearch@0.7.6':
|
'@types/flexsearch@0.7.6':
|
||||||
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
|
resolution: {integrity: sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==}
|
||||||
|
|
||||||
@@ -2399,6 +2412,10 @@ packages:
|
|||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
events@3.3.0:
|
||||||
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
|
engines: {node: '>=0.8.x'}
|
||||||
|
|
||||||
expect-type@1.1.0:
|
expect-type@1.1.0:
|
||||||
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -3119,6 +3136,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
loglevel@1.9.2:
|
||||||
|
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
loupe@3.1.3:
|
loupe@3.1.3:
|
||||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||||
|
|
||||||
@@ -5750,8 +5771,20 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/once@2.0.0': {}
|
'@tootallnate/once@2.0.0': {}
|
||||||
|
|
||||||
|
'@twilio/voice-errors@1.7.0': {}
|
||||||
|
|
||||||
|
'@twilio/voice-sdk@2.17.0':
|
||||||
|
dependencies:
|
||||||
|
'@twilio/voice-errors': 1.7.0
|
||||||
|
'@types/events': 3.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
loglevel: 1.9.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/events@3.0.3': {}
|
||||||
|
|
||||||
'@types/flexsearch@0.7.6': {}
|
'@types/flexsearch@0.7.6': {}
|
||||||
|
|
||||||
'@types/fs-extra@9.0.13':
|
'@types/fs-extra@9.0.13':
|
||||||
@@ -7137,6 +7170,8 @@ snapshots:
|
|||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
|
events@3.3.0: {}
|
||||||
|
|
||||||
expect-type@1.1.0: {}
|
expect-type@1.1.0: {}
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
extend-shallow@2.0.1:
|
||||||
@@ -7966,6 +8001,8 @@ snapshots:
|
|||||||
strip-ansi: 7.1.0
|
strip-ansi: 7.1.0
|
||||||
wrap-ansi: 9.0.0
|
wrap-ansi: 9.0.0
|
||||||
|
|
||||||
|
loglevel@1.9.2: {}
|
||||||
|
|
||||||
loupe@3.1.3: {}
|
loupe@3.1.3: {}
|
||||||
|
|
||||||
lower-case@2.0.2:
|
lower-case@2.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user