Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma 22fd5a872b feat(whatsapp-call): direct browser-Meta WebRTC, no media server 2026-04-29 15:23:32 +07:00
Muhsin KelothandGitHub fa582b31fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-28 11:00:45 +04:00
Muhsin KelothandGitHub 8aa71f4a9d Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-28 09:43:04 +04:00
Muhsin KelothandGitHub 223e6402d8 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-23 11:32:13 +04:00
Muhsin KelothandGitHub e6138739ad Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-21 13:23:15 +04:00
Muhsin KelothandGitHub 6d6133d5c9 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-21 13:23:06 +04:00
Muhsin KelothandGitHub 6cf5f323fe Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 20:28:13 +04:00
Muhsin KelothandGitHub 85f522c693 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 20:28:02 +04:00
Muhsin KelothandGitHub 17a17ad00c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 15:26:25 +04:00
Muhsin KelothandGitHub d365a5fe15 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 15:26:12 +04:00
Muhsin 98ba6e5200 refactor(voice): drop content_attributes.data from voice_call messages
Nothing reads it anymore — frontend uses message.call, CallMessageBuilder
uses call.message (the FK), and the jsonb fallback finder was dead code
(call.message_id is always linked immediately after message creation, in
the same transaction).

New voice_call messages are now created with blank content_attributes.
Existing rows keep their legacy payload; nothing reads it, so it's inert.
Safe to backfill with `Message.voice_calls.update_all(content_attributes: {})`
if you want a tidy table.
2026-04-20 14:38:57 +04:00
Muhsin 02f1fdf3b6 fix(voice): avoid Jbuilder's reserved call keyword in message serializer
Jbuilder defines Jbuilder#call(object, *attributes) as its core API for
extracting attributes. Using `json.call do ... end` collided with that
signature and raised "wrong number of arguments (given 0, expected 1+)".

