diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
new file mode 100644
index 000000000..ec24aae34
--- /dev/null
+++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js
@@ -0,0 +1,45 @@
+/* global axios */
+import ApiClient from '../../ApiClient';
+
+class WhatsappCallsAPI extends ApiClient {
+ constructor() {
+ super('whatsapp_calls', { accountScoped: true });
+ }
+
+ show(callId) {
+ return axios.get(`${this.url}/${callId}`).then(r => r.data);
+ }
+
+ initiate(conversationId, sdpOffer) {
+ return axios
+ .post(`${this.url}/initiate`, {
+ conversation_id: conversationId,
+ sdp_offer: sdpOffer,
+ })
+ .then(r => r.data);
+ }
+
+ accept(callId, sdpAnswer) {
+ return axios
+ .post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
+ .then(r => r.data);
+ }
+
+ reject(callId) {
+ return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
+ }
+
+ terminate(callId) {
+ return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
+ }
+
+ uploadRecording(callId, blob, filename = 'call-recording.webm') {
+ const formData = new FormData();
+ formData.append('recording', blob, filename);
+ return axios
+ .post(`${this.url}/${callId}/upload_recording`, formData)
+ .then(r => r.data);
+ }
+}
+
+export default new WhatsappCallsAPI();
diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js
index bae5623a7..c39a4cf9d 100644
--- a/app/javascript/dashboard/api/contacts.js
+++ b/app/javascript/dashboard/api/contacts.js
@@ -35,8 +35,9 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
- getConversations(contactId) {
- return axios.get(`${this.url}/${contactId}/conversations`);
+ getConversations(contactId, { inboxId } = {}) {
+ const params = inboxId ? { inbox_id: inboxId } : {};
+ return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getContactableInboxes(contactId) {
@@ -47,9 +48,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
- initiateCall(contactId, inboxId) {
+ initiateCall(contactId, inboxId, conversationId = null) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
+ conversation_id: conversationId,
});
}
diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js
index cc564fe96..114dbb6f4 100644
--- a/app/javascript/dashboard/api/inboxes.js
+++ b/app/javascript/dashboard/api/inboxes.js
@@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
+
+ enableWhatsappCalling(inboxId) {
+ return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
+ }
+
+ disableWhatsappCalling(inboxId) {
+ return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
+ }
}
export default new Inboxes();
diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js
index b21aeb102..f55ecdfaa 100644
--- a/app/javascript/dashboard/api/specs/contacts.spec.js
+++ b/app/javascript/dashboard/api/specs/contacts.spec.js
@@ -41,7 +41,8 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
- '/api/v1/contacts/1/conversations'
+ '/api/v1/contacts/1/conversations',
+ { params: {} }
);
});
diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
index b258dc763..7e2b6f0c4 100644
--- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
+++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue
@@ -3,10 +3,20 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
-import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
+import {
+ isVoiceCallEnabled,
+ getVoiceCallProvider,
+ VOICE_CALL_PROVIDERS,
+} from 'dashboard/helper/inbox';
+import {
+ VOICE_CALL_DIRECTION,
+ VOICE_CALL_OUTBOUND_INIT_STATUS,
+} from 'dashboard/components-next/message/constants';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
+import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
+import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -14,6 +24,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
+ // When set, the WhatsApp call continues in this conversation (matching the
+ // header button) instead of looking up the contact's most recent one.
+ conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -30,6 +43,7 @@ const { t } = useI18n();
const dialogRef = ref(null);
+const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -38,13 +52,22 @@ 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(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
+// Mirror the conversation-header button: block a new call whenever any provider
+// call is already active or ringing, otherwise starting a WhatsApp call here
+// would leave a still-live Twilio (or other) session with no visible control.
+const isCallButtonDisabled = computed(
+ () =>
+ callsStore.hasActiveCall ||
+ callsStore.hasIncomingCall ||
+ isInitiatingCall.value
+);
+
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
@@ -58,23 +81,96 @@ const navigateToConversation = conversationId => {
}
};
-const startCall = async inboxId => {
- if (isInitiatingCall.value) return;
+const whatsappCallSession = useWhatsappCallSession();
+
+// Find the most recent open conversation for this contact in the picked inbox.
+// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
+// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
+// contacts whose latest WhatsApp conversation falls outside the 20 most recent
+// across all inboxes would be treated as having no conversation.
+const findWhatsappConversationId = async inboxId => {
+ const { data } = await ContactAPI.getConversations(props.contactId, {
+ inboxId,
+ });
+ const conversations = data?.payload || [];
+ const match = [...conversations].sort(
+ (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
+ )[0];
+ return match?.id || null;
+};
+
+const startWhatsappCall = async (inboxId, conversationIdHint) => {
+ // WhatsApp /initiate is conversation-scoped, so we must hand it a
+ // conversation. Use the caller's hint when given (in-conversation flow);
+ // otherwise pick the most recent one in the inbox.
+ const conversationId =
+ conversationIdHint || (await findWhatsappConversationId(inboxId));
+ if (!conversationId) {
+ useAlert(t('CONTACT_PANEL.CALL_FAILED'));
+ return;
+ }
+
+ const response =
+ await whatsappCallSession.initiateOutboundCall(conversationId);
+ // The composable returns { status: 'locked' } when an init is already in
+ // flight or a call is already active; treat that as a soft no-op rather than
+ // claiming success.
+ if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
+ if (!response?.id) {
+ // Permission template path returns no call id. Mirror the header button and
+ // surface whether the request was just sent or is already pending instead of
+ // claiming the call started. The permission message lands in the
+ // conversation, so still navigate there.
+ const messageKey =
+ response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
+ ? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
+ : 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
+ useAlert(t(messageKey));
+ navigateToConversation(conversationId);
+ return;
+ }
+
+ // Stay non-active until the connect cable event arrives — flipping to active
+ // here would start the duration timer before the contact picks up.
+ callsStore.addCall({
+ callSid: response.call_id,
+ callId: response.id,
+ conversationId,
+ inboxId,
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
+ provider: VOICE_CALL_PROVIDERS.WHATSAPP,
+ });
+
+ useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
+ navigateToConversation(conversationId);
+};
+
+const startCall = async (inboxId, conversationIdHint = null) => {
+ if (isCallButtonDisabled.value) return;
+
+ const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
+ if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
+ try {
+ await startWhatsappCall(inboxId, conversationIdHint);
+ } catch (error) {
+ useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
+ }
+ return;
+ }
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
+ conversationId: conversationIdHint,
});
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',
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -86,6 +182,22 @@ const startCall = async inboxId => {
};
const onClick = async () => {
+ // In conversation context, only stay in this conversation if its inbox is
+ // itself voice-capable (works the same for Twilio and WhatsApp). For
+ // non-voice channels (email, web, …) fall back to the picker so the call
+ // goes out via a voice inbox.
+ if (props.conversationId) {
+ const conversation = store.getters.getConversationById(
+ props.conversationId
+ );
+ const conversationInbox = (inboxesList.value || []).find(
+ i => i.id === conversation?.inbox_id
+ );
+ if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
+ await startCall(conversationInbox.id, props.conversationId);
+ return;
+ }
+ }
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -106,7 +218,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
- :disabled="isInitiatingCall"
+ :disabled="isCallButtonDisabled"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
diff --git a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue
index ab3f8488e..332b91066 100644
--- a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue
+++ b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue
@@ -32,7 +32,11 @@ const modelValue = defineModel({ type: Boolean, default: false });
{{ header }}
-
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/call/CallCard.story.vue b/app/javascript/dashboard/components-next/call/CallCard.story.vue
new file mode 100644
index 000000000..0f9ba4660
--- /dev/null
+++ b/app/javascript/dashboard/components-next/call/CallCard.story.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue
new file mode 100644
index 000000000..fcd7ad232
--- /dev/null
+++ b/app/javascript/dashboard/components-next/call/CallCard.vue
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusLabel }}
+
+
+
+
+
+
+ {{ callInfo.countryFlag }}
+
+
+
+ {{ callInfo.location }}
+
+
+
+
+
+ {{ duration }}
+
+
+
+
+
+ {{ statusLabel }}
+
+
+
+
+
+
+
+
+
+ {{ callInfo.contactName }}
+
+
+ {{ callInfo.phoneNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #{{ call.conversationId }}
+
+
+
+
+
+ {{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue
new file mode 100644
index 000000000..39e75ddbf
--- /dev/null
+++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
index 68102dbd3..aef6a57ec 100644
--- a/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
+++ b/app/javascript/dashboard/components-next/icon/ChannelIcon.vue
@@ -1,5 +1,6 @@
-
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
index d7a9c93ad..40fa2da98 100644
--- a/app/javascript/dashboard/components-next/icon/provider.js
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -1,5 +1,5 @@
+import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import { computed } from 'vue';
-import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -27,19 +27,29 @@ export function useChannelIcon(inbox) {
const type = inboxDetails.channel_type;
let icon = channelTypeIconMap[type];
- if (type === 'Channel::Email' && inboxDetails.provider) {
+ if (type === INBOX_TYPES.EMAIL && inboxDetails.provider) {
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
icon = providerIconMap[inboxDetails.provider];
}
}
// Special case for Twilio whatsapp
- if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
+ if (
+ type === INBOX_TYPES.TWILIO &&
+ inboxDetails.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
+ ) {
icon = 'i-woot-whatsapp';
}
- // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
- if (isVoiceCallEnabled(inboxDetails)) {
+ // Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
+ // is presented as a Voice channel, so show the phone icon.
+ const voiceEnabled =
+ inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
+ if (
+ type === INBOX_TYPES.TWILIO &&
+ voiceEnabled &&
+ inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
+ ) {
icon = 'i-woot-voice';
}
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
index a0f950ad4..ae9c4ec75 100644
--- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
+++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
@@ -2,14 +2,27 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
+import { useMapGetter } from 'dashboard/composables/store';
import { useMessageContext } from '../provider.js';
-import { VOICE_CALL_STATUS } from '../constants';
-import { useCallSession } from 'dashboard/composables/useCallSession';
+import {
+ VOICE_CALL_STATUS,
+ VOICE_CALL_DIRECTION,
+ VOICE_CALL_OUTBOUND_INIT_STATUS,
+ VOICE_CALL_END_REASON,
+ MESSAGE_TYPES,
+ ATTACHMENT_TYPES,
+} from '../constants';
+import { useCallActions } from 'dashboard/composables/useCallSession';
+import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
+import { useCallsStore } from 'dashboard/stores/calls';
+import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { formatDuration } from 'shared/helpers/timeHelper';
+import { useAlert } from 'dashboard/composables';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
@@ -17,39 +30,71 @@ const LABEL_MAP = {
};
const ICON_MAP = {
- [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
- [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
- [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
-};
-
-const BG_COLOR_MAP = {
- [VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
- [VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
- [VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
- [VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
- [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
+ [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call-bold',
+ [VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
+ [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
+ [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
};
const { t } = useI18n();
const store = useStore();
-const { call, conversationId, currentUserId, inboxId } = useMessageContext();
+const {
+ call,
+ attachments,
+ contentAttributes,
+ conversationId,
+ currentUserId,
+ inboxId,
+ sender,
+ messageType,
+} = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
- useCallSession();
+ useCallActions();
+const whatsappCallSession = useWhatsappCallSession();
+const callsStore = useCallsStore();
+const contactsUiFlags = useMapGetter('contacts/getUIFlags');
+const isInitiatingCall = computed(
+ () => contactsUiFlags.value?.isInitiatingCall || false
+);
const status = computed(() => call.value?.status);
-const isOutbound = computed(() => call.value?.direction === 'outgoing');
+// Server-side call records use `outgoing`/`incoming`, while the Pinia store
+// and a few API hops normalise to `outbound`/`inbound`. Accept either so the
+// bubble label matches the message orientation no matter the source.
+const isOutbound = computed(() => {
+ const dir = call.value?.direction;
+ if (
+ dir === VOICE_CALL_DIRECTION.OUTGOING ||
+ dir === VOICE_CALL_DIRECTION.OUTBOUND
+ )
+ return true;
+ if (
+ dir === VOICE_CALL_DIRECTION.INCOMING ||
+ dir === VOICE_CALL_DIRECTION.INBOUND
+ )
+ return false;
+ // Fall back to the message orientation: agent-authored messages sit on the
+ // right (outbound) and contact-authored ones on the left.
+ return messageType.value === MESSAGE_TYPES.OUTGOING;
+});
+const isWhatsapp = computed(
+ () => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
+);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
+const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
+const endReason = computed(() => call.value?.endReason);
+const wasDeclinedByAgent = computed(
+ () =>
+ isMissedInbound.value &&
+ endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
+);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
-// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
-// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
-// Twilio's call-status webhook flipped the call to in-progress before the
-// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
@@ -66,6 +111,19 @@ const displayAgentName = computed(() => {
return conversationAssignee.value?.name || null;
});
+const audioAttachment = computed(() =>
+ (attachments?.value || []).find(a => a.fileType === ATTACHMENT_TYPES.AUDIO)
+);
+
+const durationSeconds = computed(() => {
+ const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
+ if (fromCall != null) return fromCall;
+ const data = contentAttributes?.value?.data;
+ return data?.durationSeconds || data?.duration_seconds;
+});
+
+const formattedDuration = computed(() => formatDuration(durationSeconds.value));
+
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -73,24 +131,25 @@ const labelKey = computed(() => {
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
- return isFailed.value
- ? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
- : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
+ if (isFailed.value) {
+ return isOutbound.value
+ ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
+ : 'CONVERSATION.VOICE_CALL.MISSED_CALL';
+ }
+ return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
-const formattedDuration = computed(() =>
- formatDuration(call.value?.durationSeconds)
-);
-
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
- return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
+ return isOutbound.value
+ ? t('CONVERSATION.VOICE_CALL.CALLING')
+ : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
- if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
+ if (isOutbound.value) return null;
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
@@ -99,34 +158,51 @@ const subtext = computed(() => {
agentName: displayAgentName.value,
});
}
- return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
+ return null;
}
- return isFailed.value
- ? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
- : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
+ if (isFailed.value) {
+ if (isOutbound.value) {
+ return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
+ }
+ if (wasDeclinedByAgent.value && displayAgentName.value) {
+ return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', {
+ agentName: displayAgentName.value,
+ });
+ }
+ return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
+ }
+ return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
- return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
+ return isOutbound.value
+ ? 'i-ph-phone-outgoing-bold'
+ : 'i-ph-phone-incoming-bold';
});
-const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
+// Subtle icon container — matches the design's tonal swatch over the bubble bg.
+// Status drives the accent: teal for live, ruby for missed, neutral otherwise.
+const iconContainerClass = computed(() => {
+ if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
+ return 'bg-n-teal-3 text-n-teal-11';
+ }
+ if (status.value === VOICE_CALL_STATUS.RINGING) {
+ return 'bg-n-teal-3 text-n-teal-11';
+ }
+ if (isMissedInbound.value) {
+ return 'bg-n-alpha-2 text-n-ruby-9';
+ }
+ return 'bg-n-alpha-2 text-n-slate-12';
+});
const callSid = computed(() => call.value?.providerCallId);
-// Show "Join call" when the call is still ringing, no agent has claimed it,
-// and the conversation is unassigned or assigned to the current user. Mirrors
-// the eligibility used by FloatingCallWidget so the bubble can act as a
-// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
- // Suppress the button once this call is the local active session — the
- // message status webhook may lag behind, so we can't rely on `status` alone
- // to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
@@ -135,11 +211,12 @@ const canJoinCall = computed(() => {
});
const recordingAttachment = computed(() => {
+ if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
- fileType: 'audio',
+ fileType: ATTACHMENT_TYPES.AUDIO,
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
@@ -162,48 +239,117 @@ const handleJoinCall = async () => {
callSid: callSid.value,
});
};
+
+const canCallBack = computed(
+ () =>
+ isMissedInbound.value &&
+ !!inboxId.value &&
+ !!conversationId.value &&
+ !hasActiveCall.value &&
+ !callsStore.hasIncomingCall
+);
+
+const handleCallBack = async () => {
+ if (!canCallBack.value || isInitiatingCall.value) return;
+ try {
+ if (isWhatsapp.value) {
+ const response = await whatsappCallSession.initiateOutboundCall(
+ conversationId.value
+ );
+ if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
+ // Permission template path returns no call id — show banner, no widget yet.
+ if (!response?.id) {
+ useAlert(
+ response?.status ===
+ VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
+ ? t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING')
+ : t('CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED')
+ );
+ return;
+ }
+ callsStore.addCall({
+ callSid: response.call_id,
+ callId: response.id,
+ conversationId: conversationId.value,
+ inboxId: inboxId.value,
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
+ provider: VOICE_CALL_PROVIDERS.WHATSAPP,
+ });
+ return;
+ }
+ const response = await store.dispatch('contacts/initiateCall', {
+ contactId: sender.value?.id,
+ inboxId: inboxId.value,
+ conversationId: conversationId.value,
+ });
+ callsStore.addCall({
+ callSid: response?.call_sid,
+ conversationId: response?.conversation_id ?? conversationId.value,
+ inboxId: inboxId.value,
+ callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
+ });
+ } catch (error) {
+ useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
+ }
+};
-
-
-
+
+
+
+
-
+
-
-
-
+
+
{{ $t(labelKey) }}
-
+
{{ subtext }}
-
-
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue
index 9c7a44b23..ec50d4a62 100644
--- a/app/javascript/dashboard/components-next/message/chips/Audio.vue
+++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
+// MediaRecorder-produced WebM/Opus blobs lack a Duration header →
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
new file mode 100644
index 000000000..c27cd7d1f
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
index cf5c1310e..668e0709a 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue
@@ -7,6 +7,7 @@ import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
+import InboxesAPI from 'dashboard/api/inboxes';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConstants from 'dashboard/constants/globals.js';
import {
@@ -16,6 +17,13 @@ import {
isValidBusinessData,
} from './whatsapp/utils';
+const props = defineProps({
+ enableCallingOnComplete: {
+ type: Boolean,
+ default: false,
+ },
+});
+
const store = useStore();
const router = useRouter();
const { t } = useI18n();
@@ -65,11 +73,27 @@ const handleSignupCancellation = () => {
isAuthenticating.value = false;
};
-const handleSignupSuccess = inboxData => {
- isProcessing.value = false;
- isAuthenticating.value = false;
+const enableCallingForInbox = async inboxId => {
+ try {
+ await InboxesAPI.enableWhatsappCalling(inboxId);
+ } catch (_) {
+ useAlert(
+ t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED')
+ );
+ }
+};
+const handleSignupSuccess = async inboxData => {
if (inboxData && inboxData.id) {
+ if (props.enableCallingOnComplete) {
+ isProcessing.value = true;
+ processingMessage.value = t(
+ 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING'
+ );
+ await enableCallingForInbox(inboxData.id);
+ }
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
@@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => {
},
});
} else {
+ isProcessing.value = false;
+ isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue
new file mode 100644
index 000000000..1fe082d09
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js
index d0029207e..2aea4d4d5 100644
--- a/app/javascript/dashboard/store/modules/contacts/actions.js
+++ b/app/javascript/dashboard/store/modules/contacts/actions.js
@@ -312,10 +312,14 @@ export const actions = {
commit(types.CLEAR_CONTACT_FILTERS);
},
- initiateCall: async ({ commit }, { contactId, inboxId }) => {
+ initiateCall: async ({ commit }, { contactId, inboxId, conversationId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
- const response = await ContactAPI.initiateCall(contactId, inboxId);
+ const response = await ContactAPI.initiateCall(
+ contactId,
+ inboxId,
+ conversationId
+ );
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index da5fc94bf..72ab8fa5e 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -329,12 +329,21 @@ const actions = {
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
- handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
+ handleVoiceCallCreated(
+ message,
+ rootGetters?.getCurrentUserID,
+ rootGetters?.getCurrentUserAvailability
+ );
},
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
- handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
+ handleVoiceCallUpdated(
+ commit,
+ message,
+ rootGetters?.getCurrentUserID,
+ rootGetters?.getCurrentUserAvailability
+ );
},
deleteMessage: async function deleteLabels(
diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js
index 4b58b8bb8..2a634a5d9 100644
--- a/app/javascript/dashboard/stores/calls.js
+++ b/app/javascript/dashboard/stores/calls.js
@@ -1,6 +1,16 @@
-import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
+import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
+import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
+import { defineStore } from 'pinia';
+
+const teardownByProvider = call => {
+ if (call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP) {
+ cleanupWhatsappSession();
+ } else {
+ TwilioVoiceClient.endClientCall();
+ }
+};
export const useCallsStore = defineStore('calls', {
state: () => ({
@@ -16,15 +26,33 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
- if (TERMINAL_STATUSES.includes(status)) {
- this.removeCall(callSid);
+ if (!TERMINAL_STATUSES.includes(status)) return;
+
+ const call = this.calls.find(c => c.callSid === callSid);
+ // WhatsApp recordings live in the in-memory recorder until voice_call.ended
+ // uploads them; tearing down here would race-wipe those chunks.
+ if (call?.provider === 'whatsapp') {
+ this.calls = this.calls.filter(c => c.callSid !== callSid);
+ return;
}
+
+ this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) return;
- const exists = this.calls.some(call => call.callSid === callData.callSid);
- if (exists) return;
+ const existing = this.calls.find(c => c.callSid === callData.callSid);
+ if (existing) {
+ // Merge so a later cable event with sdp_offer/provider/caller fills in
+ // gaps left by the earlier message.created path (and vice versa).
+ // Preserve a previously-captured caller snapshot when the incoming
+ // event has no sender info, otherwise the widget would flip to
+ // "Unknown caller" on the next status update.
+ const next = { ...callData };
+ if (existing.caller && !next.caller) delete next.caller;
+ Object.assign(existing, next, { isActive: existing.isActive });
+ return;
+ }
this.calls.push({
...callData,
@@ -35,7 +63,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
- TwilioVoiceClient.endClientCall();
+ teardownByProvider(callToRemove);
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -48,7 +76,8 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
- TwilioVoiceClient.endClientCall();
+ const active = this.calls.find(c => c.isActive);
+ teardownByProvider(active);
this.calls = this.calls.filter(call => !call.isActive);
},
@@ -61,9 +90,10 @@ export const useCallsStore = defineStore('calls', {
call => call.conversationId === conversationId
);
- if (callsToRemove.some(call => call.isActive)) {
- TwilioVoiceClient.endClientCall();
- }
+ // Tear down each active call via its own provider so a WhatsApp call
+ // gets cleanupWhatsappSession() (closes pc, stops recorder/mic) instead
+ // of the Twilio-only endClientCall() — otherwise mic stays open.
+ callsToRemove.filter(call => call.isActive).forEach(teardownByProvider);
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index e1d4b226a..2c2205b19 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -44,12 +44,18 @@ class Channel::Whatsapp < ApplicationRecord
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
- provider == 'whatsapp_cloud' &&
- provider_config['source'] == 'embedded_signup' &&
+ voice_calling_supported? &&
provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice')
end
+ # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
+ # reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
+ # and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
+ def voice_calling_supported?
+ provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
+ end
+
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
@@ -58,6 +64,35 @@ class Channel::Whatsapp < ApplicationRecord
end
end
+ # Enables voice: turns calling on at Meta (idempotent), subscribes the `calls`
+ # webhook field, and sets calling_enabled. Raises on Meta failure.
+ # Saved with validate: false to skip validate_provider_config's remote credential
+ # re-check, which could spuriously fail and desync the flag from Meta.
+ def enable_voice_calling!
+ raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
+ raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
+
+ provider_service.update_calling_status('ENABLED')
+ webhook_setup_service.register_callback
+ self.provider_config = provider_config.merge('calling_enabled' => true)
+ save!(validate: false)
+ end
+
+ # Disables voice: unsets calling_enabled (gates the call subsystem) and drops
+ # `calls` from the webhook subscription (best-effort, so a Meta outage can't
+ # trap admins). Leaves Meta's WABA calling.status untouched.
+ def disable_voice_calling!
+ raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
+
+ self.provider_config = provider_config.merge('calling_enabled' => false)
+ save!(validate: false)
+ begin
+ webhook_setup_service.register_callback(subscribed_fields: %w[messages smb_message_echoes])
+ rescue StandardError => e
+ Rails.logger.warn "[WHATSAPP CALL] disable webhook re-subscribe failed: #{e.message}"
+ end
+ end
+
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
@@ -88,10 +123,11 @@ class Channel::Whatsapp < ApplicationRecord
end
def perform_webhook_setup
- business_account_id = provider_config['business_account_id']
- api_key = provider_config['api_key']
+ webhook_setup_service.perform
+ end
- Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
+ def webhook_setup_service
+ Whatsapp::WebhookSetupService.new(self, provider_config['business_account_id'], provider_config['api_key'])
end
def teardown_webhooks
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index d77b183ee..e516a498e 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -69,4 +69,12 @@ class InboxPolicy < ApplicationPolicy
def reset_secret?
@account_user.administrator?
end
+
+ def enable_whatsapp_calling?
+ @account_user.administrator?
+ end
+
+ def disable_whatsapp_calling?
+ @account_user.administrator?
+ end
end
diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb
index 94e46dabd..eef84b022 100644
--- a/app/services/whatsapp/facebook_api_client.rb
+++ b/app/services/whatsapp/facebook_api_client.rb
@@ -60,14 +60,16 @@ class Whatsapp::FacebookApiClient
data['code_verification_status'] == 'VERIFIED'
end
- def subscribe_waba_webhook(waba_id, callback_url, verify_token)
+ WEBHOOK_DEFAULT_FIELDS = %w[messages smb_message_echoes calls].freeze
+
+ def subscribe_waba_webhook(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
# Step 1: Subscribe app to WABA first (required before override)
# Meta requires the app to be subscribed before using override_callback_uri
# See: https://github.com/chatwoot/chatwoot/issues/13097
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
- override_waba_callback(waba_id, callback_url, verify_token)
+ override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: subscribed_fields)
end
def subscribe_app_to_waba(waba_id)
@@ -79,14 +81,14 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'App subscription to WABA failed')
end
- def override_waba_callback(waba_id, callback_url, verify_token)
+ def override_waba_callback(waba_id, callback_url, verify_token, subscribed_fields: WEBHOOK_DEFAULT_FIELDS)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token,
- subscribed_fields: %w[messages smb_message_echoes calls]
+ subscribed_fields: subscribed_fields
}.to_json
)
diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb
index 97a53eb9a..a287b4977 100644
--- a/app/services/whatsapp/webhook_setup_service.rb
+++ b/app/services/whatsapp/webhook_setup_service.rb
@@ -17,9 +17,9 @@ class Whatsapp::WebhookSetupService
setup_webhook
end
- def register_callback
+ def register_callback(subscribed_fields: nil)
validate_parameters!
- setup_webhook
+ setup_webhook(subscribed_fields: subscribed_fields)
end
private
@@ -55,12 +55,16 @@ class Whatsapp::WebhookSetupService
@channel.save!
end
- def setup_webhook
+ def setup_webhook(subscribed_fields: nil)
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
- @api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
-
+ args = [@waba_id, callback_url, verify_token]
+ if subscribed_fields
+ @api_client.subscribe_waba_webhook(*args, subscribed_fields: subscribed_fields)
+ else
+ @api_client.subscribe_waba_webhook(*args)
+ end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
diff --git a/config/routes.rb b/config/routes.rb
index 3d2d68269..9f89466ff 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -261,6 +261,8 @@ Rails.application.routes.draw do
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
end
+ post :enable_whatsapp_calling, on: :member
+ post :disable_whatsapp_calling, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
index 1123699d8..0bea29843 100644
--- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
@@ -27,7 +27,10 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def destroy
call = resolve_call!
+ rejecting = agent_rejecting_before_pickup?(call)
+ # Tear down provider side first so a teardown failure leaves the call repairable.
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
+ finalize_as_agent_reject!(call) if rejecting
render json: { status: 'success', id: call.conversation.display_id }
end
@@ -59,4 +62,21 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
+
+ # A hangup before pickup is treated as an agent rejection, matching WhatsApp.
+ def agent_rejecting_before_pickup?(call)
+ call.ringing? && call.accepted_by_agent_id.nil?
+ end
+
+ def finalize_as_agent_reject!(call)
+ # Re-check under a row lock: a webhook may have accepted/completed the call
+ # while end_conference was in flight, so don't force agent_rejected on stale state.
+ rejected = call.with_lock do
+ next false unless agent_rejecting_before_pickup?(call)
+
+ call.update!(status: 'failed', end_reason: 'agent_rejected', accepted_by_agent_id: Current.user.id)
+ true
+ end
+ Voice::CallMessageBuilder.new(call).update_status!(status: 'failed', agent: Current.user) if rejected
+ end
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index f9d828806..76f578bf1 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -3,12 +3,38 @@ module Enterprise::Api::V1::Accounts::InboxesController
super + ee_inbox_attributes
end
+ def enable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.enable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
+ def disable_whatsapp_calling
+ return unless ensure_whatsapp_calling_supported
+
+ @inbox.channel.disable_voice_calling!
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
private
+ def ensure_whatsapp_calling_supported
+ channel = @inbox.channel
+ return true if channel.is_a?(Channel::Whatsapp) && channel.voice_calling_supported?
+
+ render_could_not_create_error('Inbox does not support WhatsApp calling')
+ false
+ end
+
def allowed_channel_types
super + ['voice']
end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index f8c0580f8..e111cdd48 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -116,6 +116,7 @@ class Call < ApplicationRecord
direction: direction,
status: display_status,
duration_seconds: duration_seconds,
+ end_reason: end_reason,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
diff --git a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
index ec29a5a38..2fcf4b5e7 100644
--- a/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
@@ -40,6 +40,22 @@ module Enterprise::Whatsapp::Providers::WhatsappCloudService
process_initiate_call_response(response)
end
+ # Sets WABA calling status ('ENABLED'/'DISABLED'). Returns true, or raises with
+ # Meta's user-facing message on failure so the caller can surface it.
+ def update_calling_status(status)
+ response = HTTParty.post(
+ "#{calls_phone_id_path}/settings",
+ headers: api_headers,
+ body: { calling: { status: status } }.to_json
+ )
+ return true if response.success?
+
+ parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
+ message = parsed.dig('error', 'error_user_msg') || parsed.dig('error', 'message') || 'Failed to update calling status'
+ Rails.logger.error "[WHATSAPP CALL] update_calling_status failed: status=#{response.code} body=#{response.body}"
+ raise message
+ end
+
private
def calls_phone_id_path
diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb
index 73ace3a78..942ee0cc0 100644
--- a/enterprise/app/services/voice/call_status/manager.rb
+++ b/enterprise/app/services/voice/call_status/manager.rb
@@ -4,6 +4,9 @@ class Voice::CallStatus::Manager
def process_status_update(status, duration: nil, timestamp: nil)
return unless Call::STATUSES.include?(status)
return if call.status == status
+ # Don't overwrite a terminal status — Twilio's late `completed` events would
+ # otherwise clobber an agent-rejection reason.
+ return if Call::TERMINAL_STATUSES.include?(call.status)
apply_call_updates!(status, duration: duration, timestamp: timestamp)
call.conversation.update!(last_activity_at: Time.zone.now)
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index 7b8b0e684..b6fb089b7 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -74,15 +74,14 @@ class Voice::InboundCallBuilder
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider(digits, :cloud)
end
+ # Mirror incoming-message routing: reuse the open conversation (or the last one when locked), else create new.
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
+ reusable = if inbox.lock_to_single_conversation
+ contact_inbox.conversations.last
+ else
+ contact_inbox.conversations.where.not(status: :resolved).last
+ end
+ return reusable if reusable
account.conversations.create!(
contact_inbox_id: contact_inbox.id,
diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb
index 8a52ea6bc..93eba957c 100644
--- a/enterprise/app/services/whatsapp/call_service.rb
+++ b/enterprise/app/services/whatsapp/call_service.rb
@@ -20,7 +20,8 @@ class Whatsapp::CallService
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
- finalize_call('failed')
+ call.update!(accepted_by_agent_id: agent.id) if call.accepted_by_agent_id.nil?
+ finalize_call('failed', end_reason: 'agent_rejected')
end
call
end
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index d290e96c2..99f6350ff 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -159,17 +159,22 @@ class Whatsapp::IncomingCallService
)
end
- # Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
+ # Ring the assignee if any, else online inbox agents, else the whole account.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
+ streams = token ? [token] : (online_agent_streams.presence || account_streams)
broadcast(call, 'voice_call.incoming',
- streams: token ? [token] : account_streams,
+ streams: streams,
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 })
end
+ def online_agent_streams
+ inbox.available_agents.pluck('users.pubsub_token').compact
+ end
+
def broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }