feat(voice): Wire Twilio voice flow through unified call model (#14091)

Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-04-30 11:25:39 +04:00
committed by GitHub
co-authored by Muhsin
parent cd9c8e3303
commit 1124c1b4c2
37 changed files with 630 additions and 811 deletions
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
async joinClientCall({ to, conversationId, callSid }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
leaveConference({ inboxId, conversationId, callSid }) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
params: { conversation_id: conversationId, call_sid: callSid },
})
.then(r => r.data);
}