Use `json.set! :call, message.call.push_event_data` instead — also
deduplicates the field list between the jbuilder and Message#push_event_data
(both now go through Call#push_event_data).
2026-04-20 14:30:42 +04:00
Muhsin c7b2a6f12c refactor(voice): stop duplicating call state in message.content_attributes
Now that the frontend reads from message.call (previous commit), the
message row is just a display anchor — all call state lives on the Call
record and is surfaced via the serializer / push_event_data.

- CallMessageBuilder shrinks to creating the bare voice_call message.
  content_attributes.data keeps only { call_sid } as a breadcrumb and a
  finder fallback for the edge case where message_id isn't linked yet.
- CallStatus::Manager stops re-writing the message on status transitions.
  call.message.touch triggers the standard message.updated dispatcher so
  sockets re-broadcast with the fresh Call embedded.
- Spec assertions switch from content_attributes.data.* to call.* +
  message.call object identity; status update spec asserts message is
  touched.
2026-04-20 13:42:11 +04:00
Muhsin c2a851c756 refactor(voice): read call state from message.call on the frontend
- VoiceCall bubble, voice.js helper, ConversationCard, and the store
  mutation all switch from message.content_attributes.data to message.call.
- Message.vue gains a `call` prop that's provided through
  useMessageContext for the bubble.
- UPDATE_MESSAGE_CALL_STATUS mutation now updates message.call.status,
  matched by provider_call_id.
- content_attributes.data is still emitted by the backend for this
  commit; backend-side trim lands in a follow-up.
2026-04-20 13:36:26 +04:00
Muhsin 5ce77eedbe feat(voice): embed Call in voice_call message payload
Adds a nested `call` object to the voice_call message JSON / push
payload so the frontend can read call state directly from message.call
instead of duplicating it in content_attributes.data.

- Message gains has_one :call (inverse of existing Call#belongs_to :message)
  via enterprise module prepend.
- Message serializer (_message.json.jbuilder) emits a call block for
  voice_call messages when the Call is linked.
- Message#push_event_data mirrors the payload into socket broadcasts.
- MessageFinder eager-loads the call (+ contact + inbox.channel) to
  avoid N+1 when listing conversation messages.
- Call gains helper methods: display_status, from_number, to_number,
  recording_url, push_event_data. These centralize the direction- and
  status-aware derivations so jbuilder and push_event_data stay in sync.

Frontend continues to read content_attributes.data — this commit only
adds the new shape; a follow-up swaps readers to message.call.
2026-04-20 13:33:43 +04:00
Muhsin KelothandGitHub 811d50043c Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring 2026-04-20 12:27:32 +04:00
Muhsin KelothandGitHub 1a2e518341 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-20 12:25:00 +04:00
Muhsin 117742a779 fix(voice): guarantee conference_sid at TwiML render, consistent Call factory
- conference_twiml now ensures call.conference_sid is present before
  rendering; backfills via Voice::Conference::Name.for(call) if missing.
  Prevents invalid TwiML on Call rows created without the sid set.
- Call factory derives account/inbox/contact from the associated
  conversation so create(:call) is always internally consistent (no more
  mismatched account_id across associations).
2026-04-18 10:37:47 +04:00
Muhsin 081e578a57 fix(voice): validate conversation_id when resolving Call in ConferenceController
Require the resolved Call to belong to the passed conversation_id so a
request with mismatched (conversation_id, call_sid) fails instead of
silently operating on the call from call_sid. Restores the implicit
authorization the previous conversation-based lookup provided.
2026-04-17 23:38:40 +04:00
Muhsin 7635a538af fix(voice): harden agent-label parsing and prevent started_at drift
- Conference::Manager.extract_user_id now requires the account id in the
  participant label to match call.account_id, so a spoofed label can't
  attach a user from another account to the call.
- CallStatus::Manager keeps the earliest started_at across multiple
  in-progress webhooks (Twilio can emit answered + in-progress, plus
  retries) so duration_seconds isn't undercounted.
2026-04-17 23:02:28 +04:00
Muhsin d6d6f87145 fix(voice): O(1) voice_call message lookup and nil-safe content_attributes
- find_message prefers call.message (via the already-linked message_id)
  instead of scanning every voice_call message in the conversation. Falls
  back to a jsonb-indexed query by call_sid when message_id isn't set yet.
- update_message! tolerates a nil content_attributes column (nullable in
  the schema).
2026-04-17 22:52:13 +04:00
Muhsin d62037676c fix(voice): scope all VoiceController Call lookups to the inbox
Every Call lookup in the Twilio webhook controller is now constrained
to the inbox resolved from the URL phone number. Prevents tampered
params from attaching the agent leg or outbound leg to a Call belonging
to another inbox/account.

Factored out the common `inbox_calls` scope used by
find_call_for_agent, sync_outbound_leg, and find_call_for_conference!.
2026-04-17 22:49:46 +04:00
Muhsin 91da28a329 fix(voice): scope inbound Call lookup and handle create races
- Scope the existing-call pre-check to the inbound account + inbox.
- Rescue ActiveRecord::RecordNotUnique so a concurrent Twilio retry
  doesn't 500 when two webhooks race to create the same Call.
2026-04-17 22:45:39 +04:00
Muhsin 4282dec7d6 fix(voice): thread callSid through agent leg + conference API clients
Required by the stricter server-side call_sid requirements from the
previous commit. Without this, agents would fail to join or leave calls
because the client side still sent only conversation_id.

- TwilioVoiceClient.joinClientCall now forwards callSid as a Device.connect
  param, which becomes params[:call_sid] on the TwiML webhook.
- VoiceAPI.leaveConference takes callSid and passes it as a query param
  so the DELETE /conference endpoint can resolve the exact call.
- useCallSession and FloatingCallWidget pass callSid through.
2026-04-17 22:41:20 +04:00
Muhsin 12dc22b693 fix(voice): address codex review feedback on Call-model PR
- Guard against delayed conference-start retries rolling a progressed
  call back to ringing (Conference::Manager#mark_ringing!).
- Scope conference-status webhook Call lookup to the resolved inbox
  (VoiceController#find_call_for_conference!).
- Require explicit call_sid on the agent TwiML leg and on
  ConferenceController#create/destroy so multi-call conversations can't
  attach the wrong call (no more most-recent-active fallback).
- Scope StatusUpdateService Call lookup to the webhook's account for
  defense-in-depth.
- Update UPDATE_MESSAGE_CALL_STATUS mutation to match the voice_call
  message by call_sid; helper/voice.js now passes callSid. Prevents an
  older call's status update from clobbering the newest bubble.
2026-04-17 22:28:10 +04:00
Muhsin 07de9ae1f1 test(voice): update specs to match Call-model-backed voice flow
- Add calls factory.
- voice_controller_spec, conference_controller_spec, and the voice
  service specs now assert against Call records instead of
  conversation.identifier / additional_attributes.
- Scope Call lookup in ConferenceController to the authorized inbox so
  call_sid alone can't cross-inbox escalate.
2026-04-17 20:24:37 +04:00
Muhsin 47788f4d9c Delete voice_call_wiring.md 2026-04-17 19:47:38 +04:00
Muhsin bb4feec53e feat(voice): wire Twilio voice flow through unified Call model
Moves call state off conversation.additional_attributes and
conversation.identifier onto first-class Call records, one per call.

- Call is the source of truth for status, direction, duration, started_at,
  accepted_by_agent, and conference_sid (in meta).
- Conference names key off Call.id (conf_account_{aid}_call_{cid}), so
  multiple calls on one conversation no longer collide. lock_to_single_conversation
  inboxes append each call as a new voice_call bubble in the existing thread.
- Agent identity on conference join webhooks is parsed from the Twilio
  ParticipantLabel (agent-{user_id}-account-{account_id}); stateless and
  independent of the /conference#create API call order.
- ConversationCard.vue derives the call badge from the latest voice_call
  message, not a denormalized cache on the conversation. Removes the stale
  "Call ended" state that lingered after subsequent text messages.
2026-04-17 19:44:11 +04:00
Muhsin KelothandGitHub 3b89787c7f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-17 12:16:55 +04:00
Muhsin 94766e1200 chore: rescue errors only in delete_twiml_app 2026-04-14 15:29:16 +04:00
Muhsin 5a16a5a0ee test: stub incoming_phone_numbers in voice teardown spec
Teardown now also clears number-level voice_url/status_callback, so
the Twilio client double needs incoming_phone_numbers.list stubbed.
2026-04-14 15:24:44 +04:00
Muhsin b8df28c8a3 chore: restrict TwilioSms attrs to SMS medium 2026-04-14 14:36:44 +04:00
Muhsin a77a1c8ab4 chore: skip TwiML provisioning if no phone number 2026-04-14 14:31:04 +04:00
Muhsin 57821ab6f9 chore: clear Twilio number webhooks during teardown 2026-04-14 14:30:23 +04:00
Muhsin KelothandGitHub b4e27ed4cb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-14 14:10:12 +04:00
Muhsin KelothandGitHub 726193e3ac Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:45:18 +04:00
Muhsin KelothandGitHub 55fd3c62cc Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-13 20:16:05 +04:00
Muhsin KelothandGitHub 1a63c4089a Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-11 10:51:31 +04:00
Muhsin KelothandGitHub b98ce4b18c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 20:06:48 +04:00
Muhsin ddbd81b85e Update voice_teardown_service.rb 2026-04-08 13:32:14 +04:00
Muhsin 986f3ebc3a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:22:16 +04:00
Muhsin a6a612568a chore: support Twilio API Key auth in ConferenceService 2026-04-08 13:21:00 +04:00
Muhsin KelothandGitHub 7ebe9db77c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:18:34 +04:00
Muhsin KelothandGitHub fdf22a034f Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-08 12:09:59 +04:00
Muhsin KelothandGitHub 14100580fb Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 16:46:02 +04:00
Muhsin KelothandGitHub 453fcecfb4 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 15:45:18 +04:00
Muhsin KelothandGitHub 401ec4cd81 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 14:04:33 +04:00
Muhsin 38867ba75f chore: use API key client for token validation 2026-04-06 13:27:52 +04:00
Muhsin d3b7d7e79a chore: show voice config only if inbox webhook set 2026-04-06 12:29:51 +04:00
Muhsin cb275ce1d9 chore: remove voiceCall gating for tabs and settings 2026-04-06 11:29:48 +04:00
Muhsin e96fa6f561 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-06 11:27:24 +04:00
Muhsin 78296b9ce3 chore: require voice_call content for Twilio incoming 2026-04-06 11:27:16 +04:00
Muhsin KelothandGitHub 9c85a92e86 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-06 11:14:24 +04:00
Muhsin KelothandGitHub f7d4d2d58c Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-04 17:48:37 +04:00
Muhsin KelothandGitHub fdce5910e0 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 16:10:34 +04:00
Muhsin aa15ddfef8 chore: enable channel_voice in inboxes spec 2026-04-01 15:38:58 +04:00
Muhsin d80a7454ce chore: run teardown_voice callback only on update 2026-04-01 15:28:08 +04:00
Muhsin b684f75631 Update twilio_sms_voice_spec.rb 2026-04-01 15:04:53 +04:00
Muhsin 6e0b10cb42 chore: expose API key secret and require it in UI 2026-04-01 15:01:54 +04:00
Muhsin 020bf6af93 chore: don't provision Twiml app if sid present 2026-04-01 14:58:50 +04:00
Muhsin a51af8ca68 chore: fix review comments 2026-04-01 14:28:11 +04:00
Muhsin dfdfc669ea chore: reserve api_key_secret and update voice UI 2026-04-01 14:22:50 +04:00
Muhsin 987d4d4a01 chore: revert unrelated changes 2026-04-01 13:04:49 +04:00
Muhsin 3471296394 Merge branch 'feat/voice-as-twilio-capability' of github.com:chatwoot/chatwoot into feat/voice-as-twilio-capability 2026-04-01 13:01:57 +04:00
Muhsin 465b70ce27 chore: revert unrelated changes 2026-04-01 13:01:46 +04:00
Muhsin KelothandGitHub c90fdd6594 Merge branch 'develop' into feat/voice-as-twilio-capability 2026-04-01 12:41:07 +04:00
Muhsin 6890293e60 chore: extract Twilio voice teardown into service 2026-03-28 12:39:02 +04:00
Muhsin 5c704691d7 chore: validate Twilio number voice capability 2026-03-27 15:01:45 +04:00
Muhsin 1946c373d7 chore: teardown Twilio voice resources when disabled 2026-03-27 14:16:17 +04:00
Muhsin 797d7790db chore: add feature flags 2026-03-27 13:43:30 +04:00
Muhsin 1d555cebfc chore: handle Twilio API Key SID conditionally 2026-03-27 13:37:04 +04:00
Muhsin 2b5d3300f3 chore: add Voice configuration UI and API fields 2026-03-26 18:28:29 +04:00
Muhsin ddfa6cea32 chore: fix the specs 2026-03-26 17:15:52 +04:00
Muhsin 85438cf58a chore: add Twilio voice support to TwilioSms channel 2026-03-26 16:40:33 +04:00
100 changed files with 3541 additions and 1182 deletions
+3 -1
View File
@@ -11,7 +11,9 @@ class MessageFinder
private
def conversation_messages
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
end
def messages
@@ -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);
}
@@ -0,0 +1,68 @@
/* global axios */
import ApiClient from './ApiClient';
class VoiceCallsAPI extends ApiClient {
constructor() {
super('voice_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`);
}
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
// it to Meta via pre_accept_call+accept_call.
accept(callId, { sdpAnswer } = {}) {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`);
}
// Outbound: browser builds the offer first; Rails ships it to Meta and
// creates the Call record. Meta delivers its SDP answer later via the
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
initiate(conversationId, provider, { sdpOffer } = {}) {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
provider,
sdp_offer: sdpOffer,
});
}
// Get the current agent's active call (if any). Used on page load to detect
// a stale active session so we can terminate it cleanly.
active() {
return axios.get(`${this.url}/active`);
}
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
// controller attaches it to the call's voice_call message; the after-create
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
uploadRecording(callId, blob, filename) {
const fd = new FormData();
fd.append('recording', blob, filename);
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
// needs the absolute account-scoped path).
uploadRecordingUrl(callId) {
return `${this.url}/${callId}/upload_recording`;
}
terminateUrl(callId) {
return `${this.url}/${callId}/terminate`;
}
}
export default new VoiceCallsAPI();
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -40,7 +40,6 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -68,15 +67,16 @@ const startCall = async inboxId => {
contactId: props.contactId,
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',
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.setActiveCall({
id: response.call_id,
callId: response.call_sid,
provider: response.provider || 'twilio',
direction: 'outbound',
status: 'ringing',
conversationId: response.conversation_id,
caller: null,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -30,6 +28,7 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -82,11 +81,13 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const voiceCallEnabled = computed(() => props.voiceEnabled);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
props.hasSelectedInbox &&
isRegularMessageMode.value &&
!voiceCallEnabled.value
);
});
@@ -111,7 +112,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !isVoiceInbox.value) setSignature();
if (newValue && !voiceCallEnabled.value) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -442,6 +444,7 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -1,4 +1,5 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -113,6 +113,7 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
@@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -30,8 +33,11 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { contentAttributes, messageType } = useMessageContext();
const router = useRouter();
const { contentAttributes, messageType, attachments } = useMessageContext();
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
// applies useCamelCase(messages, { deep: true }) before rendering.
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
@@ -40,6 +46,52 @@ const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
// Call source and metadata — all camelCase due to deep transform
const callSource = computed(() => data.value?.callSource);
const isVoiceCall = computed(() =>
['whatsapp', 'twilio'].includes(callSource.value)
);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
// Recording and transcript live on the first audio attachment. After the
// call ends, CallRecordingFetchJob creates an Attachment on this message;
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
const audioAttachment = computed(
() => attachments.value?.find(a => a.fileType === 'audio') || null
);
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
const transcript = computed(() => audioAttachment.value?.transcribedText);
const isJoining = ref(false);
const isRejecting = ref(false);
const showTranscript = ref(false);
// Reject is shown only for ringing inbound calls — outbound calls and
// in-progress calls don't need a reject button (initiator hangs up via the
// floating widget).
const showRejectButton = computed(
() =>
isVoiceCall.value &&
!isOutbound.value &&
status.value === VOICE_CALL_STATUS.RINGING
);
const formattedDuration = computed(() => {
const seconds = durationSeconds.value;
if (!seconds || seconds <= 0) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
});
// Show join/accept button logic
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
// DTLS fingerprint), so only ringing calls show the Accept button.
const showJoinButton = computed(
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -53,6 +105,19 @@ const labelKey = computed(() => {
});
const subtextKey = computed(() => {
// acceptedBy on outbound calls is the initiator, not the contact — so keep
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
// should suppress the subtext in favor of the acceptedBy line.
if (
!isOutbound.value &&
acceptedBy.value?.name &&
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
status.value
)
) {
return null;
}
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
@@ -64,12 +129,50 @@ const subtextKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const answeredByText = computed(() => {
if (isOutbound.value) return '';
if (!acceptedBy.value?.name) return '';
return acceptedBy.value.name;
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const handleJoinCall = async () => {
if (isJoining.value || !isVoiceCall.value) return;
isJoining.value = true;
try {
const result = await acceptVoiceCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: result.call.conversationId },
});
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Accept from bubble failed:', err);
} finally {
isJoining.value = false;
}
};
const handleRejectCall = async () => {
if (isRejecting.value || !callId.value) return;
isRejecting.value = true;
try {
await VoiceCallsAPI.reject(callId.value);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Reject from bubble failed:', err);
} finally {
isRejecting.value = false;
}
};
</script>
<template>
@@ -90,14 +193,90 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow">
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<span v-if="answeredByText" class="text-xs text-n-slate-11">
{{
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
name: answeredByText,
})
}}
</span>
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
class="text-xs text-n-slate-10"
>
{{ formattedDuration }}
</span>
</div>
<button
v-if="showRejectButton"
:disabled="isRejecting"
:title="$t('WHATSAPP_CALL.REJECT')"
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isRejecting }"
@click="handleRejectCall"
>
<i
v-if="isRejecting"
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
/>
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
</button>
<button
v-if="showJoinButton"
:disabled="isJoining"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isJoining }"
@click="handleJoinCall"
>
<i
v-if="isJoining"
class="i-ph-circle-notch-bold text-sm animate-spin"
/>
<i v-else class="i-ph-phone-bold text-sm" />
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
</button>
</div>
<div
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-2"
>
<audio controls class="w-full h-8" :src="recordingUrl">
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-3"
>
<button
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
@click="showTranscript = !showTranscript"
>
<i
class="text-sm"
:class="
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
"
/>
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
</button>
<p
v-if="showTranscript"
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
>
{{ transcript }}
</p>
</div>
</div>
</BaseBubble>
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
@@ -0,0 +1,244 @@
<script setup>
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useVoiceCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('voice_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('voice_call:permission_granted', onPermissionGranted);
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
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="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
: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">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{
call.provider === 'twilio'
? 'Incoming voice call'
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_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"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<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"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
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': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
: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">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
<template v-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => {
return (
@@ -13,6 +13,14 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import {
prepareOutboundOffer,
cleanupInboundWebRTC,
} from 'dashboard/composables/useVoiceCallSession';
const props = defineProps({
chat: {
@@ -30,7 +38,10 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
useInbox();
const voiceCallsStore = useVoiceCallsStore();
const isInitiatingCall = ref(false);
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -91,6 +102,99 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
// Returns the provider to use for an outbound call, based on the inbox
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
// use :twilio. Returns null if outbound calling isn't supported on this
// channel.
const callProvider = computed(() => {
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
return 'whatsapp';
}
if (voiceCallEnabled.value) {
return 'twilio';
}
return null;
});
const canInitiateVoiceCall = computed(() => {
if (!callProvider.value) return false;
if (voiceCallsStore.hasVoiceCall) return false;
return true;
});
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
// the browser builds the SDP offer locally before any network call, so Meta
// gets the agent's real fingerprint and the eventual answer applies cleanly
// in the cable handler. Twilio still POSTs first and waits for the
// synchronous agent_offer in the response (its OutboundCallBuilder produces
// the SDP server-side). On 138006 (no call permission) we tear down the local
// PC immediately so the mic light goes off while the contact's phone shows
// the consent prompt.
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
return;
isInitiatingCall.value = true;
const isWhatsApp = callProvider.value === 'whatsapp';
try {
let sdpOffer = null;
if (isWhatsApp) {
sdpOffer = await prepareOutboundOffer({});
}
const response = await VoiceCallsAPI.initiate(
currentChat.value.id,
callProvider.value,
isWhatsApp ? { sdpOffer } : {}
);
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
// Tear down the local PC; we'll rebuild from scratch on retry.
cleanupInboundWebRTC();
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
const callData = response.data || {};
voiceCallsStore.setActiveCall({
id: callData.id,
callId: callData.call_id,
provider: callData.provider || callProvider.value,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
cleanupInboundWebRTC();
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
<template>
@@ -152,6 +256,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="canInitiateVoiceCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -47,7 +47,11 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -211,11 +215,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
// Test Voice (Twilio with voice_enabled)
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
expect(wrapper.vm.voiceCallEnabled).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -274,7 +278,8 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
expectedProperties.forEach(prop => {
@@ -0,0 +1,36 @@
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { useAlert } from 'dashboard/composables';
/**
* Detects a stale active call on page load and cleans it up. Direct
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
* Best we can do is terminate the stranded Call so the agent isn't shown
* "in a call" forever.
*/
export function useCallReconnection() {
const callsStore = useVoiceCallsStore();
const { t } = useI18n();
onMounted(async () => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
try {
const { data } = await VoiceCallsAPI.active();
if (!data?.call && !data?.id) return;
const activeCallData = data.call || data;
try {
await VoiceCallsAPI.terminate(activeCallData.id);
} catch {
// best-effort — Rails-side cleanup
}
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
} catch {
callsStore.clearActiveCall();
}
});
}
@@ -42,8 +42,8 @@ export function useCallSession() {
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
@@ -66,6 +66,7 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
callSid,
});
callsStore.setCallActive(callSid);
@@ -1,7 +1,11 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
return {
inbox,
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
voiceCallEnabled,
voiceCallProvider,
};
};
@@ -0,0 +1,654 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Timer from 'dashboard/helper/Timer';
import { emitter } from 'shared/helpers/mitt';
// ─────────────────────────────────────────────────────────────────────────────
// Module-level WebRTC + recorder state. One active peer per browser tab.
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
// disambiguates so beforeunload doesn't warn during a clean hangup.
// ─────────────────────────────────────────────────────────────────────────────
let inboundPc = null;
let inboundStream = null;
let inboundAudio = null;
let audioContext = null;
let mediaRecorder = null;
let recordingMime = null;
let recordingChunks = [];
let recordingFilename = null;
let intentionallyClosing = false;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
function pickRecorderMime() {
if (typeof MediaRecorder === 'undefined') return null;
return (
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
);
}
function teardownRecorder() {
mediaRecorder = null;
recordingChunks = [];
recordingMime = null;
recordingFilename = null;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
}
function extensionFromMime(mime) {
if (!mime) return 'webm';
if (mime.startsWith('audio/webm')) return 'webm';
if (mime.startsWith('audio/ogg')) return 'ogg';
return 'webm';
}
// Mix the agent's mic and Meta's remote audio into a single stream so the
// resulting Blob is a real conversation rather than two interleaved channels.
// Web Audio handles the mix; we never apply gain (raw levels matter for
// Whisper's speech-detection floor).
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
if (mediaRecorder || !localStream || !remoteStream) return;
const mime = pickRecorderMime();
if (!mime) {
// eslint-disable-next-line no-console
console.warn(
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
);
return;
}
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000,
});
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const localSource = audioContext.createMediaStreamSource(localStream);
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
const mixDest = audioContext.createMediaStreamDestination();
localSource.connect(mixDest);
remoteSource.connect(mixDest);
recordingMime = mime;
recordingChunks = [];
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size) recordingChunks.push(event.data);
};
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
// beacon to capture most of the call if the user closes the tab.
mediaRecorder.start(5000);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recorder setup failed:', err);
teardownRecorder();
}
}
// stop() returns nothing useful; the final dataavailable fires on the next
// task and onstop fires after that. Wait for onstop, then assemble the blob.
async function stopRecorderAndGetBlob() {
if (!mediaRecorder) return null;
const mr = mediaRecorder;
if (mr.state === 'inactive') {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
teardownRecorder();
return { blob, filename: recordingFilename };
}
return new Promise(resolve => {
const cleanup = () => {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const filename = recordingFilename;
teardownRecorder();
resolve({ blob, filename });
};
mr.onstop = cleanup;
try {
mr.stop();
} catch {
cleanup();
}
});
}
function cleanupInboundWebRTC() {
// Stop recorder first so the source nodes still see live tracks during
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
teardownRecorder();
if (inboundStream) {
inboundStream.getTracks().forEach(track => track.stop());
inboundStream = null;
}
if (inboundPc) {
inboundPc.close();
inboundPc = null;
}
if (inboundAudio) {
inboundAudio.srcObject = null;
if (inboundAudio.parentNode) {
inboundAudio.parentNode.removeChild(inboundAudio);
}
inboundAudio = null;
}
}
function waitForIceGatheringComplete(pc) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
// eslint-disable-next-line no-console
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
}
function attachRemoteTrackHandler(pc, callId, providerCallId) {
pc.ontrack = event => {
let remoteStream = event.streams && event.streams[0];
if (!remoteStream) {
if (!event.track) return;
remoteStream = new MediaStream([event.track]);
}
if (!inboundAudio) {
const audio = document.createElement('audio');
audio.autoplay = true;
document.body.appendChild(audio);
inboundAudio = audio;
}
inboundAudio.srcObject = remoteStream;
inboundAudio.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[Voice Call] audio.play() rejected:', err);
});
if (inboundStream) {
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
}
};
}
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
// cable event, opens its mic, builds the answer, and ships it back so Rails
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
async function prepareInboundAnswer({
callId,
providerCallId,
sdpOffer,
iceServers,
}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Outbound: browser builds the offer locally, before any network call. Rails
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
// later via the voice_call.outbound_connected cable event.
async function prepareOutboundOffer({
callId,
providerCallId,
iceServers,
} = {}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
// from the actionCable handler when voice_call.outbound_connected lands.
async function applyOutboundAnswer(sdpAnswer) {
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
if (inboundPc.signalingState !== 'have-local-offer') {
// Already applied — duplicate cable delivery. No-op rather than detonate.
return;
}
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Some browsers fire ontrack synchronously inside setRemoteDescription;
// others delay it. If we already have a remote stream, kick the recorder.
if (inboundAudio?.srcObject && inboundStream) {
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
}
}
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
// Standalone for the message bubble's Accept button. Branches on whether the
// store has an SDP offer (WhatsApp direct mode = always yes).
export async function acceptVoiceCallById(callId) {
const callsStore = useVoiceCallsStore();
if (callsStore.hasActiveCall) {
return { success: false, error: 'active_call_exists' };
}
let call = callsStore.incomingCalls.find(
c => c.id === callId || c.callId === String(callId)
);
let sdpOffer = call?.sdpOffer;
let iceServers = call?.iceServers;
let providerCallId = call?.callId;
if (!sdpOffer) {
const { data } = await VoiceCallsAPI.show(callId);
if (data.status !== 'ringing') {
return { success: false, error: 'not_ringing' };
}
sdpOffer = data.sdp_offer;
iceServers = data.ice_servers;
providerCallId = data.call_id;
call = {
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer,
iceServers,
};
callsStore.addIncomingCall(call);
}
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
// network call so the cable handler doesn't drop a frame on a null guard.
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
return { success: true, call: activeCallData };
} catch (err) {
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall?.();
throw err;
}
}
// Best-effort upload. Errors are swallowed because the call has already
// completed by the time we get here; surfacing failures isn't actionable.
async function uploadRecordingBlob(callId) {
if (!mediaRecorder && recordingChunks.length === 0) return;
try {
const result = await stopRecorderAndGetBlob();
if (!result?.blob || result.blob.size === 0) return;
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recording upload failed:', err);
}
}
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
export function useVoiceCallSession() {
const { t } = useI18n();
const callsStore = useVoiceCallsStore();
const getters = useStoreGetters();
const accountId = computed(() => getters.getCurrentAccountId.value);
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
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);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const isOutboundRinging = computed(
() =>
activeCall.value?.direction === 'outbound' &&
activeCall.value?.status === 'ringing'
);
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')}`;
});
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
// the only one that ships audio to the server. uploadRecordingBlob
// gathers the blob via the recorder's onstop listener and then frees the
// recorder — only AFTER that completes do we close the PC and stop tracks.
callsStore.registerCleanupCallback(async callId => {
const idForUpload = callId || activeCall.value?.id;
durationTimer.stop();
callDuration.value = 0;
try {
if (idForUpload) await uploadRecordingBlob(idForUpload);
} finally {
cleanupInboundWebRTC();
}
});
// Warn the user if they try to navigate away mid-call. The actual cleanup
// happens in the pagehide handler — beforeunload is purely the warning.
const handleBeforeUnload = e => {
if (!callsStore.activeCall || intentionallyClosing) return undefined;
e.preventDefault();
e.returnValue = '';
// Some browsers (older Safari/Firefox) only honour a returned string.
// eslint-disable-next-line consistent-return
return '';
};
// Fires after the user confirms close (or on tab switch). We use fetch
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
// authenticates via devise-token-auth headers (`access-token`, `client`,
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
// fetch+keepalive sends both cookies AND custom headers and the request
// survives page unload (capped at ~64KB body, fine for terminate).
const handlePageHide = () => {
const call = callsStore.activeCall;
if (!call || intentionallyClosing) return;
// Stop the recorder synchronously; final dataavailable already fired via
// the 5s timeslice, so chunks[] is approximately current.
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
const sessionHeaders = (() => {
try {
return JSON.parse(
decodeURIComponent(
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
[])[2] || '%7B%7D'
)
);
} catch {
return {};
}
})();
const authHeaders = {
'access-token': sessionHeaders['access-token'] || '',
client: sessionHeaders.client || '',
uid: sessionHeaders.uid || '',
expiry: sessionHeaders.expiry || '',
'token-type': sessionHeaders['token-type'] || 'Bearer',
};
if (recordingChunks.length > 0 && accountId.value) {
try {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const fd = new FormData();
fd.append(
'recording',
blob,
recordingFilename || `call_${call.id}.webm`
);
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
{
method: 'POST',
body: fd,
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
if (accountId.value) {
try {
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
{
method: 'POST',
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
cleanupInboundWebRTC();
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
// Start timer when an outbound call transitions to connected.
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
// Floating-widget Accept button. Same WhatsApp-direct flow as
// acceptVoiceCallById — see comments there.
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
const sdpOffer = call.sdpOffer;
const iceServers = call.iceServers;
if (!sdpOffer) {
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
isAccepting.value = false;
return;
}
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId: call.callId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
durationTimer.start();
emitter.emit('voice_call:agent_webrtc_connected');
} catch (err) {
callError.value =
err?.name === 'NotAllowedError'
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[Voice Call] acceptCall error:', err);
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await VoiceCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
intentionallyClosing = true;
try {
await uploadRecordingBlob(call.id);
} catch {
// Best effort
}
try {
await VoiceCallsAPI.terminate(call.id);
} catch {
// Best effort
} finally {
cleanupInboundWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
intentionallyClosing = false;
}
};
const toggleMute = () => {
if (!inboundStream) return;
const audioTrack = inboundStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
const startDurationTimer = () => {
durationTimer.start();
};
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
};
}
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
+1
View File
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
+70 -5
View File
@@ -4,6 +4,8 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
const { isImpersonating } = useImpersonation();
@@ -33,8 +35,12 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.ended': this.onVoiceCallEnded,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
};
}
@@ -195,16 +201,75 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onEnrichmentCompleted = () => {
this.app.$store.dispatch('accounts/get', { silent: true });
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
// the bubble's Accept button can build the WebRTC answer locally
// without re-fetching from /show.
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
onVoiceCallAccepted = data => {
const voiceCallsStore = useVoiceCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
if (data.accepted_by_agent_id !== currentUserId) {
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.handleCallEnded(data.call_id);
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
// contact picks up. Apply it to the existing local RTCPeerConnection so
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
// by prepareOutboundOffer when the agent clicked the call button.
const voiceCallsStore = useVoiceCallsStore();
const activeCall = voiceCallsStore.activeCall;
if (!activeCall || activeCall.callId !== data.call_id) return;
if (!data.sdp_answer) return;
applyOutboundAnswer(data.sdp_answer)
.then(() => {
voiceCallsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallPermissionGranted = data => {
emitter.emit('voice_call:permission_granted', {
contactName: data.contact_name,
});
};
}
export default {
+20 -10
View File
@@ -11,9 +11,28 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -30,7 +49,6 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -45,7 +63,6 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -58,7 +75,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -97,9 +113,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -142,9 +155,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
+9 -7
View File
@@ -23,11 +23,11 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
const call = message?.call || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
callSid: call.provider_call_id,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
@@ -60,9 +60,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
conversationId,
callStatus: status,
callSid,
});
const isNewCall =
status === 'ringing' &&
@@ -83,7 +83,11 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"ANSWERED_BY": "Answered by {name}",
"ACCEPT_CALL": "Accept",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
"TRANSCRIPT": "Transcript"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -636,7 +636,17 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -36,6 +36,7 @@ import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappCall from './whatsappCall.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
@@ -81,6 +82,7 @@ export default {
...sla,
...snooze,
...teamsSettings,
...whatsappCall,
...whatsappTemplates,
...contentTemplates,
...mfa,
@@ -0,0 +1,20 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"RINGING": "Ringing…",
"CALL_FAILED": "Call failed. Please try again.",
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
}
}
@@ -20,11 +20,17 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
export default {
components: {
@@ -36,6 +42,7 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -44,6 +51,13 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const voiceCallsStore = useVoiceCallsStore();
// Detect a stale active WhatsApp call left over from a previous tab /
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
// path because Meta caches our DTLS fingerprint; the only safe action
// is to clean up server-side state and tell the agent the call dropped.
useCallReconnection();
return {
uiSettings,
@@ -51,8 +65,15 @@ export default {
accountId,
upgradePageRef,
windowWidth,
// Twilio voice still flows through useCallSession + the calls store.
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
// voiceCalls store. Both widgets can mount simultaneously; only one
// ever has state at a time because they listen to disjoint cable
// events.
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
};
},
data() {
@@ -163,6 +184,7 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -46,6 +47,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -169,19 +171,17 @@ export default {
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,7 +197,6 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -232,6 +231,24 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -812,7 +829,6 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,6 +12,10 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -30,7 +34,6 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -0,0 +1,156 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -307,34 +307,21 @@ export const mutations = {
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
[types.UPDATE_MESSAGE_CALL_STATUS](
_state,
{ conversationId, callStatus }
{ conversationId, callStatus, callSid }
) {
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
const message = (chat.messages || []).find(
m =>
m.content_type === CONTENT_TYPES.VOICE_CALL &&
m.call?.provider_call_id === callSid
);
if (!message?.call) return;
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
message.call = { ...message.call, status: callStatus };
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
@@ -2,55 +2,18 @@ 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',
callSid: 'CA123',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
it('does nothing if no matching voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
@@ -59,6 +22,7 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
@@ -66,7 +30,7 @@ describe('#mutations', () => {
});
});
it('updates the last voice call message status', () => {
it('updates only the voice call message matching the given callSid', () => {
const state = {
allConversations: [
{
@@ -75,12 +39,12 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA111', status: 'ringing' },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
call: { provider_call_id: 'CA222', status: 'ringing' },
},
],
},
@@ -89,34 +53,15 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA111',
});
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');
expect(state.allConversations[0].messages[0].call.status).toBe(
'in-progress'
);
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
});
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', () => {
it('preserves existing call fields when updating status', () => {
const state = {
allConversations: [
{
@@ -125,8 +70,11 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
call: {
provider_call_id: 'CA123',
status: 'ringing',
direction: 'incoming',
duration_seconds: null,
},
},
],
@@ -136,12 +84,13 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA123',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA123',
status: 'in-progress',
direction: 'incoming',
duration_seconds: null,
});
});
@@ -152,8 +101,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages).toEqual([]);
});
it('does nothing if matching message has no call object yet', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA-OTHER' },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
callSid: 'CA-MISSING',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA-OTHER',
});
});
});
});
@@ -52,7 +52,6 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
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',
@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
export const useVoiceCallsStore = defineStore('voiceCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action (per account)
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasVoiceCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
// Snapshot the DB id before nulling activeCall so the composable can
// POST upload_recording with the right path even after we clear state.
const dbCallId = this.activeCall?.id;
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
this.activeCall = null;
}
},
},
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
+2
View File
@@ -100,3 +100,5 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
+5
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_secret :string
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
@@ -11,6 +12,8 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
end
end
end
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
+5
View File
@@ -152,9 +152,14 @@ class Message < ApplicationRecord
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:call] = call.push_event_data if voice_call? && respond_to?(:call) && call.present?
merge_sender_attributes(data)
end
def voice_call?
content_type == 'voice_call'
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
+4 -1
View File
@@ -46,7 +46,10 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
# voice_call bubbles are in-app system events for the call lifecycle —
# dispatching them to the channel would deliver "WhatsApp Call" as a
# text message to the contact every time the agent placed a call.
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
def validate_target_channel
@@ -1,4 +1,6 @@
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
prepend Whatsapp::Providers::WhatsappCloudCallMethods if defined?(Whatsapp::Providers::WhatsappCloudCallMethods)
def send_message(phone_number, message)
@message = message
+13 -4
View File
@@ -72,6 +72,7 @@ if resource.twilio?
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end
end
@@ -129,10 +130,18 @@ if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
# `calling_enabled` is needed by every agent (not just admins) so the
# ConversationHeader can decide whether to render the outbound call button.
json.calling_enabled resource.channel.try(:provider_config)&.fetch('calling_enabled', false) || false
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
@@ -12,3 +12,7 @@ json.private message.private
json.source_id message.source_id
json.sender message.sender.push_event_data if message.sender
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
json.set! :call, message.call.push_event_data
end
+13
View File
@@ -303,6 +303,19 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
resources :voice_calls, only: [:show] do
collection do
get :active
post :initiate
end
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -0,0 +1,7 @@
class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0]
def change
add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false
add_column :channel_twilio_sms, :twiml_app_sid, :string
add_column :channel_twilio_sms, :api_key_secret, :string
end
end
@@ -0,0 +1,20 @@
class DropChannelVoice < ActiveRecord::Migration[7.0]
def up
drop_table :channel_voice, if_exists: true
end
def down
create_table :channel_voice do |t|
t.string :phone_number, null: false
t.string :provider, null: false, default: 'twilio'
t.jsonb :provider_config, null: false
t.integer :account_id, null: false
t.jsonb :additional_attributes, default: {}
t.timestamps
end
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
+3 -12
View File
@@ -552,6 +552,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "api_key_sid"
t.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated"
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -568,18 +571,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
phone_source_id
end
def phone_source_id
return super unless @inbox.channel_type == 'Channel::Voice'
return super unless twilio_voice_inbox?
return SecureRandom.uuid if @contact.phone_number.blank?
@@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder
end
def allowed_channels?
super || @inbox.channel_type == 'Channel::Voice'
super || twilio_voice_inbox?
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
end
end
@@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -10,36 +10,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
end
def create
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
call = resolve_call!
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: conversation.display_id,
id: call.conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
call = resolve_call!
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: call.conversation.display_id }
end
private
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
def resolve_call!
sid = params[:call_sid].presence
raise ActionController::ParameterMissing, :call_sid if sid.blank?
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
conversation = fetch_conversation_by_display_id
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
.find_by!(provider_call_id: sid)
end
def set_voice_inbox_for_conference
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
authorize contact, :show?
authorize voice_inbox, :show?
result = Voice::OutboundCallBuilder.perform!(
call = Voice::OutboundCallBuilder.perform!(
account: Current.account,
inbox: voice_inbox,
user: Current.user,
contact: contact
)
conversation = result[:conversation]
render json: {
conversation_id: conversation.display_id,
conversation_id: call.conversation.display_id,
inbox_id: voice_inbox.id,
call_sid: result[:call_sid],
conference_sid: conversation.additional_attributes['conference_sid']
call_sid: call.provider_call_id,
conference_sid: call.conference_sid
}
end
@@ -30,9 +28,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
end
def voice_inbox
@voice_inbox ||= Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::Voice'
).find(params.require(:inbox_id))
@voice_inbox ||= begin
inbox = Current.user.assigned_inboxes.where(
account_id: Current.account.id,
channel_type: 'Channel::TwilioSms'
).find(params.require(:inbox_id))
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
inbox
end
end
end
@@ -0,0 +1,183 @@
class Api::V1::Accounts::VoiceCallsController < Api::V1::Accounts::BaseController
before_action :set_call, only: %i[show accept reject terminate upload_recording]
def show
render json: call_payload(@call)
end
def accept
call = Voice::CallService.new(
call: @call,
agent: current_user,
sdp_answer: params[:sdp_answer]
).accept
render json: call_payload(call)
rescue Voice::CallErrors::NotRinging, Voice::CallErrors::AlreadyAccepted, Voice::CallErrors::CallFailed => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "[VOICE CALL] accept failed: #{e.class} #{e.message}"
render json: { error: 'Failed to accept call' }, status: :internal_server_error
end
def reject
call = Voice::CallService.new(call: @call, agent: current_user).reject
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] reject failed: #{e.class} #{e.message}"
render json: { error: 'Failed to reject call' }, status: :internal_server_error
end
def terminate
call = Voice::CallService.new(call: @call, agent: current_user).terminate
render json: { id: call.id, status: call.status }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] terminate failed: #{e.class} #{e.message}"
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
end
# Browser-supplied recording, captured via MediaRecorder during the call.
# Idempotent: subsequent uploads after the first audio attachment exists
# silently no-op so the hangup-vs-pagehide race can't double-attach.
def upload_recording
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
return render json: { id: @call.id, status: 'no_message' }, status: :unprocessable_entity if @call.message.blank?
return render json: { id: @call.id, status: 'already_uploaded' } if @call.message.attachments.exists?(file_type: :audio)
attach_recording!
render json: { id: @call.id, status: 'uploaded' }
rescue StandardError => e
Rails.logger.error "[VOICE CALL] upload_recording failed: #{e.class} #{e.message}"
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
end
def active
call = current_account.calls.active_for_agent(current_user.id).last
if call
elapsed = call.started_at ? (Time.current - call.started_at).to_i : 0
render json: {
id: call.id,
call_id: call.provider_call_id,
provider: call.provider,
conversation_id: call.conversation_id,
status: call.status,
elapsed_seconds: elapsed
}
else
render json: { call: nil }
end
end
def initiate
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
authorize conversation, :show?
initiate_whatsapp(conversation)
rescue Voice::CallErrors::NoCallPermission
handle_no_call_permission(conversation)
rescue ActiveRecord::RecordNotFound
render json: { error: 'Conversation not found' }, status: :not_found
rescue StandardError => e
Rails.logger.error "[VOICE CALL] initiate failed: #{e.class} #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def initiate_whatsapp(conversation)
error = validate_whatsapp_calling(conversation)
return render json: { error: error }, status: :unprocessable_entity if error
return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if params[:sdp_offer].blank?
contact_phone = conversation.contact&.phone_number
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
call = create_whatsapp_outbound_call(conversation, contact_phone, params[:sdp_offer])
message = Voice::CallMessageBuilder.create!(conversation: conversation, call: call, user: current_user)
call.update!(message_id: message.id)
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id, provider: 'whatsapp' }
end
# Browser → Rails → Meta. The browser already opened its mic, built an
# RTCPeerConnection, generated the offer, and waited for ICE gathering.
# We just hand that offer to Meta. Meta returns the call_id immediately
# (no SDP yet); the contact's phone rings; on pickup the connect webhook
# delivers Meta's SDP answer and we relay it back via ActionCable.
def create_whatsapp_outbound_call(conversation, contact_phone, sdp_offer)
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer)
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
current_account.calls.create!(
provider: :whatsapp,
inbox: conversation.inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: provider_call_id,
direction: :outgoing,
status: 'ringing',
accepted_by_agent_id: current_user.id,
meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
end
def validate_whatsapp_calling(conversation)
channel = conversation.inbox.channel
return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud'
return 'Calling is not enabled for this inbox' unless channel.provider_config['calling_enabled']
nil
end
def handle_no_call_permission(conversation)
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago
contact_phone = conversation.contact.phone_number.delete('+')
result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result
attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)
conversation.update!(additional_attributes: attrs)
render json: { status: 'permission_requested' }
end
def call_payload(call)
{
id: call.id,
call_id: call.provider_call_id,
provider: call.provider,
status: call.status,
direction: call.direction_label,
conversation_id: call.conversation_id,
inbox_id: call.inbox_id,
message_id: call.message_id,
accepted_by_agent_id: call.accepted_by_agent_id,
elapsed_seconds: call.started_at ? (Time.current - call.started_at).to_i : 0,
sdp_offer: call.meta&.dig('sdp_offer'),
ice_servers: call.meta&.dig('ice_servers') || Call.default_ice_servers,
caller: caller_info(call)
}
end
def caller_info(call)
contact = call.conversation&.contact
return {} unless contact
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
end
def set_call
@call = current_account.calls.find(params[:id])
authorize @call.conversation, :show?
rescue ActiveRecord::RecordNotFound
render json: { error: 'Call not found' }, status: :not_found
end
def attach_recording!
@call.message.attachments.create!(
account_id: @call.account_id,
file_type: :audio,
file: params[:recording]
)
end
end
@@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController
end
def channel_type_from_params
case permitted_params[:channel][:type]
when 'voice'
Channel::Voice
else
super
end
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
super
end
def account_channels_method
case permitted_params[:channel][:type]
when 'voice'
Current.account.voice_channels
else
super
end
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
super
end
def create_channel
return create_voice_channel if permitted_params[:channel][:type] == 'voice'
super
end
def get_channel_attributes(channel_type)
attrs = super
attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms'
attrs
end
def create_voice_channel
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice')
voice_params = params.require(:channel).permit(
:phone_number, :provider,
provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret]
)
config = voice_params[:provider_config] || {}
Current.account.twilio_sms.create!(
phone_number: voice_params[:phone_number],
account_sid: config[:account_sid],
auth_token: config[:auth_token],
api_key_sid: config[:api_key_sid],
api_key_secret: config[:api_key_secret],
medium: :sms,
voice_enabled: true
)
end
end
@@ -20,30 +20,26 @@ class Twilio::VoiceController < ApplicationController
end
def call_twiml
account = current_account
Rails.logger.info(
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
conversation = resolve_conversation
conference_sid = ensure_conference_sid!(conversation)
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
call = resolve_call
render xml: conference_twiml(call)
end
def conference_status
event = mapped_conference_event
return head :no_content unless event
conversation = find_conversation_for_conference!(
call = find_call_for_conference!(
friendly_name: params[:FriendlyName],
call_sid: twilio_call_sid
)
Voice::Conference::Manager.new(
conversation: conversation,
call: call,
event: event,
call_sid: twilio_call_sid,
participant_label: participant_label
).process
@@ -80,8 +76,8 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
def resolve_conversation
return find_conversation_for_agent if agent_leg?(twilio_from)
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
case twilio_direction
when 'inbound'
@@ -92,85 +88,79 @@ class Twilio::VoiceController < ApplicationController
call_sid: twilio_call_sid
)
when 'outbound-api', 'outbound-dial'
sync_outbound_leg(
call_sid: twilio_call_sid,
from_number: twilio_from,
direction: twilio_direction
)
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
else
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
end
end
def find_conversation_for_agent
if params[:conversation_id].present?
current_account.conversations.find_by!(display_id: params[:conversation_id])
else
current_account.conversations.find_by!(identifier: twilio_call_sid)
end
def find_call_for_agent
sid = params[:call_sid].presence
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
inbox_calls.find_by!(provider_call_id: sid)
end
def sync_outbound_leg(call_sid:, from_number:, direction:)
def sync_outbound_leg(call_sid:, direction:)
parent_sid = params['ParentCallSid'].presence
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
Voice::CallSessionSyncService.new(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: twilio_to,
direction: 'outbound'
}
).perform
Voice::CallSessionSyncService.new(call: call, parent_call_sid: parent_sid).perform
end
def ensure_conference_sid!(conversation)
attrs = conversation.additional_attributes || {}
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
attrs['conference_sid']
def inbox_calls
Call.where(inbox_id: inbox.id, provider: :twilio)
end
def conference_twiml(conference_sid, agent_leg)
def conference_twiml(call)
conference_sid = ensure_conference_sid!(call)
Twilio::TwiML::VoiceResponse.new.tap do |response|
response.dial do |dial|
dial.conference(
conference_sid,
start_conference_on_enter: agent_leg,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
participant_label: agent_leg ? 'agent' : 'contact'
participant_label: participant_label_for(twilio_from)
)
end
end.to_s
end
def ensure_conference_sid!(call)
return call.conference_sid if call.conference_sid.present?
call.update!(conference_sid: Voice::Conference::Name.for(call))
call.conference_sid
end
def participant_label_for(from_number)
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
'contact'
end
def conference_status_callback_url
phone_digits = inbox_channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
def find_conversation_for_conference!(friendly_name:, call_sid:)
def find_call_for_conference!(friendly_name:, call_sid:)
name = friendly_name.to_s
scope = current_account.conversations
if name.present?
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
return conversation if conversation
end
scope.find_by!(identifier: call_sid)
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
e164 = "+#{digits}"
channel = Channel::Voice.find_by!(phone_number: e164)
channel = Channel::TwilioSms.find_by!(phone_number: e164)
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{e164}" unless channel.voice_enabled?
@inbox = channel.inbox
end
@@ -0,0 +1,41 @@
module Enterprise::Webhooks::WhatsappEventsJob
def handle_message_events(channel, params)
if call_event?(params)
handle_call_events(channel, params)
return
end
if call_permission_reply?(params)
handle_call_permission_reply(channel, params)
return
end
super
end
private
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def call_permission_reply?(params)
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply'
end
def handle_call_events(channel, params)
Whatsapp::IncomingCallService.new(
inbox: channel.inbox,
params: extract_call_params(params)
).perform
end
def handle_call_permission_reply(channel, params)
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
end
def extract_call_params(params)
params.dig(:entry, 0, :changes, 0, :value) || {}
end
end
+87 -5
View File
@@ -18,15 +18,18 @@
# contact_id :bigint not null
# conversation_id :bigint not null
# inbox_id :bigint not null
# media_session_id :string
# message_id :bigint
# provider_call_id :string not null
#
# Indexes
#
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
# index_calls_on_message_id (message_id)
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
# index_calls_on_accepted_by_agent_id_and_status (accepted_by_agent_id,status)
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
# index_calls_on_media_session_id (media_session_id) UNIQUE
# index_calls_on_message_id (message_id)
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
# All valid call statuses
@@ -34,6 +37,10 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
META_ACCESSORS = %i[conference_sid recording_sid parent_call_sid initiated_at ended_at].freeze
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -41,7 +48,7 @@ class Call < ApplicationRecord
belongs_to :inbox
belongs_to :conversation
belongs_to :contact
belongs_to :message, optional: true
belongs_to :message, optional: true, inverse_of: :call
belongs_to :accepted_by_agent, class_name: 'User', optional: true
has_one_attached :recording
@@ -52,4 +59,79 @@ class Call < ApplicationRecord
validates :status, presence: true, inclusion: { in: STATUSES }
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :active_for_agent, ->(agent_id) { active.where(accepted_by_agent_id: agent_id) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
META_ACCESSORS.each do |key|
define_method(key) { (meta || {})[key.to_s] }
define_method("#{key}=") { |value| self.meta = (meta || {}).merge(key.to_s => value) }
end
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its
# public srflx candidate. Configurable via VOICE_CALL_STUN_URLS (comma-
# separated). TURN can be added by appending turn:user@host?credential=...
# entries to the same env var.
def self.default_ice_servers
urls = ENV.fetch('VOICE_CALL_STUN_URLS', 'stun:stun.l.google.com:19302').split(',').map(&:strip).reject(&:blank?)
[{ urls: urls }]
end
def display_direction
DISPLAY_DIRECTION[direction]
end
alias direction_label display_direction
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
def display_status
status.to_s.tr('_', '-')
end
def from_number
incoming? ? contact.phone_number : inbox.channel&.phone_number
end
def to_number
incoming? ? inbox.channel&.phone_number : contact.phone_number
end
def recording_url
return nil unless recording.attached?
Rails.application.routes.url_helpers.rails_blob_url(recording)
end
def push_event_data
{
id: id,
provider_call_id: provider_call_id,
provider: provider,
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
to_number: to_number,
recording_url: recording_url,
transcript: transcript
}
end
end
-122
View File
@@ -1,122 +0,0 @@
# == Schema Information
#
# Table name: channel_voice
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# phone_number :string not null
# provider :string default("twilio"), not null
# provider_config :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_voice_on_account_id (account_id)
# index_channel_voice_on_phone_number (phone_number) UNIQUE
#
class Channel::Voice < ApplicationRecord
include Channelable
self.table_name = 'channel_voice'
validates :phone_number, presence: true, uniqueness: true
validates :provider, presence: true
validates :provider_config, presence: true
# Validate phone number format (E.164 format)
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
# Provider-specific configs stored in JSON
validate :validate_provider_config
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
def name
"Voice (#{phone_number})"
end
def messaging_window_enabled?
false
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
case provider
when 'twilio'
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
else
raise "Unsupported voice provider: #{provider}"
end
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
def twilio?
provider == 'twilio'
end
def validate_provider_config
return if provider_config.blank?
case provider
when 'twilio'
validate_twilio_config
end
end
def validate_twilio_config
config = provider_config.with_indifferent_access
# Require credentials and provisioned TwiML App SID
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
def provider_config_hash
if provider_config.is_a?(Hash)
provider_config
else
JSON.parse(provider_config.to_s)
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
app_sid = service.perform
return if app_sid.blank?
cfg = provider_config.with_indifferent_access
cfg[:twiml_app_sid] = app_sid
self.provider_config = cfg
rescue StandardError => e
error_details = {
error_class: e.class.to_s,
message: e.message,
phone_number: phone_number,
account_id: account_id,
backtrace: e.backtrace&.first(5)
}
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
public :provider_config_hash
end
@@ -0,0 +1,87 @@
module Enterprise::Channel::TwilioSms
extend ActiveSupport::Concern
def self.prepended(base)
base.class_eval do
encrypts :api_key_secret if Chatwoot.encryption_configured?
validate :voice_requires_phone_number, if: :voice_enabled?
before_validation :provision_twiml_app, on: :create, if: :voice_enabled?
before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true?
after_commit :teardown_voice, on: :update, if: :voice_disabled?
end
end
def voice_enabled?
voice_enabled
end
def initiate_call(to:, conference_sid: nil, agent_id: nil)
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
to: to,
conference_sid: conference_sid,
agent_id: agent_id
)
end
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
def voice_status_webhook_url
digits = phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
end
private
# Override: when api_key_secret is stored separately (voice channels),
# use it instead of auth_token for API key authentication.
# Existing SMS channels store the secret in auth_token — that path is unchanged via super.
def client
if api_key_sid.present? && api_key_secret.present?
Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid)
else
super
end
end
def voice_requires_phone_number
return if phone_number.present?
errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID')
end
def voice_enabled_changed_to_true?
voice_enabled? && voice_enabled_changed?
end
def voice_disabled?
!voice_enabled? && voice_enabled_previously_changed?
end
def teardown_voice
Twilio::VoiceTeardownService.new(channel: self).perform
end
def provision_twiml_app
return if twiml_app_sid.present?
return if phone_number.blank?
validate_voice_capability!
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
self.twiml_app_sid = service.perform
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}")
errors.add(:base, "Twilio voice setup failed: #{e.message}")
end
def validate_voice_capability!
number = client.incoming_phone_numbers.list(phone_number: phone_number).first
raise 'Phone number not found in Twilio account' unless number
raise 'This phone number does not support voice calls' unless number.capabilities['voice']
end
alias provision_twiml_app_on_update provision_twiml_app
end
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_many :calls, dependent: :destroy_async
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
@@ -25,17 +25,6 @@ module Enterprise::Conversation
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
attrs_change = previous_changes['additional_attributes']
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)
@@ -1,4 +1,12 @@
module Enterprise::Message
def self.prepended(base)
base.class_eval do
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
end
end
private
def mark_pending_conversation_as_open_for_human_response
@@ -1,9 +1,9 @@
module Enterprise::Contacts::ContactableInboxesService
private
# Extend base selection to include Voice inboxes
# Extend base selection to include voice-enabled TwilioSms inboxes
def get_contactable_inbox(inbox)
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
super
end
@@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
# Whisper API rejects files larger than 25 MB. At Opus 48 kbps that is ~70
# minutes of audio — longer recordings keep the audio attachment but skip
# transcription rather than failing with an OpenAI 413.
WHISPER_BYTE_LIMIT = 25.megabytes
attr_reader :attachment, :message, :account
@@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def perform
return { error: 'Transcription limit exceeded' } unless can_transcribe?
return { error: 'Message not found' } if message.blank?
return { error: 'Audio too large for Whisper' } if audio_too_large?
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
@@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
account.usage_limits[:captain][:responses][:current_available].positive?
end
def audio_too_large?
blob = attachment.file&.blob
return false unless blob
blob.byte_size > WHISPER_BYTE_LIMIT
end
def fetch_audio_file
blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
@@ -63,11 +75,17 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
# temperature: 0.0 minimises Whisper's hallucinations on ambiguous or
# low-amplitude segments. The previous value (0.4) triggered spiraling
# repetitions like "Oh, dear. Oh, dear. Oh, dear." on silences and
# "No. No. No." / "Hello. Hello. Hello." on near-silent audio —
# well-documented Whisper behaviour at non-zero temperatures. 0.0
# matches OpenAI's own default recommendation.
response = @client.audio.transcribe(
parameters: {
model: WHISPER_MODEL,
file: file,
temperature: 0.4
temperature: 0.0
}
)
transcribed_text = response['text']
@@ -0,0 +1,41 @@
class Twilio::VoiceTeardownService
pattr_initialize [:channel!]
def perform
delete_twiml_app if channel.twiml_app_sid.present?
clear_number_webhooks
ensure
clear_voice_credentials
end
private
def delete_twiml_app
twilio_client.applications(channel.twiml_app_sid).delete
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_number_webhooks
numbers = twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
return if numbers.empty?
twilio_client
.incoming_phone_numbers(numbers.first.sid)
.update(voice_url: '', status_callback: '')
rescue StandardError => e
Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
end
def clear_voice_credentials
channel.update(twiml_app_sid: nil)
end
def twilio_client
@twilio_client ||= if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
else
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
end
end
@@ -17,8 +17,8 @@ class Twilio::VoiceWebhookSetupService
private
def validate_token_credentials!
# Only validate Account SID + Auth Token
token_client.incoming_phone_numbers.list(limit: 1)
validation_client = channel.api_key_sid.present? ? api_key_client : token_client
validation_client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
@@ -58,17 +58,11 @@ class Twilio::VoiceWebhookSetupService
end
def api_key_client
@api_key_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
end
@api_key_client ||= ::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
end
def token_client
@token_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
end
@token_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
def log_twilio_error(context, error)
@@ -80,11 +74,10 @@ class Twilio::VoiceWebhookSetupService
end
def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{
context: context,
phone_number: channel.phone_number,
account_sid: cfg[:account_sid],
account_sid: channel.account_sid,
error_class: error.class.to_s,
message: error.message
}
@@ -1,90 +1,119 @@
class Voice::CallMessageBuilder
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
new(
conversation: conversation,
direction: direction,
payload: payload,
user: user,
timestamps: timestamps
).perform!
# Maps Call model statuses to voice_call display statuses (hyphenated for frontend)
CALL_TO_VOICE_STATUS = {
'ringing' => 'ringing',
'in_progress' => 'in-progress',
'failed' => 'failed',
'no_answer' => 'no-answer',
'completed' => 'completed'
}.freeze
def self.create!(conversation:, call:, user: nil)
new(conversation: conversation, call: call, user: user).create!
end
def initialize(conversation:, direction:, payload:, user:, timestamps:)
# Backwards-compatible entry used by Voice::OutboundCallBuilder (Twilio).
def self.perform!(call:)
create!(conversation: call.conversation, call: call, user: call.accepted_by_agent)
end
def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil)
new(conversation: call.conversation, call: call).update_status!(
status: status, agent: agent, duration_seconds: duration_seconds
)
end
def initialize(conversation:, call:, user: nil)
@conversation = conversation
@direction = direction
@payload = payload
@call = call
@user = user
@timestamps = timestamps
end
def perform!
validate_sender!
message = latest_message
message ? update_message!(message) : create_message!
end
private
attr_reader :conversation, :direction, :payload, :user, :timestamps
def latest_message
conversation.messages.voice_calls.order(created_at: :desc).first
end
def update_message!(message)
message.update!(
def create!
# Direct create rather than Messages::MessageBuilder because the latter
# rejects `incoming` messages on non-Api inboxes — that gate is meant for
# human messages flowing through chatbot-style integrations, but a voice
# call bubble is a system event that needs to live on the WhatsApp inbox.
Message.create!(
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
content: message_content,
message_type: message_type,
content_attributes: { 'data' => base_payload },
content_type: 'voice_call',
content_attributes: { 'data' => build_data_payload },
sender: sender
)
end
def create_message!
params = {
content: 'Voice Call',
message_type: message_type,
content_type: 'voice_call',
content_attributes: { 'data' => base_payload }
}
Messages::MessageBuilder.new(sender, conversation, params).perform
def update_status!(status:, agent: nil, duration_seconds: nil)
message = call.message
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = map_status(status) if status
data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent
data['data']['duration_seconds'] = duration_seconds if duration_seconds
message.update!(content_attributes: data)
message
end
def base_payload
@base_payload ||= begin
data = payload.slice(
:call_sid,
:status,
:call_direction,
:conference_sid,
:from_number,
:to_number
).stringify_keys
data['call_direction'] = direction
data['meta'] = {
'created_at' => timestamps[:created_at] || current_timestamp,
'ringing_at' => timestamps[:ringing_at] || current_timestamp
}.compact
data
end
private
attr_reader :conversation, :call, :user
# `call_source` lets the FE disambiguate WhatsApp vs Twilio for UI copy
# and event routing without loading the whole Call record client-side.
# `media_server_enabled` is hard-coded to false because WhatsApp now runs
# browser↔Meta WebRTC directly (no media server) and Twilio's bubble
# never gates on this flag. Rejoin is unsupported across the board.
def build_data_payload
{
'call_sid' => call.provider_call_id,
'status' => map_status(call.status),
'call_direction' => call.direction_label,
'call_source' => call.provider,
'call_id' => call.id,
'from_number' => from_number,
'to_number' => to_number,
'media_server_enabled' => false,
'meta' => { 'created_at' => Time.zone.now.to_i }
}
end
def message_content
call.twilio? ? 'Voice Call' : 'WhatsApp Call'
end
def message_type
direction == 'outbound' ? 'outgoing' : 'incoming'
call.outgoing? ? 'outgoing' : 'incoming'
end
def sender
return user if direction == 'outbound'
return user if call.outgoing? && user
conversation.contact
end
def validate_sender!
return unless direction == 'outbound'
raise ArgumentError, 'Agent sender required for outbound calls' unless user
def from_number
if call.incoming?
conversation.contact&.phone_number
else
conversation.inbox.channel&.phone_number
end
end
def current_timestamp
@current_timestamp ||= Time.zone.now.to_i
def to_number
if call.incoming?
conversation.inbox.channel&.phone_number
else
conversation.contact&.phone_number
end
end
def map_status(status)
CALL_TO_VOICE_STATUS[status] || status
end
end
@@ -0,0 +1,89 @@
class Voice::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
# WhatsApp accept: forward the browser-built SDP answer to Meta. Twilio
# voice does not flow through this service.
def accept
raise ArgumentError, "Unsupported provider: #{call.provider}" unless call.whatsapp?
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
call.with_lock { transition_to_in_progress! }
Voice::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: agent)
update_conversation_call_status('in-progress')
broadcast(:accepted, accepted_by_agent_id: agent.id)
call
end
def reject
call.reload
return call if call.terminal? || call.in_progress?
invoke_provider(:reject_call)
finalize_call('failed')
call
end
def terminate
return call if call.terminal?
invoke_provider(:terminate_call)
finalize_call('completed')
call
end
private
def transition_to_in_progress!
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
svc = call.inbox.channel.provider_service
raise Voice::CallErrors::CallFailed, 'Meta pre_accept failed' unless svc.pre_accept_call(call.provider_call_id, sdp_answer)
raise Voice::CallErrors::CallFailed, 'Meta accept failed' unless svc.accept_call(call.provider_call_id, sdp_answer)
end
# Auto-assignment on accept: take ownership of the conversation if it has
# no assignee. If someone else already holds it, leave it (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
def invoke_provider(method)
return unless call.whatsapp?
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id)
Rails.logger.error "[VOICE CALL] #{method} returned false for #{call.provider_call_id}" unless success
rescue StandardError => e
Rails.logger.error "[VOICE CALL] #{method} failed: #{e.message}"
end
def finalize_call(status)
call.update!(status: status)
Voice::CallMessageBuilder.update_status!(call: call, status: status)
update_conversation_call_status(status)
broadcast(:ended, status: status)
end
def update_conversation_call_status(mapped_status)
conversation = call.conversation
conversation.update!(
additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => mapped_status)
)
end
def broadcast(event, extra = {})
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -1,94 +1,17 @@
class Voice::CallSessionSyncService
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
@conversation = conversation
@call_sid = call_sid
@message_call_sid = message_call_sid || call_sid
@from_number = leg[:from_number]
@to_number = leg[:to_number]
@direction = leg[:direction]
end
pattr_initialize [:call!, { parent_call_sid: nil }]
def perform
ActiveRecord::Base.transaction do
attrs = refreshed_attributes
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
sync_voice_call_message!(attrs)
end
conversation
record_parent_call_sid!
call
end
private
def refreshed_attributes
attrs = (conversation.additional_attributes || {}).dup
attrs['call_direction'] = direction
attrs['call_status'] ||= 'ringing'
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
attrs['meta'] ||= {}
attrs['meta']['initiated_at'] ||= current_timestamp
attrs
end
def record_parent_call_sid!
return if parent_call_sid.blank?
return if call.parent_call_sid == parent_call_sid
def sync_voice_call_message!(attrs)
Voice::CallMessageBuilder.perform!(
conversation: conversation,
direction: direction,
payload: {
call_sid: message_call_sid,
status: attrs['call_status'],
conference_sid: attrs['conference_sid'],
from_number: origin_number_for(direction),
to_number: target_number_for(direction)
},
user: agent_for(attrs),
timestamps: {
created_at: attrs.dig('meta', 'initiated_at'),
ringing_at: attrs.dig('meta', 'ringing_at')
}
)
end
def origin_number_for(current_direction)
return outbound_origin if current_direction == 'outbound'
from_number.presence || inbox_number
end
def target_number_for(current_direction)
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
to_number || conversation.contact&.phone_number
end
def agent_for(attrs)
agent_id = attrs['agent_id']
return nil unless agent_id
agent = conversation.account.users.find_by(id: agent_id)
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
agent
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
end
def outbound_origin
inbox_number || from_number
end
def inbox_number
conversation.inbox&.channel&.phone_number
call.update!(parent_call_sid: parent_call_sid)
end
end
@@ -1,66 +1,45 @@
class Voice::CallStatus::Manager
pattr_initialize [:conversation!, :call_sid]
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
pattr_initialize [:call!]
def process_status_update(status, duration: nil, timestamp: nil)
return unless ALLOWED_STATUSES.include?(status)
return unless Call::STATUSES.include?(status)
return if call.status == status
current_status = conversation.additional_attributes&.dig('call_status')
return if current_status == status
apply_status(status, duration: duration, timestamp: timestamp)
update_message(status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
# Touch the linked message so the normal message.updated dispatcher
# re-broadcasts it with the fresh Call embedded via push_event_data.
call.message&.touch
end
private
def apply_status(status, duration:, timestamp:)
attrs = (conversation.additional_attributes || {}).dup
attrs['call_status'] = status
def apply_call_updates!(status, duration:, timestamp:)
attrs = { status: status }
ts = timestamp || now_seconds
if status == 'in-progress'
attrs['call_started_at'] ||= timestamp || now_seconds
elsif TERMINAL_STATUSES.include?(status)
attrs['call_ended_at'] = timestamp || now_seconds
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
if status == 'in_progress'
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
started_at = Time.zone.at(ts)
attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
elsif Call::TERMINAL_STATUSES.include?(status)
call.ended_at = ts
attrs[:meta] = call.meta
attrs[:duration_seconds] = resolved_duration(duration, ts)
end
conversation.update!(
additional_attributes: attrs,
last_activity_at: current_time
)
call.update!(attrs)
end
def resolved_duration(attrs, provided_duration, timestamp)
def resolved_duration(provided_duration, timestamp)
return provided_duration if provided_duration
return unless call.started_at
started_at = attrs['call_started_at']
return unless started_at && timestamp
[timestamp - started_at.to_i, 0].max
end
def update_message(status)
message = conversation.messages
.where(content_type: 'voice_call')
.order(created_at: :desc)
.first
return unless message
data = (message.content_attributes || {}).dup
data['data'] ||= {}
data['data']['status'] = status
message.update!(content_attributes: data)
[timestamp - call.started_at.to_i, 0].max
end
def now_seconds
current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
Time.zone.now.to_i
end
end
@@ -1,71 +1,71 @@
class Voice::Conference::Manager
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
pattr_initialize [:call!, :event!, :participant_label]
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
def process
case event
when 'start'
ensure_conference_sid!
mark_ringing!
when 'join'
mark_in_progress! if agent_participant?
join_agent! if agent_participant?
when 'leave'
handle_leave!
when 'end'
finalize_conference!
finalize!
end
end
private
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
)
end
def ensure_conference_sid!
attrs = conversation.additional_attributes || {}
return if attrs['conference_sid'].present?
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
conversation.update!(additional_attributes: attrs)
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
end
def mark_ringing!
return if current_status
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
return unless call.status == 'ringing'
status_manager.process_status_update('ringing')
end
def mark_in_progress!
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
def join_agent!
user_id = extract_user_id
call.update!(accepted_by_agent_id: user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
def extract_user_id
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
return unless match
return unless match[2].to_i == call.account_id
match[1].to_i
end
def handle_leave!
case current_status
case call.status
when 'ringing'
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
when 'in-progress'
status_manager.process_status_update('completed', timestamp: current_timestamp)
status_manager.process_status_update('no_answer', timestamp: now)
when 'in_progress'
status_manager.process_status_update('completed', timestamp: now)
end
end
def finalize_conference!
return if %w[completed no-answer failed].include?(current_status)
def finalize!
return if Call::TERMINAL_STATUSES.include?(call.status)
status_manager.process_status_update('completed', timestamp: current_timestamp)
end
def current_status
conversation.additional_attributes&.dig('call_status')
status_manager.process_status_update('completed', timestamp: now)
end
def agent_participant?
participant_label.to_s.start_with?('agent')
participant_label.to_s.start_with?('agent-')
end
def current_timestamp
def now
Time.zone.now.to_i
end
end
@@ -1,5 +1,5 @@
module Voice::Conference::Name
def self.for(conversation)
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
def self.for(call)
"conf_account_#{call.account_id}_call_#{call.id}"
end
end
@@ -1,33 +1,50 @@
class Voice::InboundCallBuilder
attr_reader :account, :inbox, :from_number, :call_sid
attr_reader :account, :inbox, :from_number, :call_sid, :provider, :extra_meta
def self.perform!(account:, inbox:, from_number:, call_sid:)
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
# `provider` defaults to :twilio for backward compatibility with the original
# Twilio-only call sites; WhatsApp pass :whatsapp + extra_meta carrying the
# SDP offer + ICE servers.
# rubocop:disable Metrics/ParameterLists
def self.perform!(account:, inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid,
provider: provider, extra_meta: extra_meta).perform!
end
def initialize(account:, inbox:, from_number:, call_sid:)
def initialize(account:, inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
@account = account
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
end
# rubocop:enable Metrics/ParameterLists
def perform!
timestamp = current_timestamp
existing = find_existing_call
return existing if existing
ActiveRecord::Base.transaction do
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = find_conversation || create_conversation!(contact, contact_inbox)
conversation.reload
update_conversation!(conversation, timestamp)
build_voice_message!(conversation, timestamp)
conversation
conversation = resolve_conversation!(contact, contact_inbox)
call = create_call!(contact, conversation)
message = Voice::CallMessageBuilder.perform!(call: call)
call.update!(message_id: message.id)
call
end
rescue ActiveRecord::RecordNotUnique
# A concurrent provider retry won the create race; return what now exists.
find_existing_call || raise
end
private
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: provider, provider_call_id: call_sid)
end
def ensure_contact!
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
record.name = from_number if record.name.blank?
@@ -43,57 +60,38 @@ class Voice::InboundCallBuilder
end
end
def find_conversation
return if call_sid.blank?
def resolve_conversation!(contact, contact_inbox)
if inbox.lock_to_single_conversation
reusable = account.conversations
.where(contact_id: contact.id, inbox_id: inbox.id)
.where.not(status: :resolved)
.order(last_activity_at: :desc)
.first
return reusable if reusable
end
account.conversations.includes(:contact).find_by(identifier: call_sid)
end
def create_conversation!(contact, contact_inbox)
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: :open,
identifier: call_sid
status: :open
)
end
def update_conversation!(conversation, timestamp)
attrs = {
'call_direction' => 'inbound',
'call_status' => 'ringing',
'conference_sid' => Voice::Conference::Name.for(conversation),
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(contact, conversation)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'inbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conversation.additional_attributes['conference_sid'],
from_number: from_number,
to_number: inbox.channel&.phone_number
},
timestamps: { created_at: timestamp, ringing_at: timestamp }
contact: contact,
provider: provider,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
call.update!(conference_sid: Voice::Conference::Name.for(call)) if call.twilio?
call
end
end
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
raise ArgumentError, 'Agent required' if user.blank?
timestamp = current_timestamp
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
conversation = create_conversation!(contact_inbox)
conversation.reload
conference_sid = Voice::Conference::Name.for(conversation)
call_sid = initiate_call!
update_conversation!(conversation, call_sid, conference_sid, timestamp)
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
{ conversation: conversation, call_sid: call_sid }
call = create_call!(conversation, call_sid)
message = Voice::CallMessageBuilder.perform!(call: call)
call.update!(message_id: message.id)
call
end
end
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
end
def initiate_call!
inbox.channel.initiate_call(
to: contact.phone_number
)[:call_sid]
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
end
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
attrs = {
'call_direction' => 'outbound',
'call_status' => 'ringing',
'agent_id' => user.id,
'conference_sid' => conference_sid,
'meta' => { 'initiated_at' => timestamp }
}
conversation.update!(
identifier: call_sid,
additional_attributes: attrs,
last_activity_at: current_time
)
end
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
Voice::CallMessageBuilder.perform!(
def create_call!(conversation, call_sid)
call = Call.create!(
account: account,
inbox: inbox,
conversation: conversation,
direction: 'outbound',
payload: {
call_sid: call_sid,
status: 'ringing',
conference_sid: conference_sid,
from_number: inbox.channel&.phone_number,
to_number: contact.phone_number
},
user: user,
timestamps: { created_at: timestamp, ringing_at: timestamp }
contact: contact,
accepted_by_agent: user,
provider: :twilio,
direction: :outgoing,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }
)
end
def current_timestamp
@current_timestamp ||= current_time.to_i
end
def current_time
@current_time ||= Time.zone.now
call.update!(conference_sid: Voice::Conference::Name.for(call))
call
end
end
@@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter
end
def twilio_client
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
end
def config
@config ||= @channel.provider_config_hash
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token)
end
end
@@ -1,46 +1,36 @@
class Voice::Provider::Twilio::ConferenceService
pattr_initialize [:conversation!, { twilio_client: nil }]
pattr_initialize [:call!, { twilio_client: nil }]
def ensure_conference_sid
existing = conversation.additional_attributes&.dig('conference_sid')
return existing if existing.present?
return call.conference_sid if call.conference_sid.present?
sid = Voice::Conference::Name.for(conversation)
merge_attributes('conference_sid' => sid)
sid
call.update!(conference_sid: Voice::Conference::Name.for(call))
call.conference_sid
end
def mark_agent_joined(user:)
merge_attributes(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => { id: user.id, name: user.name }
)
call.update!(accepted_by_agent: user)
end
def end_conference
return if call.conference_sid.blank?
twilio_client
.conferences
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
end
private
def merge_attributes(attrs)
current = conversation.additional_attributes || {}
conversation.update!(additional_attributes: current.merge(attrs))
end
def twilio_client
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
end
def account_sid
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
end
def auth_token
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
@twilio_client ||= begin
channel = call.inbox.channel
if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
else
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
end
end
end
end
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
token: access_token.to_jwt,
identity: identity,
voice_enabled: true,
account_sid: config['account_sid'],
account_sid: channel.account_sid,
agent_id: user.id,
account_id: account.id,
inbox_id: inbox.id,
phone_number: inbox.channel.phone_number,
phone_number: channel.phone_number,
twiml_endpoint: twiml_url,
has_twiml_app: config['twiml_app_sid'].present?
has_twiml_app: channel.twiml_app_sid.present?
}
end
private
def config
@config ||= inbox.channel.provider_config_hash || {}
def channel
@channel ||= inbox.channel
end
def identity
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
def access_token
Twilio::JWT::AccessToken.new(
config['account_sid'],
config['api_key_sid'],
config['api_key_secret'],
channel.account_sid,
channel.api_key_sid,
channel.api_key_secret,
identity: identity,
ttl: 1.hour.to_i
).tap { |token| token.add_grant(voice_grant) }
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = config['twiml_app_sid']
grant.outgoing_application_sid = channel.twiml_app_sid
grant.outgoing_application_params = outgoing_params
end
end
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
agent_id: user.id,
identity: identity,
client_name: identity,
accountSid: config['account_sid'],
accountSid: channel.account_sid,
is_agent: 'true'
}
end
def twiml_url
digits = inbox.channel.phone_number.delete_prefix('+')
digits = channel.phone_number.delete_prefix('+')
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
end
end
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
'queued' => 'ringing',
'initiated' => 'ringing',
'ringing' => 'ringing',
'in-progress' => 'in-progress',
'inprogress' => 'in-progress',
'answered' => 'in-progress',
'in-progress' => 'in_progress',
'inprogress' => 'in_progress',
'answered' => 'in_progress',
'completed' => 'completed',
'busy' => 'no-answer',
'no-answer' => 'no-answer',
'busy' => 'no_answer',
'no-answer' => 'no_answer',
'failed' => 'failed',
'canceled' => 'failed'
}.freeze
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
normalized_status = normalize_status(call_status)
return if normalized_status.blank?
conversation = account.conversations.find_by(identifier: call_sid)
return unless conversation
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
return unless call
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: call_sid
).process_status_update(
Voice::CallStatus::Manager.new(call: call).process_status_update(
normalized_status,
duration: payload_duration,
timestamp: payload_timestamp
@@ -0,0 +1,61 @@
class Whatsapp::CallPermissionReplyService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.provider_config['calling_enabled']
reply_data = extract_reply_data
return unless reply_data&.dig(:accepted)
contact = find_contact(reply_data[:from_number])
return unless contact
conversation = find_active_conversation(contact)
return unless conversation
clear_permission_flag(conversation)
broadcast_permission_granted(contact, conversation)
end
private
def extract_reply_data
value = params.dig(:entry, 0, :changes, 0, :value)
message = value&.dig(:messages, 0)
reply = message&.dig(:interactive, :call_permission_reply)
return unless reply
accepted = reply[:response] == 'accept'
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
{ from_number: message[:from], accepted: accepted }
end
def find_contact(from_number)
inbox.contact_inboxes.joins(:contact)
.where(contacts: { phone_number: "+#{from_number}" })
.first&.contact
end
def find_active_conversation(contact)
inbox.conversations.where(contact: contact).where.not(status: :resolved).last
end
def clear_permission_flag(conversation)
attrs = conversation.additional_attributes || {}
attrs.delete('call_permission_requested_at')
conversation.update!(additional_attributes: attrs)
end
def broadcast_permission_granted(contact, conversation)
ActionCable.server.broadcast("account_#{inbox.account_id}", {
event: 'voice_call.permission_granted',
data: {
account_id: inbox.account_id,
conversation_id: conversation.id,
contact_name: contact.name,
contact_phone: contact.phone_number
}
})
end
end
@@ -0,0 +1,113 @@
class Whatsapp::IncomingCallService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.provider_config['calling_enabled']
Array(params[:calls]).each do |call_payload|
process_call_event(call_payload.with_indifferent_access)
end
end
private
def process_call_event(call_payload)
case call_payload[:event]
when 'connect' then handle_call_connect(call_payload)
when 'terminate' then handle_call_terminate(call_payload)
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{call_payload[:event]}"
end
end
def handle_call_connect(call_payload)
existing = Call.whatsapp.find_by(provider_call_id: call_payload[:id])
existing ? handle_outbound_connect(existing, call_payload) : handle_inbound_connect(call_payload)
rescue ActiveRecord::RecordNotUnique
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{call_payload[:id]}"
end
def handle_outbound_connect(call, call_payload)
return if call.in_progress?
sdp_answer = fix_sdp_setup(call_payload.dig(:session, :sdp))
call.update!(status: 'in_progress', started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
Voice::CallMessageBuilder.update_status!(call: call, status: 'in_progress', agent: call.accepted_by_agent)
update_conversation_call_status(call.conversation, 'in-progress', call.direction_label)
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end
# Inbound delegates to Voice::InboundCallBuilder (Twilio's path) for contact +
# conversation + call + message creation; auto-assignment falls out of the
# standard Conversation lifecycle. We just stash Meta's SDP offer in meta.
def handle_inbound_connect(call_payload)
sdp_offer = call_payload.dig(:session, :sdp)
call = Voice::InboundCallBuilder.perform!(
account: inbox.account, inbox: inbox,
from_number: "+#{call_payload[:from]}", call_sid: call_payload[:id],
provider: :whatsapp,
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
update_conversation_call_status(call.conversation, 'ringing', call.direction_label)
broadcast_incoming_call(call, sdp_offer)
end
def handle_call_terminate(call_payload)
call = Call.whatsapp.find_by(provider_call_id: call_payload[:id])
return unless call
duration = call_payload[:duration]&.to_i
final_status = answered?(call, duration) ? 'completed' : 'no_answer'
call.update!(status: final_status, duration_seconds: duration, end_reason: call_payload[:terminate_reason])
Voice::CallMessageBuilder.update_status!(call: call, status: final_status, agent: call.accepted_by_agent,
duration_seconds: duration)
mapped = Voice::CallMessageBuilder::CALL_TO_VOICE_STATUS[final_status] || final_status
update_conversation_call_status(call.conversation, mapped, call.direction_label)
broadcast(call, 'voice_call.ended', status: call.status, duration_seconds: call.duration_seconds)
end
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || call.accepted_by_agent_id.present?
end
def update_conversation_call_status(conversation, call_status, direction)
conversation.update!(
additional_attributes: (conversation.additional_attributes || {}).merge(
'call_status' => call_status, 'call_direction' => direction
)
)
end
# PLA-98: ring only the conversation's assignee when assigned, account-wide
# otherwise so any eligible agent can pick up.
def broadcast_incoming_call(call, sdp_offer)
contact = call.contact
data = base_payload(call).merge(
direction: call.direction_label, inbox_id: call.inbox_id,
sdp_offer: sdp_offer, ice_servers: Call.default_ice_servers,
caller: { name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
)
streams = call.conversation.assignee&.pubsub_token ? [call.conversation.assignee.pubsub_token] : ["account_#{inbox.account_id}"]
streams.each { |s| ActionCable.server.broadcast(s, event: 'voice_call.incoming', data: data) }
end
def broadcast(call, event, extra = {})
ActionCable.server.broadcast(
"account_#{inbox.account_id}",
event: event, data: base_payload(call).merge(extra)
)
end
def base_payload(call)
{
account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
provider: 'whatsapp', conversation_id: call.conversation_id
}
end
# Browsers always emit a=setup:active in answers, but Meta sometimes echoes
# actpass in its outbound answer; pin it to active so peers don't renegotiate.
def fix_sdp_setup(sdp)
sdp.present? ? sdp.gsub('a=setup:actpass', 'a=setup:active') : sdp
end
end
@@ -0,0 +1,85 @@
module Whatsapp::Providers::WhatsappCloudCallMethods
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
def accept_call(call_id, sdp_answer)
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
end
def reject_call(call_id)
call_api('reject_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'reject' })
end
def terminate_call(call_id)
call_api('terminate_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'terminate' })
end
def send_call_permission_request(to_phone_number, body_text = 'We would like to call you regarding your conversation.')
response = HTTParty.post(
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text).to_json
)
unless response.success?
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
return nil
end
response.parsed_response
end
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer).to_json
)
process_initiate_call_response(response)
end
private
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
body
end
def call_api(action_name, body)
url = "#{phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
response.success?
end
def permission_request_body(to_phone_number, body_text)
{
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
type: 'interactive',
interactive: {
type: 'call_permission_request',
action: { name: 'call_permission_request' },
body: { text: body_text }
}
}
end
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}
end
def process_initiate_call_response(response)
return response.parsed_response if response.success?
parsed = response.parsed_response
error_code = parsed&.dig('error', 'code')
error_msg = parsed&.dig('error', 'error_user_msg') || 'Failed to initiate call'
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == 138_006
raise StandardError, error_msg
end
end
+6
View File
@@ -0,0 +1,6 @@
module Voice::CallErrors
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
class CallFailed < StandardError; end
class NoCallPermission < StandardError; end
end
@@ -2,9 +2,9 @@ require 'rails_helper'
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'creates conference and sets identifier' do
it 'resolves the Call by call_sid and invokes the conference service' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(body['conference_sid']).to eq('CF123')
expect(body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
end
it 'does not allow accessing conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
it 'rejects the request when call_sid is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content)
expect(conference_service).not_to have_received(:ensure_conference_sid)
end
it 'does not allow accessing calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end
end
end
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'ends conference and returns success' do
it 'ends the conference for the resolved call' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
end
it 'does not allow ending conferences for conversations from inboxes without access' do
it 'does not allow ending conferences for calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id }
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end
@@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
it 'creates a voice inbox when administrator' do
account.enable_features('channel_voice')
account.save!
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
perform: "AP#{SecureRandom.hex(16)}"))
@@ -34,8 +39,7 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
api_key_secret: SecureRandom.hex(16) } } },
as: :json
expect(response).to have_http_status(:success)
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') }
let(:inbox) { channel.inbox }
let(:digits) { channel.phone_number.delete_prefix('+') }
@@ -19,15 +19,24 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
let(:to_number) { channel.phone_number }
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
instance_double(Voice::InboundCallBuilder)
conversation = create(:conversation, account: account, inbox: inbox)
contact = conversation.contact
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
call.update!(conference_sid: Voice::Conference::Name.for(call))
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
).and_return(conversation)
).and_return(call)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
@@ -39,60 +48,63 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>')
expect(response.body).to include('<Dial>')
expect(response.body).to include(call.conference_sid)
end
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: call_sid
)
call.update!(conference_sid: Voice::Conference::Name.for(call))
sync_double = instance_double(Voice::CallSessionSyncService, perform: call)
expect(Voice::CallSessionSyncService).to receive(:new).with(
hash_including(
conversation: conversation,
call_sid: call_sid,
message_call_sid: conversation.identifier,
leg: {
from_number: from_number,
to_number: to_number,
direction: 'outbound'
}
)
hash_including(call: call, parent_call_sid: nil)
).and_return(sync_double)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-api'
}
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>')
expect(response.body).to include(call.conference_sid)
end
it 'uses the parent call SID when syncing outbound-dial legs' do
parent_sid = 'CA_parent'
child_sid = 'CA_child'
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: parent_sid
)
call.update!(conference_sid: Voice::Conference::Name.for(call))
sync_double = instance_double(Voice::CallSessionSyncService, perform: call)
expect(Voice::CallSessionSyncService).to receive(:new).with(
hash_including(
conversation: conversation,
call_sid: child_sid,
message_call_sid: parent_sid,
leg: {
from_number: from_number,
to_number: to_number,
direction: 'outbound'
}
)
hash_including(call: call, parent_call_sid: parent_sid)
).and_return(sync_double)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => child_sid,
'ParentCallSid' => parent_sid,
'From' => from_number,
'To' => to_number,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-dial'
}
@@ -0,0 +1,104 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::TwilioSms do
let(:account) { create(:account) }
let(:twiml_app_sid) { 'AP1234567890abcdef' }
before do
allow_any_instance_of(described_class).to receive(:validate_voice_capability!) # rubocop:disable RSpec/AnyInstance
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
end
describe 'factory' do
it 'has a valid :with_voice factory' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel).to be_valid
expect(channel.voice_enabled?).to be true
end
end
describe 'validations' do
it 'requires a phone number when voice is enabled' do
channel = build(:channel_twilio_sms, :with_voice, account: account, phone_number: nil)
channel.valid?
expect(channel.errors[:base]).to include('Voice calling requires a phone number and cannot be used with messaging service SID')
end
end
describe '#voice_enabled?' do
it 'returns true when voice_enabled is set' do
channel = create(:channel_twilio_sms, :with_voice, account: account)
expect(channel.voice_enabled?).to be true
end
it 'returns false by default' do
channel = create(:channel_twilio_sms, account: account)
expect(channel.voice_enabled?).to be false
end
end
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
digits = channel.phone_number.delete_prefix('+')
expect(channel.voice_call_webhook_url).to include(digits)
end
end
describe '#voice_status_webhook_url' do
it 'returns the status webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
digits = channel.phone_number.delete_prefix('+')
expect(channel.voice_status_webhook_url).to include(digits)
end
end
describe 'provisioning on create' do
it 'stores twiml_app_sid from the webhook setup service' do
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
channel = create(:channel_twilio_sms, :with_voice, twiml_app_sid: nil)
expect(channel.twiml_app_sid).to eq(twiml_app_sid)
end
end
describe 'teardown on disable' do
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:app_context) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:applications).with(channel.twiml_app_sid).and_return(app_context)
allow(app_context).to receive(:delete)
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
allow(numbers_list).to receive(:list).with(phone_number: channel.phone_number).and_return([])
end
it 'deletes the TwiML app and clears twiml_app_sid' do
original_twiml_sid = channel.twiml_app_sid
channel.update!(voice_enabled: false)
expect(twilio_client).to have_received(:applications).with(original_twiml_sid)
expect(app_context).to have_received(:delete)
expect(channel.reload.twiml_app_sid).to be_nil
end
it 'preserves api_key_sid and api_key_secret' do
channel.update!(voice_enabled: false)
expect(channel.reload.api_key_sid).to be_present
expect(channel.reload.api_key_secret).to be_present
end
it 'does not fail if Twilio API errors' do
allow(app_context).to receive(:delete).and_raise(StandardError.new('Not found'))
expect { channel.update!(voice_enabled: false) }.not_to raise_error
expect(channel.reload.twiml_app_sid).to be_nil
end
end
end
@@ -1,79 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Voice do
let(:twiml_app_sid) { 'AP1234567890abcdef' }
let(:channel) { create(:channel_voice) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
end
it 'has a valid factory' do
expect(channel).to be_valid
end
describe 'validations' do
it 'validates presence of provider_config' do
channel.provider_config = nil
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include("can't be blank")
end
it 'validates presence of account_sid in provider_config' do
channel.provider_config = { auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
end
it 'validates presence of auth_token in provider_config' do
channel.provider_config = { account_sid: 'sid' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
end
it 'validates presence of api_key_sid in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
end
it 'validates presence of api_key_secret in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
end
it 'validates presence of twiml_app_sid in provider_config' do
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
expect(channel).not_to be_valid
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
end
it 'is valid with all required provider_config fields' do
channel.provider_config = {
account_sid: 'test_sid',
auth_token: 'test_token',
api_key_sid: 'test_key',
api_key_secret: 'test_secret',
twiml_app_sid: 'test_app_sid'
}
expect(channel).to be_valid
end
end
describe '#name' do
it 'returns Voice with phone number' do
expect(channel.name).to include('Voice')
expect(channel.name).to include(channel.phone_number)
end
end
describe 'provisioning on create' do
it 'stores twiml_app_sid in provider_config' do
ch = create(:channel_voice)
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
end
end
end
@@ -9,14 +9,16 @@ RSpec.describe Twilio::VoiceWebhookSetupService do
let(:api_key_secret) { 'api_key_secret_123' }
let(:phone_number) { '+15551230001' }
let(:frontend_url) { 'https://app.chatwoot.test' }
let(:account) { create(:account) }
let(:channel) do
build(:channel_voice, phone_number: phone_number, provider_config: {
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret
})
build(:channel_twilio_sms, :with_voice,
account: account,
phone_number: phone_number,
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret)
end
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
@@ -4,10 +4,9 @@ require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
let(:call_sid) { 'CA1234567890abcdef' }
before do
@@ -24,98 +23,88 @@ RSpec.describe Voice::InboundCallBuilder do
)
end
context 'when no existing conversation matches call_sid' do
it 'creates a new inbound conversation with ringing status' do
conversation = nil
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
context 'when no existing call matches call_sid' do
it 'creates a new conversation and Call with ringing status' do
call = nil
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
attrs = conversation.additional_attributes
expect(conversation.identifier).to eq(call_sid)
expect(attrs['call_direction']).to eq('inbound')
expect(attrs['call_status']).to eq('ringing')
expect(attrs['conference_sid']).to be_present
expect(attrs.dig('meta', 'initiated_at')).to be_present
expect(conversation.contact.phone_number).to eq(from_number)
aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.provider).to eq('twilio')
expect(call.direction).to eq('incoming')
expect(call.status).to eq('ringing')
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.meta['initiated_at']).to be_present
expect(call.contact.phone_number).to eq(from_number)
end
end
it 'creates a single voice_call message marked as incoming' do
conversation = perform_builder
voice_message = conversation.messages.voice_calls.last
it 'creates a voice_call message matched to the call and linked via message_id' do
call = perform_builder
voice_message = call.conversation.messages.voice_calls.last
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
data = voice_message.content_attributes['data']
expect(data).to include(
'call_sid' => call_sid,
'status' => 'ringing',
'call_direction' => 'inbound',
'conference_sid' => conversation.additional_attributes['conference_sid'],
'from_number' => from_number,
'to_number' => inbox.channel.phone_number
)
expect(data['meta']['created_at']).to be_present
expect(data['meta']['ringing_at']).to be_present
aggregate_failures do
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.call).to eq(call)
end
end
it 'sets the contact name to the phone number for new callers' do
conversation = perform_builder
call = perform_builder
expect(conversation.contact.name).to eq(from_number)
expect(call.contact.name).to eq(from_number)
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = perform_builder
conversation = call.conversation
perform_builder
expect(conversation.identifier).to be_nil
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
end
end
context 'when a conversation already exists for the call_sid' do
let(:contact) { create(:contact, account: account, phone_number: from_number) }
context 'when a Call already exists for the call_sid' do
let(:existing_call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: call_sid
)
end
it 'returns the existing call without creating a duplicate' do
existing_call
expect { perform_builder }.not_to change(Call, :count)
expect(perform_builder).to eq(existing_call)
end
end
context 'when the inbox has lock_to_single_conversation enabled' do
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_conversation) do
create(
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
)
end
let(:existing_message) do
create(
:message,
account: account,
inbox: inbox,
conversation: existing_conversation,
message_type: :incoming,
content_type: :voice_call,
sender: contact,
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
)
let!(:existing_open_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
end
it 'reuses the conversation without creating a duplicate' do
existing_message
expect { perform_builder }.not_to change(account.conversations, :count)
existing_conversation.reload
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
before { inbox.update!(lock_to_single_conversation: true) }
it 'reuses the most recent non-resolved conversation' do
call = nil
expect { call = perform_builder }.not_to change(account.conversations, :count)
expect(call.conversation).to eq(existing_open_conversation)
end
it 'updates the existing voice call message instead of creating a new one' do
existing_message
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
updated_message = existing_conversation.reload.messages.voice_calls.last
data = updated_message.content_attributes['data']
expect(data['status']).to eq('ringing')
expect(data['call_direction']).to eq('inbound')
it 'still creates a new Call and voice_call message on the reused conversation' do
expect { perform_builder }.to change(Call, :count).by(1)
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
end
end
end
@@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
allow(inbox).to receive(:channel).and_return(channel)
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
allow(Voice::Conference::Name).to receive(:for).and_call_original
end
describe '.perform!' do
it 'creates a conversation and voice call message' do
conversation_count = account.conversations.count
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
it 'creates a conversation, Call, and voice_call message' do
call = nil
expect do
call = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
result = described_class.perform!(
aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.direction).to eq('outgoing')
expect(call.status).to eq('ringing')
expect(call.accepted_by_agent_id).to eq(user.id)
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
voice_message = call.conversation.messages.voice_calls.last
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.message_type).to eq('outgoing')
expect(voice_message.call).to eq(call)
end
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
expect(account.conversations.count).to eq(conversation_count + 1)
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
conversation = result[:conversation].reload
attrs = conversation.additional_attributes
aggregate_failures do
expect(result[:call_sid]).to eq(call_sid)
expect(conversation.identifier).to eq(call_sid)
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
expect(attrs['agent_id']).to eq(user.id)
expect(attrs['conference_sid']).to be_present
voice_message = conversation.messages.voice_calls.last
expect(voice_message.message_type).to eq('outgoing')
message_data = voice_message.content_attributes['data']
expect(message_data).to include(
'call_sid' => call_sid,
'conference_sid' => attrs['conference_sid'],
'from_number' => channel.phone_number,
'to_number' => contact.phone_number
)
end
expect(call.conversation.identifier).to be_nil
expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
end
it 'raises an error when contact is missing a phone number' do
@@ -79,19 +79,5 @@ RSpec.describe Voice::OutboundCallBuilder do
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end
end
end
@@ -2,7 +2,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
@@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.with(channel.account_sid, channel.auth_token)
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
@@ -2,10 +2,19 @@ require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:call) do
create(
:call,
account: account,
inbox: channel.inbox,
conversation: conversation,
contact: conversation.contact
)
end
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:service) { described_class.new(call: call, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
@@ -13,42 +22,37 @@ describe Voice::Provider::Twilio::ConferenceService do
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
it 'returns existing sid if present on the Call' do
call.update!(conference_sid: 'CF_EXISTING')
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
expect(service.ensure_conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.reload.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
it 'sets accepted_by_agent on the Call' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
expect(call.reload.accepted_by_agent_id).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
it 'completes in-progress conferences matching the call conference_sid' do
call.update!(conference_sid: 'CF123_FRIENDLY')
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(conferences_proxy).to receive(:list).with(friendly_name: 'CF123_FRIENDLY', status: 'in-progress').and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
@@ -56,5 +60,11 @@ describe Voice::Provider::Twilio::ConferenceService do
expect(conf_context).to have_received(:update).with(status: 'completed')
end
it 'no-ops when call has no conference_sid' do
allow(twilio_client).to receive(:conferences)
service.end_conference
expect(twilio_client).not_to have_received(:conferences)
end
end
end
@@ -3,7 +3,7 @@ require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
@@ -4,43 +4,47 @@ require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
let(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do
Conversation.create!(
account_id: account.id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
end
let!(:call) do
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
end
let(:message) do
conversation.messages.create!(
let!(:message) do
msg = conversation.messages.create!(
account_id: account.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'ringing' } }
)
call.update!(message_id: msg.id)
msg
end
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
it 'updates conversation and last voice message with call status' do
# Ensure records are created after stub setup
conversation
message
it 'updates the Call and touches the linked message on status transition' do
previous_updated_at = message.updated_at
travel 1.second
described_class.new(
account: account,
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
call_status: 'completed'
).perform
conversation.reload
call.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
expect(call.status).to eq('completed')
expect(message.updated_at).to be > previous_updated_at
end
it 'normalizes busy to no-answer' do
conversation
message
it 'normalizes busy to no_answer on the Call' do
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'busy'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
expect(call.reload.status).to eq('no_answer')
end
it 'no-ops when conversation not found' do
it 'no-ops when no Call matches the provided call_sid' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :call do
association :conversation
account { conversation.account }
inbox { conversation.inbox }
contact { conversation.contact }
provider { :twilio }
direction { :incoming }
status { 'ringing' }
sequence(:provider_call_id) { |n| "CA#{SecureRandom.hex(15)}#{n}" }
end
end
-21
View File
@@ -1,21 +0,0 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_voice, class: 'Channel::Voice' do
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
provider_config do
{
account_sid: "AC#{SecureRandom.hex(16)}",
auth_token: SecureRandom.hex(16),
api_key_sid: SecureRandom.hex(8),
api_key_secret: SecureRandom.hex(16),
twiml_app_sid: "AP#{SecureRandom.hex(16)}"
}
end
account
after(:create) do |channel_voice|
create(:inbox, channel: channel_voice, account: channel_voice.account)
end
end
end
+8
View File
@@ -17,5 +17,13 @@ FactoryBot.define do
trait :whatsapp do
medium { :whatsapp }
end
trait :with_voice do
with_phone_number
voice_enabled { true }
api_key_sid { "SK#{SecureRandom.hex(16)}" }
api_key_secret { SecureRandom.hex(16) }
twiml_app_sid { "AP#{SecureRandom.hex(16)}" }
end
end
end