Files
chatwoot/app/javascript/dashboard/api/channels/voice.js
Sojan Jose fb4042c863 refactor(voice): use Twilio-native statuses and remove legacy fallbacks
- Drop FE normalizeStatus and BE STATUS_MAPPING
- Update bubbles/preview and store to use Twilio status values
- DRY ActionCable incoming-call payload builder
- Use server-provided conference_sid for client connect
- VoiceAPI: standardized returns, accept store, simplify disconnects
- Remove legacy/fallback comments introduced in this PR
2025-08-20 13:38:50 +02:00

183 lines
5.5 KiB
JavaScript

/* global axios */
import ApiClient from '../ApiClient';
class VoiceAPI extends ApiClient {
constructor() {
super('voice', { accountScoped: true });
this.device = null;
this.activeConnection = null;
this.initialized = false;
this.store = null;
}
// ------------------- 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);
}
endCall(callSid, conversationId) {
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);
}
joinCall(params) {
const conversationId = params.conversation_id || params.conversationId;
const callSid = params.call_sid || params.callSid;
const payload = { call_sid: callSid, conversation_id: conversationId };
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);
}
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);
}
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);
}
// ------------------- Client (Twilio) APIs -------------------
async initializeDevice(inboxId, { store } = {}) {
if (this.initialized && this.device && this.device.state !== 'error')
return this.device;
if (!inboxId) throw new Error('Inbox ID is required to initialize');
if (store) this.store = store;
const { Device } = await import('@twilio/voice-sdk');
const response = await this.getToken(inboxId);
const { token, voice_enabled, account_id } = response || {};
if (!voice_enabled) throw new Error('Voice not enabled for this inbox');
if (!token) throw new Error('Invalid token');
this.device = new Device(token, {
allowIncomingWhileBusy: true,
disableAudioContextSounds: true,
appParams: { account_id },
});
// Basic listeners
this.device.removeAllListeners();
this.device.on('registered', () => {});
this.device.on('unregistered', () => {});
this.device.on('error', () => {});
this.device.on('connect', conn => {
this.activeConnection = conn;
// Listen for connection disconnect
conn.on('disconnect', () => {
this.activeConnection = null;
// Dispatch event to update UI when call disconnects
if (this.store) {
this.store.dispatch('calls/clearActiveCall');
}
});
});
this.device.on('disconnect', () => {
this.activeConnection = null;
});
this.device.on('tokenWillExpire', async () => {
try {
const r = await this.getToken(inboxId);
if (r?.token) this.device.updateToken(r.token);
} catch (error) {
// Token refresh failed
}
});
await this.device.register();
this.initialized = true;
return this.device;
}
endClientCall() {
try {
// Disconnect active connection first
if (this.activeConnection) {
this.activeConnection.disconnect();
this.activeConnection = null;
}
// Disconnect all connections on the device
if (this.device) {
this.device.disconnectAll();
}
} catch (error) {
this.activeConnection = null;
try {
if (this.device) {
this.device.disconnectAll();
}
} catch (fallbackError) {
// ignore
}
}
}
joinClientCall({ To }) {
if (!this.device || !this.initialized) throw new Error('Twilio not ready');
if (!To) throw new Error('Missing To');
// Guard: if there is already an active/connecting call, return it instead of creating a new one
if (this.activeConnection) {
return this.activeConnection;
}
if (this.device.state === 'busy') {
const existing = (this.device.calls || [])[0];
if (existing) {
this.activeConnection = existing;
return existing;
}
}
const connection = this.device.connect({
params: { To: String(To), is_agent: 'true' },
});
this.activeConnection = connection;
return connection;
}
getDeviceStatus() {
if (!this.device) return 'not_initialized';
const s = this.device.state;
switch (s) {
case 'registered':
return 'ready';
case 'unregistered':
return 'disconnected';
case 'destroyed':
return 'terminated';
case 'busy':
return 'busy';
case 'error':
return 'error';
default:
return s;
}
}
}
export default new VoiceAPI();