feat(whatsapp): add calling enable/disable toggle in inbox settings UI
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_whatsapp_call, only: [:accept, :reject, :terminate]
|
||||
|
||||
def accept
|
||||
sdp_answer = params[:sdp_answer]
|
||||
return render json: { error: 'sdp_answer is required' }, status: :unprocessable_entity if sdp_answer.blank?
|
||||
|
||||
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).pre_accept_and_accept(sdp_answer)
|
||||
render json: { id: wa_call.id, status: wa_call.status }
|
||||
rescue Whatsapp::CallErrors::NotRinging, Whatsapp::CallErrors::AlreadyAccepted => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] accept failed: #{e.message}"
|
||||
render json: { error: 'Failed to accept call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def reject
|
||||
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).reject
|
||||
render json: { id: wa_call.id, status: wa_call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] reject failed: #{e.message}"
|
||||
render json: { error: 'Failed to reject call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def terminate
|
||||
wa_call = Whatsapp::CallService.new(wa_call: @whatsapp_call, agent: current_user).terminate
|
||||
render json: { id: wa_call.id, status: wa_call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] terminate failed: #{e.message}"
|
||||
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def initiate
|
||||
conversation = current_account.conversations.find(params[:conversation_id])
|
||||
error = validate_whatsapp_calling(conversation)
|
||||
return render json: { error: error }, status: :unprocessable_entity if error
|
||||
|
||||
contact_phone = conversation.contact&.phone_number
|
||||
return render json: { error: 'Contact phone number not available' }, status: :unprocessable_entity if contact_phone.blank?
|
||||
|
||||
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'))
|
||||
return render json: { error: 'Failed to initiate call' }, status: :internal_server_error unless result
|
||||
|
||||
render json: { status: 'calling', call_id: result['call_id'] }
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Conversation not found' }, status: :not_found
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP CALL] initiate failed: #{e.message}"
|
||||
render json: { error: 'Failed to initiate call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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 set_whatsapp_call
|
||||
@whatsapp_call = current_account.whatsapp_calls.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Call not found' }, status: :not_found
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
/* global axios */
|
||||
class WhatsappCallsAPI {
|
||||
constructor() {
|
||||
this.apiVersion = '/api/v1';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
const isInsideAccountScopedURLs =
|
||||
window.location.pathname.includes('/app/accounts');
|
||||
if (isInsideAccountScopedURLs) {
|
||||
return window.location.pathname.split('/')[3];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/whatsapp_calls`;
|
||||
}
|
||||
|
||||
accept(callId, sdpAnswer) {
|
||||
return axios.post(`${this.baseUrl}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.baseUrl}/${callId}/reject`);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.baseUrl}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
initiate(conversationId) {
|
||||
return axios.post(`${this.baseUrl}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappCallsAPI();
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
} = useWhatsappCallSession();
|
||||
|
||||
// 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();
|
||||
});
|
||||
</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">
|
||||
{{ 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">
|
||||
<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="font-mono text-sm text-n-teal-9">
|
||||
{{ formattedCallDuration }}
|
||||
</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>
|
||||
@@ -13,6 +13,9 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -30,7 +33,8 @@ const store = useStore();
|
||||
const route = useRoute();
|
||||
const conversationHeader = ref(null);
|
||||
const { width } = useElementSize(conversationHeader);
|
||||
const { isAWebWidgetInbox } = useInbox();
|
||||
const { isAWebWidgetInbox, isAWhatsAppCloudChannel } = useInbox();
|
||||
const isInitiatingCall = ref(false);
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
@@ -90,6 +94,32 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const canInitiateWhatsappCall = computed(() => {
|
||||
if (!isAWhatsAppCloudChannel.value) return false;
|
||||
return !!inbox.value?.callingEnabled;
|
||||
});
|
||||
|
||||
const initiateWhatsappCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id) return;
|
||||
isInitiatingCall.value = true;
|
||||
try {
|
||||
await WhatsappCallsAPI.initiate(currentChat.value.id);
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.CALLING'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
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>
|
||||
@@ -151,6 +181,19 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<button
|
||||
v-if="canInitiateWhatsappCall"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { ref, computed, onUnmounted } from 'vue';
|
||||
import { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||
import WhatsappCallsAPI from 'dashboard/api/whatsappCalls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useWhatsappCallSession() {
|
||||
const callsStore = useWhatsappCallsStore();
|
||||
|
||||
// WebRTC internals
|
||||
let peerConnection = null;
|
||||
let localStream = null;
|
||||
const remoteAudio = ref(null);
|
||||
|
||||
// UI state
|
||||
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 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')}`;
|
||||
});
|
||||
|
||||
const cleanupWebRTC = () => {
|
||||
if (localStream) {
|
||||
localStream.getTracks().forEach(t => t.stop());
|
||||
localStream = null;
|
||||
}
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
if (remoteAudio.value) {
|
||||
remoteAudio.value.srcObject = null;
|
||||
// Remove dynamically created audio element from DOM
|
||||
if (remoteAudio.value.parentNode) {
|
||||
remoteAudio.value.parentNode.removeChild(remoteAudio.value);
|
||||
}
|
||||
remoteAudio.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Accepts an incoming WhatsApp call:
|
||||
* 1. Requests mic access
|
||||
* 2. Creates RTCPeerConnection with ICE servers from the call payload
|
||||
* 3. Sets remote description (the SDP offer from Meta)
|
||||
* 4. Creates an SDP answer
|
||||
* 5. Posts the SDP answer to Chatwoot backend → Meta API
|
||||
*/
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
isAccepting.value = true;
|
||||
callError.value = null;
|
||||
|
||||
try {
|
||||
// 1. Get microphone access
|
||||
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
// 2. Build ICE config
|
||||
const iceServers = call.iceServers?.length
|
||||
? call.iceServers
|
||||
: [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
// 3. Create RTCPeerConnection
|
||||
peerConnection = new RTCPeerConnection({ iceServers });
|
||||
|
||||
// 4. Add local audio tracks
|
||||
localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, localStream);
|
||||
});
|
||||
|
||||
// 5. Handle remote audio stream → play via hidden <audio> element
|
||||
peerConnection.ontrack = event => {
|
||||
const [stream] = event.streams;
|
||||
if (remoteAudio.value) {
|
||||
remoteAudio.value.srcObject = stream;
|
||||
remoteAudio.value.play().catch(() => {});
|
||||
} else {
|
||||
// Fallback: create audio element dynamically
|
||||
const audio = document.createElement('audio');
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
remoteAudio.value = audio;
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Set remote description from Meta's SDP offer
|
||||
await peerConnection.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp: call.sdpOffer,
|
||||
});
|
||||
|
||||
// 7. Create SDP answer
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
// 8. Post the SDP answer to Chatwoot backend
|
||||
await WhatsappCallsAPI.accept(call.id, answer.sdp);
|
||||
|
||||
// 9. Mark as active in store
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.setActiveCall({
|
||||
...call,
|
||||
peerConnection,
|
||||
});
|
||||
|
||||
durationTimer.start();
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
err.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Please allow mic access and try again.'
|
||||
: 'Failed to accept call. Please try again.';
|
||||
cleanupWebRTC();
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCall = async call => {
|
||||
try {
|
||||
await WhatsappCallsAPI.reject(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
try {
|
||||
await WhatsappCallsAPI.terminate(call.id);
|
||||
} catch {
|
||||
// Best effort — always cleanup locally
|
||||
} finally {
|
||||
cleanupWebRTC();
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!localStream) return;
|
||||
const audioTrack = localStream.getAudioTracks()[0];
|
||||
if (!audioTrack) return;
|
||||
audioTrack.enabled = !audioTrack.enabled;
|
||||
isMuted.value = !audioTrack.enabled;
|
||||
};
|
||||
|
||||
const dismissIncomingCall = call => {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
firstIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -34,6 +35,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'whatsapp_call.incoming': this.onWhatsappCallIncoming,
|
||||
'whatsapp_call.accepted': this.onWhatsappCallAccepted,
|
||||
'whatsapp_call.ended': this.onWhatsappCallEnded,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +204,37 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
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
|
||||
onWhatsappCallIncoming = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
whatsappCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallAccepted = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
const currentUserId = this.app.$store.getters.getCurrentUserID;
|
||||
// If accepted by a different agent, remove from incoming list for this agent
|
||||
if (data.accepted_by_agent_id !== currentUserId) {
|
||||
whatsappCallsStore.handleCallAcceptedByOther(data.call_id);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onWhatsappCallEnded = data => {
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
whatsappCallsStore.handleCallEnded(data.call_id);
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -759,6 +759,9 @@
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
|
||||
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
|
||||
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
|
||||
"WHATSAPP_CALLING_TITLE": "WhatsApp Calling",
|
||||
"WHATSAPP_CALLING_SUBHEADER": "Enable agents to make and receive WhatsApp voice calls directly from Chatwoot. Requires your WhatsApp number to be approved for the Calling API by Meta.",
|
||||
"WHATSAPP_CALLING_LABEL": "Enable WhatsApp Calling",
|
||||
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
|
||||
},
|
||||
"HELP_CENTER": {
|
||||
|
||||
@@ -36,6 +36,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import whatsappCall from './whatsappCall.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
@@ -79,6 +80,7 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...whatsappCall,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...yearInReview,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"WHATSAPP_CALL": {
|
||||
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
|
||||
"OUTGOING_WHATSAPP_CALL": "Outgoing WhatsApp Call",
|
||||
"ACCEPT": "Accept",
|
||||
"REJECT": "Reject",
|
||||
"HANG_UP": "Hang Up",
|
||||
"MUTE": "Mute",
|
||||
"UNMUTE": "Unmute",
|
||||
"INITIATE_CALL": "Call via WhatsApp",
|
||||
"CALLING": "Calling…",
|
||||
"CALL_FAILED": "Call failed. Please try again.",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
|
||||
"CALL_TAKEN": "Call accepted by another agent"
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,16 @@ 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 { useWhatsappCallsStore } from 'dashboard/stores/whatsappCalls';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -36,6 +41,7 @@ export default {
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
FloatingCallWidget,
|
||||
WhatsappCallWidget,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
@@ -44,6 +50,7 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const callsStore = useCallsStore();
|
||||
const whatsappCallsStore = useWhatsappCallsStore();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -53,6 +60,10 @@ export default {
|
||||
windowWidth,
|
||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||
hasWhatsappCall: computed(
|
||||
() =>
|
||||
whatsappCallsStore.hasActiveCall || whatsappCallsStore.hasIncomingCall
|
||||
),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -163,6 +174,7 @@ export default {
|
||||
/>
|
||||
<CopilotContainer />
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
<WhatsappCallWidget v-if="hasWhatsappCall" />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
|
||||
+36
@@ -38,6 +38,7 @@ export default {
|
||||
isSyncingTemplates: false,
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
callingEnabled: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -66,6 +67,8 @@ export default {
|
||||
setDefaults() {
|
||||
this.hmacMandatory = this.inbox.hmac_mandatory || false;
|
||||
this.allowedDomains = this.inbox.allowed_domains || '';
|
||||
this.callingEnabled =
|
||||
this.inbox.provider_config?.calling_enabled || false;
|
||||
},
|
||||
handleHmacFlag() {
|
||||
this.updateInbox();
|
||||
@@ -131,6 +134,23 @@ export default {
|
||||
await this.$refs.whatsappReauth.requestAuthorization();
|
||||
}
|
||||
},
|
||||
async updateCallingEnabled() {
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/updateInbox', {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
provider_config: {
|
||||
...this.inbox.provider_config,
|
||||
calling_enabled: this.callingEnabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async syncTemplates() {
|
||||
this.isSyncingTemplates = true;
|
||||
try {
|
||||
@@ -401,6 +421,22 @@ export default {
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_SUBHEADER')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="callingEnabled"
|
||||
v-model="callingEnabled"
|
||||
type="checkbox"
|
||||
@change="updateCallingEnabled"
|
||||
/>
|
||||
<label for="callingEnabled" class="text-body-main text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CALLING_LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<WhatsappReauthorize
|
||||
v-if="isEmbeddedSignupWhatsApp"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useWhatsappCallsStore = defineStore('whatsappCalls', {
|
||||
state: () => ({
|
||||
// Incoming ringing calls waiting for agent action
|
||||
incomingCalls: [],
|
||||
// The single active call (accepted + audio connected)
|
||||
activeCall: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasIncomingCall: state => state.incomingCalls.length > 0,
|
||||
hasActiveCall: state => 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;
|
||||
},
|
||||
|
||||
handleCallAcceptedByOther(callId) {
|
||||
// Another agent accepted — remove from incoming list for this agent
|
||||
this.removeIncomingCall(callId);
|
||||
},
|
||||
|
||||
handleCallEnded(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
if (this.activeCall?.callId === callId) {
|
||||
this.activeCall = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -59,6 +59,11 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
end
|
||||
|
||||
def handle_message_events(channel, params)
|
||||
if call_event?(params)
|
||||
handle_call_events(channel, params)
|
||||
return
|
||||
end
|
||||
|
||||
case channel.provider
|
||||
when 'whatsapp_cloud'
|
||||
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform
|
||||
@@ -67,6 +72,21 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call_events(channel, params)
|
||||
Whatsapp::IncomingCallService.new(
|
||||
inbox: channel.inbox,
|
||||
params: extract_call_params(params)
|
||||
).perform
|
||||
end
|
||||
|
||||
def call_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||
end
|
||||
|
||||
def extract_call_params(params)
|
||||
params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
|
||||
@@ -132,6 +132,7 @@ class Account < ApplicationRecord
|
||||
has_many :twitter_profiles, dependent: :destroy_async, class_name: '::Channel::TwitterProfile'
|
||||
has_many :users, through: :account_users
|
||||
has_many :web_widgets, dependent: :destroy_async, class_name: '::Channel::WebWidget'
|
||||
has_many :whatsapp_calls, dependent: :destroy_async
|
||||
has_many :webhooks, dependent: :destroy_async
|
||||
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
|
||||
has_many :working_hours, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
class WhatsappCall < ApplicationRecord
|
||||
STATUSES = %w[ringing accepted rejected missed ended failed].freeze
|
||||
DIRECTIONS = %w[inbound outbound].freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||
|
||||
validates :call_id, presence: true, uniqueness: true
|
||||
validates :direction, inclusion: { in: DIRECTIONS }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where(status: %w[ringing accepted]) }
|
||||
scope :ringing, -> { where(status: 'ringing') }
|
||||
|
||||
def accepted?
|
||||
status == 'accepted'
|
||||
end
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
end
|
||||
|
||||
def terminal?
|
||||
%w[rejected missed ended failed].include?(status)
|
||||
end
|
||||
|
||||
def sdp_offer
|
||||
meta['sdp_offer']
|
||||
end
|
||||
|
||||
def ice_servers
|
||||
meta['ice_servers'] || []
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
class Whatsapp::CallService
|
||||
pattr_initialize [:wa_call!, :agent!]
|
||||
|
||||
def pre_accept_and_accept(sdp_answer)
|
||||
ensure_ringing!
|
||||
ensure_not_already_taken!
|
||||
|
||||
provider = wa_call.inbox.channel.provider_service
|
||||
call_id = wa_call.call_id
|
||||
|
||||
# Step 1: pre_accept
|
||||
pre_response = provider.pre_accept_call(call_id)
|
||||
raise "pre_accept failed: #{pre_response}" unless pre_response
|
||||
|
||||
# Step 2: accept with SDP answer (fix setup attribute as required by Meta)
|
||||
fixed_sdp = fix_sdp_setup(sdp_answer)
|
||||
accept_response = provider.accept_call(call_id, fixed_sdp)
|
||||
raise "accept failed: #{accept_response}" unless accept_response
|
||||
|
||||
wa_call.update!(
|
||||
status: 'accepted',
|
||||
accepted_by_agent_id: agent.id
|
||||
)
|
||||
|
||||
broadcast_accepted
|
||||
wa_call
|
||||
end
|
||||
|
||||
def reject
|
||||
return if wa_call.terminal?
|
||||
|
||||
provider = wa_call.inbox.channel.provider_service
|
||||
provider.reject_call(wa_call.call_id)
|
||||
|
||||
wa_call.update!(status: 'rejected')
|
||||
broadcast_call_ended
|
||||
wa_call
|
||||
end
|
||||
|
||||
def terminate
|
||||
return if wa_call.terminal?
|
||||
|
||||
provider = wa_call.inbox.channel.provider_service
|
||||
provider.terminate_call(wa_call.call_id)
|
||||
|
||||
wa_call.update!(status: 'ended')
|
||||
broadcast_call_ended
|
||||
wa_call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_ringing!
|
||||
raise Whatsapp::CallErrors::NotRinging, 'Call is not in ringing state' unless wa_call.ringing?
|
||||
end
|
||||
|
||||
def ensure_not_already_taken!
|
||||
raise Whatsapp::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if wa_call.accepted?
|
||||
end
|
||||
|
||||
def fix_sdp_setup(sdp)
|
||||
sdp.gsub('a=setup:actpass', 'a=setup:active')
|
||||
end
|
||||
|
||||
def broadcast_accepted
|
||||
payload = {
|
||||
event: 'whatsapp_call.accepted',
|
||||
data: {
|
||||
id: wa_call.id,
|
||||
call_id: wa_call.call_id,
|
||||
accepted_by_agent_id: agent.id,
|
||||
conversation_id: wa_call.conversation_id
|
||||
}
|
||||
}
|
||||
ActionCable.server.broadcast("account_#{wa_call.account_id}", payload)
|
||||
end
|
||||
|
||||
def broadcast_call_ended
|
||||
payload = {
|
||||
event: 'whatsapp_call.ended',
|
||||
data: {
|
||||
id: wa_call.id,
|
||||
call_id: wa_call.call_id,
|
||||
status: wa_call.status,
|
||||
conversation_id: wa_call.conversation_id
|
||||
}
|
||||
}
|
||||
ActionCable.server.broadcast("account_#{wa_call.account_id}", payload)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,181 @@
|
||||
class Whatsapp::IncomingCallService
|
||||
pattr_initialize [:inbox!, :params!]
|
||||
|
||||
def perform
|
||||
calls = params[:calls]
|
||||
return if calls.blank?
|
||||
|
||||
calls.each do |call_payload|
|
||||
process_call_event(call_payload.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_call_event(call_payload)
|
||||
event = call_payload[:event]
|
||||
|
||||
case event
|
||||
when 'call_connect'
|
||||
handle_call_connect(call_payload)
|
||||
when 'call_terminate'
|
||||
handle_call_terminate(call_payload)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call_connect(call_payload)
|
||||
contact = find_or_create_contact("+#{call_payload[:from]}")
|
||||
return unless contact
|
||||
|
||||
conversation = find_or_create_conversation(contact)
|
||||
return unless conversation
|
||||
|
||||
direction = call_payload.fetch(:direction, 'inbound')
|
||||
wa_call = create_call_record(call_payload, conversation, direction)
|
||||
create_call_activity_message(conversation, 'incoming_call', direction)
|
||||
broadcast_incoming_call(wa_call, contact, call_payload.dig(:session, :sdp))
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.warn "[WHATSAPP CALL] Duplicate call_id received: #{call_payload[:id]}"
|
||||
end
|
||||
|
||||
def create_call_record(call_payload, conversation, direction)
|
||||
WhatsappCall.create!(
|
||||
account: inbox.account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
call_id: call_payload[:id],
|
||||
direction: direction,
|
||||
status: 'ringing',
|
||||
meta: { sdp_offer: call_payload.dig(:session, :sdp), ice_servers: default_ice_servers }
|
||||
)
|
||||
end
|
||||
|
||||
def handle_call_terminate(call_payload)
|
||||
call_id = call_payload[:id]
|
||||
duration = call_payload[:duration]&.to_i
|
||||
end_reason = call_payload[:terminate_reason]
|
||||
|
||||
wa_call = WhatsappCall.find_by(call_id: call_id)
|
||||
return unless wa_call
|
||||
|
||||
final_status = wa_call.accepted? ? 'ended' : 'missed'
|
||||
wa_call.update!(
|
||||
status: final_status,
|
||||
duration_seconds: duration,
|
||||
end_reason: end_reason
|
||||
)
|
||||
|
||||
call_event = duration.to_i.positive? ? 'call_ended' : 'call_missed'
|
||||
create_call_activity_message(wa_call.conversation, call_event, wa_call.direction, duration: duration)
|
||||
broadcast_call_ended(wa_call)
|
||||
end
|
||||
|
||||
def find_or_create_contact(phone_number)
|
||||
waid = phone_number.delete('+')
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: waid,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: phone_number,
|
||||
phone_number: phone_number
|
||||
}
|
||||
).perform
|
||||
|
||||
contact_inbox&.contact
|
||||
end
|
||||
|
||||
def find_or_create_conversation(contact)
|
||||
contact_inbox = contact.contact_inboxes.find_by(inbox: inbox)
|
||||
return unless contact_inbox
|
||||
|
||||
conversation = contact_inbox.conversations.where.not(status: :resolved).last
|
||||
return conversation if conversation
|
||||
|
||||
::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { channel: 'whatsapp' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_call_activity_message(conversation, event, direction, duration: nil)
|
||||
content = call_activity_content(event, direction, duration)
|
||||
conversation.messages.create!(
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content,
|
||||
content_attributes: {
|
||||
call_event: event,
|
||||
call_direction: direction,
|
||||
call_duration_seconds: duration
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def call_activity_content(event, direction, duration)
|
||||
case event
|
||||
when 'incoming_call'
|
||||
direction == 'inbound' ? 'Incoming WhatsApp call' : 'Outgoing WhatsApp call'
|
||||
when 'call_ended'
|
||||
formatted = format_duration(duration)
|
||||
"WhatsApp call ended — #{formatted}"
|
||||
when 'call_missed'
|
||||
'Missed WhatsApp call'
|
||||
else
|
||||
'WhatsApp call'
|
||||
end
|
||||
end
|
||||
|
||||
def format_duration(seconds)
|
||||
return '0s' if seconds.nil? || seconds.zero?
|
||||
|
||||
minutes = seconds / 60
|
||||
secs = seconds % 60
|
||||
minutes.positive? ? "#{minutes}m #{secs}s" : "#{secs}s"
|
||||
end
|
||||
|
||||
def broadcast_incoming_call(wa_call, contact, sdp_offer)
|
||||
payload = {
|
||||
event: 'whatsapp_call.incoming',
|
||||
data: {
|
||||
id: wa_call.id,
|
||||
call_id: wa_call.call_id,
|
||||
direction: wa_call.direction,
|
||||
inbox_id: wa_call.inbox_id,
|
||||
conversation_id: wa_call.conversation_id,
|
||||
caller: {
|
||||
name: contact.name,
|
||||
phone: contact.phone_number,
|
||||
avatar: contact.avatar_url
|
||||
},
|
||||
sdp_offer: sdp_offer,
|
||||
ice_servers: default_ice_servers
|
||||
}
|
||||
}
|
||||
|
||||
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||
end
|
||||
|
||||
def broadcast_call_ended(wa_call)
|
||||
payload = {
|
||||
event: 'whatsapp_call.ended',
|
||||
data: {
|
||||
id: wa_call.id,
|
||||
call_id: wa_call.call_id,
|
||||
status: wa_call.status,
|
||||
duration_seconds: wa_call.duration_seconds,
|
||||
conversation_id: wa_call.conversation_id
|
||||
}
|
||||
}
|
||||
|
||||
ActionCable.server.broadcast("account_#{inbox.account_id}", payload)
|
||||
end
|
||||
|
||||
def default_ice_servers
|
||||
[{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
end
|
||||
end
|
||||
@@ -79,6 +79,58 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
"#{api_base_path}/v13.0/#{media_id}"
|
||||
end
|
||||
|
||||
def pre_accept_call(call_id)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls/#{call_id}",
|
||||
headers: api_headers,
|
||||
body: { action: 'pre_accept' }.to_json
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
def accept_call(call_id, sdp_answer)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls/#{call_id}",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
action: 'accept',
|
||||
sdp: sdp_answer,
|
||||
sdp_type: 'answer'
|
||||
}.to_json
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
def reject_call(call_id)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls/#{call_id}",
|
||||
headers: api_headers,
|
||||
body: { action: 'reject' }.to_json
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
def terminate_call(call_id)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls/#{call_id}",
|
||||
headers: api_headers,
|
||||
body: { action: 'terminate' }.to_json
|
||||
)
|
||||
response.success?
|
||||
end
|
||||
|
||||
def initiate_call(to_phone_number)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
to: to_phone_number,
|
||||
type: 'audio'
|
||||
}.to_json
|
||||
)
|
||||
response.parsed_response if response.success?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csat_template_service
|
||||
|
||||
@@ -128,6 +128,7 @@ 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?)
|
||||
json.calling_enabled resource.channel.try(:provider_config)&.dig('calling_enabled') || false
|
||||
end
|
||||
|
||||
## Voice Channel Attributes
|
||||
|
||||
@@ -296,6 +296,17 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :whatsapp_calls, only: [] do
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
end
|
||||
collection do
|
||||
post :initiate
|
||||
end
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class CreateWhatsappCalls < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :whatsapp_calls do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :inbox_id, null: false
|
||||
t.bigint :conversation_id, null: false
|
||||
t.bigint :accepted_by_agent_id
|
||||
t.string :call_id, null: false
|
||||
t.string :direction, null: false
|
||||
t.string :status, null: false, default: 'ringing'
|
||||
t.integer :duration_seconds
|
||||
t.string :end_reason
|
||||
t.jsonb :meta, null: false, default: {}
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :whatsapp_calls, :call_id, unique: true
|
||||
add_index :whatsapp_calls, [:account_id, :conversation_id]
|
||||
add_index :whatsapp_calls, [:inbox_id, :status]
|
||||
end
|
||||
end
|
||||
+27
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_05_193732) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -73,6 +73,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
t.integer "status", default: 0
|
||||
t.jsonb "internal_attributes", default: {}, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
t.integer "open_conversations_count", default: 0, null: false
|
||||
t.integer "resolved_conversations_count", default: 0, null: false
|
||||
t.integer "pending_conversations_count", default: 0, null: false
|
||||
t.integer "snoozed_conversations_count", default: 0, null: false
|
||||
t.index ["status"], name: "index_accounts_on_status"
|
||||
end
|
||||
|
||||
@@ -199,8 +203,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
t.text "description"
|
||||
t.integer "assignment_order", default: 0, null: false
|
||||
t.integer "conversation_priority", default: 0, null: false
|
||||
t.integer "fair_distribution_limit", default: 100, null: false
|
||||
t.integer "fair_distribution_window", default: 3600, null: false
|
||||
t.integer "fair_distribution_limit", default: 1, null: false
|
||||
t.integer "fair_distribution_window", default: 60, null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -442,6 +446,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
t.jsonb "provider_config", default: {}
|
||||
t.string "provider"
|
||||
t.boolean "verified_for_sending", default: false, null: false
|
||||
t.integer "imap_retry_count", default: 0, null: false
|
||||
t.datetime "imap_retry_after"
|
||||
t.index ["email"], name: "index_channel_email_on_email", unique: true
|
||||
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
|
||||
end
|
||||
@@ -1254,6 +1260,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
|
||||
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
|
||||
end
|
||||
|
||||
create_table "whatsapp_calls", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "accepted_by_agent_id"
|
||||
t.string "call_id", null: false
|
||||
t.string "direction", null: false
|
||||
t.string "status", default: "ringing", null: false
|
||||
t.integer "duration_seconds"
|
||||
t.string "end_reason"
|
||||
t.jsonb "meta", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "conversation_id"], name: "index_whatsapp_calls_on_account_id_and_conversation_id"
|
||||
t.index ["call_id"], name: "index_whatsapp_calls_on_call_id", unique: true
|
||||
t.index ["inbox_id", "status"], name: "index_whatsapp_calls_on_inbox_id_and_status"
|
||||
end
|
||||
|
||||
create_table "working_hours", force: :cascade do |t|
|
||||
t.bigint "inbox_id"
|
||||
t.bigint "account_id"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Whatsapp::CallErrors
|
||||
class NotRinging < StandardError; end
|
||||
class AlreadyAccepted < StandardError; end
|
||||
class CallFailed < StandardError; end
|
||||
end
|
||||
Reference in New Issue
Block a user