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:
@@ -12,6 +12,10 @@ import {
|
||||
import messageReadActions from './actions/messageReadActions';
|
||||
import messageTranslateActions from './actions/messageTranslateActions';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
// 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);
|
||||
if (message.message_type === MESSAGE_TYPE.INCOMING) {
|
||||
commit(types.SET_CONVERSATION_CAN_REPLY, {
|
||||
@@ -308,10 +312,12 @@ const actions = {
|
||||
});
|
||||
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
|
||||
}
|
||||
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
|
||||
},
|
||||
|
||||
updateMessage({ commit }, message) {
|
||||
updateMessage({ commit, rootGetters }, message) {
|
||||
commit(types.ADD_MESSAGE, message);
|
||||
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
|
||||
},
|
||||
|
||||
deleteMessage: async function deleteLabels(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
|
||||
|
||||
const state = {
|
||||
allConversations: [],
|
||||
@@ -24,6 +25,10 @@ const state = {
|
||||
copilotAssistant: {},
|
||||
};
|
||||
|
||||
const getConversationById = _state => conversationId => {
|
||||
return _state.allConversations.find(c => c.id === conversationId);
|
||||
};
|
||||
|
||||
// mutations
|
||||
export const mutations = {
|
||||
[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) {
|
||||
_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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user