Compare commits

..
Author SHA1 Message Date
Pranav b8452453ca ignore mails from chatwoot 2026-05-03 16:30:37 -07:00
Pranav 69d8de6bc9 increase message count 2026-05-03 15:30:45 -07:00
Pranav 30060e932f Add a log for the loop 2026-05-03 14:09:52 -07:00
Pranav 54c3940edc Limit the messages synced per run 2026-05-03 14:06:15 -07:00
Pranav 724cb483e8 10k -> 2k 2026-05-03 13:32:07 -07:00
Pranav 55f1f606b9 10k emails at once 2026-05-03 13:30:30 -07:00
Pranav 68bbe2cac4 Test bulk_fetch 2026-05-03 13:07:53 -07:00
64790ea204 fix: Redirect to the conversation URL if custom_view is not available (#14340)
When an agent shares a conversation link copied from a custom view (e.g.
/custom_view/{id}/conversations/{id}), the link previously broke for
recipients who didn't have access to that custom view. The conversation
now loads regardless — if the custom view isn't available to the
recipient, they're redirected to the direct conversation URL.

### How to reproduce

1. As Agent A, open a conversation from inside a personal custom view
and copy the URL from the address bar.
2. Share the URL with Agent B who does not have access to that custom
view.
3. Before this fix, the link failed to load the conversation. After this
fix, Agent B lands on the conversation via the direct URL.

### What changed

- Added a beforeEnter guard on the conversations_through_folders route.
It checks the user's available conversation custom views (fetching them
on demand for deep links), and if the foldersId in the URL isn't among
them, redirects to the inbox_conversation route with the same
conversation_id.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-05-01 10:53:01 -07:00
517b67dfd6 feat: Add rich template preview for WhatsApp & Twilio Templates (#13206)
This PR introduces a comprehensive, display-only template preview system
for both WhatsApp and Twilio templates rendering of all supported
template types.

This lays the foundation for rich template previews across:
- Template selection modals
- Campaign creation flows
- Message composer
- Message screen.

<img width="1818" height="2058" alt="template-preview"
src="https://github.com/user-attachments/assets/9833b28c-f824-4568-8f74-6da09ac61f62"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 18:54:34 +04:00
353089473e feat(voice): Assignment aware visibility and join conflict for inbound calls (#14333)
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.

Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on

### How to test

1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 18:38:10 +04:00
91 changed files with 2042 additions and 2528 deletions
@@ -1,45 +0,0 @@
/* 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();
@@ -3,16 +3,10 @@ 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,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
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';
@@ -64,63 +58,9 @@ const navigateToConversation = conversationId => {
}
};
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).
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId);
const conversations = data?.payload || [];
const match = conversations
.filter(c => c.inbox_id === inboxId)
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
return match?.id || null;
};
const startWhatsappCall = async inboxId => {
const conversationId = await findWhatsappConversationId(inboxId);
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
if (!response?.id) {
// Permission flow returns no id — banner already handled server-side; surface to user.
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
@@ -1,25 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -1,25 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -1,20 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -1,68 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -1,22 +1,18 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'dashboard/components-next/message/chips/Audio.vue';
const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -31,36 +27,39 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const { call, attachments, contentAttributes } = useMessageContext();
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId } = useMessageContext();
const status = computed(() => call.value?.status);
const isOutbound = computed(() => call.value?.direction === 'outgoing');
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === 'audio')
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Duration lives in two places depending on which payload the FE got:
// - call.duration_seconds / call.durationSeconds (push_event_data shape)
// - content_attributes.data.duration_seconds (message-side mirror)
// Both can be camelCased by useTransformKeys upstream — check every variant.
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data || contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
// 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
);
return conversation?.meta?.assignee || null;
});
const formattedDuration = computed(() => {
const s = Number(durationSeconds.value);
if (!s || Number.isNaN(s)) return '';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
const displayAgentName = computed(() => {
if (call.value?.accepted_by_agent_name)
return call.value.accepted_by_agent_name;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => {
@@ -75,16 +74,28 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtextKey = computed(() => {
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -118,19 +129,10 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
{{ $t(labelKey) }}
</span>
<span class="text-xs text-n-slate-11">
<!-- When the audio chip is rendered it already shows duration in
its own player; suppress here to avoid two competing numbers. -->
{{
audioAttachment
? $t(subtextKey)
: formattedDuration || $t(subtextKey)
}}
{{ subtext }}
</span>
</div>
</div>
<div v-if="audioAttachment" class="px-3 pb-3 w-full">
<AudioChip :attachment="audioAttachment" class="text-n-slate-12" />
</div>
</div>
</BaseBubble>
</template>
@@ -41,33 +41,8 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
duration.value = audioPlayer.value?.duration;
};
const playbackSpeedLabel = computed(() => {
@@ -78,8 +53,7 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
duration.value = audioPlayer.value?.duration;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -1,21 +0,0 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,23 +0,0 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,21 +0,0 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -1,20 +0,0 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,33 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-if="message.buttons && message.buttons.length > 0"
class="flex flex-col gap-2"
>
<Button
v-for="(button, index) in message.buttons"
:key="index"
:label="button.text || button.title || 'Button'"
slate
class="!text-n-blue-11 w-full"
/>
</div>
</div>
</template>
@@ -10,22 +10,22 @@ defineProps({
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
<div class="flex flex-col gap-2 pt-2.5">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
class="text-sm prose prose-bubble"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<div class="flex justify-center items-center p-3">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<div class="flex justify-center items-center p-3">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
const DOC_EXTENSIONS = ['.doc'];
const mediaType = computed(() => {
if (props.message.mediaType) return props.message.mediaType;
const format = props.message.header?.format;
if (format) return format.toLowerCase();
const url = props.message.image_url || '';
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
return 'image';
});
const fileType = computed(() => {
const url = props.message.image_url || '';
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
});
</script>
<template>
<div
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<img
v-if="mediaType === 'image'"
:src="message.image_url"
class="object-cover w-full max-h-44 rounded-lg"
alt="Template media"
/>
<div
v-else-if="mediaType === 'video'"
class="overflow-hidden relative rounded-lg"
>
<video
:src="message.image_url"
class="object-cover w-full max-h-44"
controls
preload="metadata"
/>
</div>
<div v-else-if="mediaType === 'document'" class="flex items-center">
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
</div>
<span
v-if="message.content"
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const actions = computed(() => props.message.actions || []);
</script>
<template>
<div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-for="(action, index) in actions"
:key="index"
class="flex justify-center items-center p-3"
>
<Button
:label="action.title || action.text || 'Button'"
link
class="hover:!no-underline"
>
<template #icon>
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,105 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
const greet = findWhatsApp('greet');
const eventInvitation = findWhatsApp('event_invitation_static');
const orderConfirmation = findWhatsApp('order_confirmation');
const discountCoupon = findWhatsApp('discount_coupon');
const trainingVideo = findWhatsApp('training_video');
const twilioGreet = findTwilio('greet');
const shoeLaunch = findTwilio('shoe_launch');
const welcomeMessage = findTwilio('welcome_message_new');
const courseFeeReminder = findTwilio('course_fee_reminder');
</script>
<template>
<Story
title="Components/TemplatePreview"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WhatsApp - Simple Text">
<TemplatePreview
:template="greet"
:variables="getWhatsAppVariables(greet)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Simple Text (No Variables)">
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
</Variant>
<Variant title="WhatsApp - Call to Action Buttons">
<TemplatePreview
:template="eventInvitation"
:variables="getWhatsAppVariables(eventInvitation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Image Media">
<TemplatePreview
:template="orderConfirmation"
:variables="getWhatsAppVariables(orderConfirmation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Video Media">
<TemplatePreview
:template="trainingVideo"
:variables="getWhatsAppVariables(trainingVideo)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Copy Code">
<TemplatePreview
:template="discountCoupon"
:variables="getWhatsAppVariables(discountCoupon)"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio - Text">
<TemplatePreview
:template="twilioGreet"
:variables="twilioGreet.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Media">
<TemplatePreview
:template="shoeLaunch"
:variables="shoeLaunch.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Quick Reply">
<TemplatePreview
:template="welcomeMessage"
:variables="welcomeMessage.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Call to Action">
<TemplatePreview
:template="courseFeeReminder"
:variables="courseFeeReminder.variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,112 @@
<script setup>
import { computed } from 'vue';
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
import {
PLATFORMS,
TEMPLATE_TYPES,
WA_HEADER_FORMATS,
WA_MEDIA_FORMATS,
} from 'dashboard/services/TemplateConstants';
import CardTemplate from './CardTemplate.vue';
import CallToActionTemplate from './CallToActionTemplate.vue';
import MediaTemplate from './MediaTemplate.vue';
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
import QuickReplyTemplate from './QuickReplyTemplate.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
variables: {
type: Object,
default: () => ({}),
},
platform: {
type: String,
required: true,
validator: value => Object.values(PLATFORMS).includes(value),
},
});
const COMPONENT_MAP = {
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
};
const substituteVariables = (text, variables) => {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
const value = variables[variable];
return value !== undefined && value !== '' ? value : `[${variable}]`;
});
};
const processedTemplate = computed(() => {
const normalized = TemplateNormalizer.normalize(
props.template,
props.platform
);
let content = '';
let imageUrl = '';
let title = '';
let footer = '';
if (props.platform === PLATFORMS.WHATSAPP) {
content = normalized.body?.text || '';
if (normalized.header) {
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
imageUrl = normalized.header.example?.header_handle?.[0] || '';
}
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
title = normalized.header.text || '';
}
}
footer = normalized.footer?.text || '';
} else {
content = normalized.body || '';
if (normalized.media && normalized.media.length > 0) {
imageUrl = normalized.media[0];
}
}
const buttons = normalized.buttons?.length
? normalized.buttons
: normalized.actions || [];
return {
...normalized,
content: substituteVariables(content, props.variables),
title: substituteVariables(title, props.variables),
footer: substituteVariables(footer, props.variables),
image_url: substituteVariables(imageUrl, props.variables),
buttons,
actions: normalized.actions || [],
};
});
const previewComponent = computed(
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
);
</script>
<template>
<div class="template-preview">
<component :is="previewComponent" :message="processedTemplate" />
</div>
</template>
@@ -0,0 +1,199 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
</script>
<template>
<Story
title="Components/TemplatePreviewExamples"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WA: Event Invitation (Buttons)">
<TemplatePreview
:template="whatsAppTemplates[0]"
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Purchase Receipt (Document)">
<TemplatePreview
:template="whatsAppTemplates[1]"
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Discount Coupon (Copy Code)">
<TemplatePreview
:template="whatsAppTemplates[2]"
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Support Callback (Phone)">
<TemplatePreview
:template="whatsAppTemplates[3]"
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Training Video (Video)">
<TemplatePreview
:template="whatsAppTemplates[4]"
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Order Confirmation (Image)">
<TemplatePreview
:template="whatsAppTemplates[5]"
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Product Launch (Image + Footer)">
<TemplatePreview
:template="whatsAppTemplates[6]"
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Technician Visit (Header + Buttons)">
<TemplatePreview
:template="whatsAppTemplates[7]"
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Greet (Simple Text)">
<TemplatePreview
:template="whatsAppTemplates[8]"
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Hello World (Header + Footer)">
<TemplatePreview
:template="whatsAppTemplates[9]"
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Feedback Request (Button)">
<TemplatePreview
:template="whatsAppTemplates[10]"
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Address Update (Header)">
<TemplatePreview
:template="whatsAppTemplates[11]"
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Delivery Confirmation">
<TemplatePreview
:template="whatsAppTemplates[12]"
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio: Shoe Launch (Media)">
<TemplatePreview
:template="twilioTemplates[0]"
:variables="twilioTemplates[0].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch Custom Price (Media)">
<TemplatePreview
:template="twilioTemplates[1]"
:variables="twilioTemplates[1].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch (Media)">
<TemplatePreview
:template="twilioTemplates[2]"
:variables="twilioTemplates[2].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Greet (Text)">
<TemplatePreview
:template="twilioTemplates[3]"
:variables="twilioTemplates[3].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Order Status (Text)">
<TemplatePreview
:template="twilioTemplates[4]"
:variables="twilioTemplates[4].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Hello World (Text)">
<TemplatePreview
:template="twilioTemplates[5]"
:variables="twilioTemplates[5].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message New (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[6]"
:variables="twilioTemplates[6].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[7]"
:variables="twilioTemplates[7].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[8]"
:variables="twilioTemplates[8].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
<TemplatePreview
:template="twilioTemplates[9]"
:variables="twilioTemplates[9].variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -8,7 +8,7 @@ defineProps({
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -0,0 +1,26 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<div v-if="message.title" class="text-base font-bold">
{{ message.title }}
</div>
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
<span v-dompurify-html="message.content" />
</div>
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
{{ message.footer }}
</div>
</div>
</template>
@@ -0,0 +1,6 @@
// Main Template Preview Component
export { default as TemplatePreview } from './TemplatePreview.vue';
// Core Services
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
@@ -0,0 +1,209 @@
export const twilioTemplates = [
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '100$',
},
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
friendly_name: 'shoe_launch',
template_type: 'media',
},
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '400$',
},
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
friendly_name: 'product_launch_custom_price',
template_type: 'media',
},
{
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
types: {
'twilio/media': {
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
friendly_name: 'product_launch',
template_type: 'media',
},
{
body: 'Hey {{1}}, how may I help you?',
types: {
'twilio/text': {
body: 'Hey {{1}}, how may I help you?',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
},
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
friendly_name: 'greet',
template_type: 'text',
},
{
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
types: {
'twilio/text': {
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
3: '12345',
},
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
friendly_name: 'order_status',
template_type: 'text',
},
{
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
types: {
'twilio/text': {
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
friendly_name: 'hello_world',
template_type: 'text',
},
{
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
actions: [
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Support_payload',
title: 'Support',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
friendly_name: 'welcome_message_new',
template_type: 'quick_reply',
},
{
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
types: {
'twilio/quick-reply': {
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
actions: [
{
id: 'Chatwoot Cloud_payload',
title: 'Chatwoot Cloud',
},
{
id: 'Self-hosted Chatwoot_payload',
title: 'Self-hosted Chatwoot',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
friendly_name: 'saas_whatsapp_question',
template_type: 'quick_reply',
},
{
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
actions: [
{
id: 'Support_payload',
title: 'Support',
},
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Demo_payload',
title: 'Demo',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
friendly_name: 'welcome_message',
template_type: 'quick_reply',
},
{
types: {
'twilio/call-to-action': {
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
friendly_name: 'course_fee_reminder',
template_type: 'call_to_action',
},
];
@@ -0,0 +1,408 @@
export const whatsAppTemplates = [
{
id: '1381151706284063',
name: 'event_invitation_static',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'F1',
param_name: 'event_name',
},
{
example: 'Dubai',
param_name: 'location',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '767076159336759',
name: 'purchase_receipt',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
example: {
header_handle: [
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
],
},
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
example: {
body_text: [['credit', 'CS Mutual', 'receipt']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1469258364071127',
name: 'discount_coupon',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
example: {
body_text_named_params: [
{
example: '30',
param_name: 'discount_percentage',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
example: ['SAVE1OFF'],
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1075221534579807',
name: 'support_callback',
status: 'APPROVED',
category: 'UTILITY',
language: 'en',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
{
example: '232323',
param_name: 'ticket_id',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+2112121212',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1023596726651144',
name: 'training_video',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'VIDEO',
example: {
header_handle: [
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
],
},
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'john',
param_name: 'name',
},
{
example: 'July 31',
param_name: 'date',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1106685194739985',
name: 'order_confirmation',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
example: {
body_text: [['blue canvas shoes']],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'POSITIONAL',
},
{
id: '1242180011253003',
name: 'product_launch',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
},
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'blue',
param_name: 'color',
},
],
},
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1449876326175680',
name: 'technician_visit',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
example: {
body_text: [
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
parameter_format: 'POSITIONAL',
},
{
id: '997298832221901',
name: 'greet',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'John',
param_name: 'customer_name',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '632315222954611',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameter_format: 'POSITIONAL',
},
{
id: '787864066907971',
name: 'feedback_request',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1938057163677205',
name: 'address_update',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
example: {
body_text: [['John', '123 Main St', 'support@telco.com']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1644094842949394',
name: 'delivery_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
type: 'BODY',
example: {
body_text: [['John', 'Jan 1, 2024']],
},
},
],
parameter_format: 'POSITIONAL',
},
];
// Helper function to get variable values from examples
export const getWhatsAppVariables = template => {
const variables = {};
template.components?.forEach(component => {
if (component.example?.body_text_named_params) {
component.example.body_text_named_params.forEach(param => {
variables[param.param_name] = param.example;
});
}
if (component.example?.body_text) {
component.example.body_text[0]?.forEach((value, index) => {
variables[(index + 1).toString()] = value;
});
}
});
return variables;
};
@@ -1,9 +1,8 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -22,40 +21,16 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
const caller = call?.caller;
return {
conversation,
inbox,
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
caller?.phone ||
'Unknown caller',
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
avatar: sender?.avatar || sender?.thumbnail,
};
};
@@ -74,25 +49,21 @@ const handleEndCall = async () => {
};
const handleJoinCall = async call => {
if (!call || isJoining.value) return;
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
// After a hard refresh the conversation may not be hydrated yet — but the call
// object already carries inboxId from the cable / seeding path, so accept can
// proceed without it. Twilio still needs inboxId for initializeDevice; falls
// back to the conversation's inbox_id when present.
const result = await joinCall({
conversationId: call.conversationId,
inboxId: call.inboxId || conversation?.inbox_id,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result && conversation) {
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
@@ -191,30 +162,6 @@ watch(
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
@@ -4,7 +4,6 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
@@ -13,13 +12,6 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
const props = defineProps({
@@ -99,45 +91,6 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
});
callsStore.setCallActive(response.call_id);
} catch (error) {
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
</script>
<template>
@@ -199,18 +152,6 @@ const startWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="whatsappCallSession.isInitiating.value"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -1,22 +1,14 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -26,7 +18,6 @@ export function useCallSession() {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
watch(
hasActiveCall,
@@ -41,83 +32,20 @@ export function useCallSession() {
{ immediate: true }
);
// Browser-native confirm prompt when reload/close happens mid-call. Reload
// tears down the WebRTC session permanently for WhatsApp (no rejoin) and
// drops the agent leg for Twilio. Also warn while a call is ringing — the
// cable broadcast that delivered the incoming-call event isn't replayed on
// refresh, so the agent loses the ability to accept it.
const handleBeforeUnload = event => {
if (!hasActiveCall.value && !hasIncomingCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// Hydrate the calls store from already-loaded conversation messages. The
// voice_call.incoming / message.created cable events are one-shot and aren't
// replayed when the page reconnects, so without this seeding a hard refresh
// during a ringing call would leave the FloatingCallWidget empty even though
// the call is still ringing on Meta's side.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
conversations.forEach(conv => {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== 'voice_call') return;
if (msg.call?.status !== 'ringing') return;
handleVoiceCallCreated(msg, currentUserId);
});
});
};
// pagehide fires after the user confirms the refresh prompt. Terminate the
// active call only — its WebRTC session dies with the page and can't be
// rejoined. Ringing calls intentionally stay alive on Meta so the agent can
// pick them up after the page reloads (FloatingCallWidget rehydrates them
// via seedCallsFromHydratedMessages once the conversation messages land).
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
seedCallsFromHydratedMessages();
});
// Conversations are typically fetched after this composable mounts, so the
// initial seed pass runs before any messages exist. Re-seed whenever the
// conversation list changes — addCall is idempotent (it merges by callSid).
watch(
() => store.getters.getAllConversations?.length,
() => seedCallsFromHydratedMessages()
);
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
if (isWhatsappCall(findCall(callSid))) {
await whatsappSession.endActiveCall();
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -129,18 +57,6 @@ export function useCallSession() {
isJoining.value = true;
try {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -161,12 +77,13 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
if (error?.response?.status === 409) {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Tear down any half-built WebRTC state so the user's next click starts
// fresh; otherwise the leftover pc + mic stream survives and confuses
// the second-attempt SDP exchange.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -174,12 +91,7 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
whatsappSession.rejectIncomingCall(call.callId);
} else {
TwilioVoiceClient.endClientCall();
}
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
@@ -1,388 +0,0 @@
import { ref } from 'vue';
import Cookies from 'js-cookie';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Browser ↔ Meta WebRTC is a singleton — only one PeerConnection at a time can
// hold the user's mic. Module-level state lets cable handlers and the pagehide
// listener reach the live session without prop-drilling refs through composables.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
// Lazily attach a hidden <audio autoplay> to the document so Meta's track
// actually plays through the speakers — without this, mic flows to Meta but
// the user hears nothing back.
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
// play() may reject under autoplay policies; surface to console but don't crash the call.
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// Smaller timeslice → chunks flush to memory every second so a remote hangup
// that races cleanup still leaves data behind to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls don't get ice_servers from the backend (call doesn't exist yet
// at offer time). Without STUN the browser only has host candidates which can't
// reach Meta through NAT, so the browser→Meta direction silently drops media.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
};
// Mix local mic + remote audio via Web Audio so the recording captures both legs.
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// Without at least one remote track, createMediaStreamSource on remoteStream
// wires up to nothing — the recorded mix is effectively just silence.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies. Resume so the
// graph actually runs; otherwise the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Add to the stable placeholder stream so any sources/recorders referencing
// it stay connected — never reassign the variable, since the recorder's
// audioContext source taps the original MediaStream object.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Defer recorder setup until we actually have remote tracks; createMediaStreamSource
// on an empty MediaStream is unreliable across browsers.
setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* best-effort — server-side idempotency guard handles a retry */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const error = ref(null);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
// Recorder fires from ontrack once remote tracks arrive.
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (cable's voice_call.incoming raced
// the click), so fall back to GET /whatsapp_calls/:id which exposes the
// SDP offer + ICE servers from the show jbuilder.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
error.value = null;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// Permission flow returns no call id — let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
error.value = e;
throw e;
} finally {
isInitiating.value = false;
}
};
const endActiveCall = async () => {
if (!activeCallId) {
cleanup();
return;
}
intentionallyClosing = true;
const callIdSnapshot = activeCallId;
try {
await stopRecorderAndUpload(callIdSnapshot);
await WhatsappCallsAPI.terminate(callIdSnapshot).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
error,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance; expose the shared session
// surface so they can apply the outbound answer onto the live PeerConnection.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Recorder + audio playback fire from ontrack as soon as Meta's tracks arrive.
};
export const hasActiveWhatsappCall = () => Boolean(activeCallId);
// Used by the calls store as a sync teardown safety net.
export const cleanupWhatsappSession = () => cleanup();
// Cable-driven end (contact hung up / call timed out). Flush any in-memory
// recorder chunks and upload them so the resulting message bubble shows the
// audio + transcript — without this, only agent-initiated hangups upload.
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
// Mute helpers — toggle the mic track's enabled flag (instantaneous, no renegotiation).
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const isWhatsappCallMuted = () => {
if (!localStream) return false;
const tracks = localStream.getAudioTracks();
if (!tracks.length) return false;
return !tracks[0].enabled;
};
// devise-token-auth requires access-token / client / uid headers on every
// request — navigator.sendBeacon can't set custom headers, so the dashboard
// stashes the auth payload in the cw_d_session_info cookie at login and we
// rehydrate it here for unload-time requests.
const getDeviseAuthHeaders = () => {
try {
const raw = Cookies.get('cw_d_session_info');
if (!raw) return null;
const session = JSON.parse(raw);
return {
'access-token': session['access-token'] || '',
client: session.client || '',
uid: session.uid || '',
expiry: session.expiry || '',
'token-type': session['token-type'] || 'Bearer',
};
} catch (_) {
return null;
}
};
// Best-effort terminate beacon for any WhatsApp call — the backend's terminate
// endpoint handles both ringing and in_progress states (terminate while ringing
// records 'no_answer' which is the right shape for "agent left the page").
// Browser-direct WebRTC has no rejoin path, so the only sensible thing on page
// close is to release the call on Meta's side.
//
// Uses fetch+keepalive instead of navigator.sendBeacon so we can attach the
// devise-token-auth headers — without them the request 401s and the call
// stays open on Meta until the carrier-side timeout (~60s).
export const sendWhatsappCallBeacon = callId => {
if (!callId) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const headers = getDeviseAuthHeaders();
if (!headers) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
try {
fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...headers },
body: '{}',
}).catch(() => {});
} catch (_) {
/* noop */
}
};
// Backward-compat wrapper for the active-call case — guarded by
// intentionallyClosing so we don't double-terminate after an explicit hangup.
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
sendWhatsappCallBeacon(activeCallId);
};
@@ -4,11 +4,6 @@ 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 { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
handleWhatsappRemoteEnd,
} from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -40,11 +35,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
// WhatsApp call SDP exchange happens via these events; Twilio-shaped voice_call.*
// events also flow through here but are ignored when provider !== 'whatsapp'.
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -215,44 +205,6 @@ 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
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
// Caller info for the FloatingCallWidget so it doesn't show "Unknown caller"
// before the conversation/contact has loaded into the store.
caller: data.caller,
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
applyOutboundAnswer(data.id, data.sdp_answer).catch(() => {});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== 'whatsapp') return;
// Must await the upload-and-cleanup BEFORE removeCall, because the store's
// sync teardownByProvider -> cleanupWhatsappSession would otherwise wipe
// mediaRecorder + recorderChunks before any upload microtask gets to run.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop — upload is best-effort */
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
+3 -6
View File
@@ -16,7 +16,6 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -26,11 +25,9 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
+63 -12
View File
@@ -22,15 +22,37 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
const extractAssigneeId = conversation => {
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
};
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
if (currentUserId == null) return false;
return !!assigneeId && assigneeId !== currentUserId;
};
const shouldShowCall = ({
callDirection,
senderId,
assigneeId,
currentUserId,
}) => {
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
// Outbound calls are scoped to the initiator via shouldSkipCall; the
// conversation may be auto-assigned to a different agent on creation, so
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
if (callDirection === 'outbound') return true;
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
};
function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -38,16 +60,23 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callId, provider, callDirection, conversationId, senderId } =
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
return;
}
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
callDirection,
senderId,
@@ -57,8 +86,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const {
callSid,
status,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
const callsStore = useCallsStore();
@@ -70,11 +105,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callSid,
});
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (
!shouldShowCall({
callDirection,
senderId,
assigneeId,
currentUserId,
})
) {
callsStore.removeCall(callSid);
return;
}
if (isNewCall) {
if (status === 'ringing') {
callsStore.addCall({
callSid,
conversationId,
@@ -83,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
export function syncConversationCallVisibility(conversation, currentUserId) {
const assigneeId = extractAssigneeId(conversation);
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.removeCallsForConversation(conversation.id);
}
@@ -83,7 +83,8 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered"
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -97,10 +98,6 @@
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
"WHATSAPP_CALL": "Start WhatsApp call",
"WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.",
"WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.",
"WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.",
"SLA_STATUS": {
"FRT": "FRT {status}",
"NRT": "NRT {status}",
@@ -301,9 +298,7 @@
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
"UNMUTE": "Unmute mic"
"END_CALL": "End call"
}
},
"EMAIL_TRANSCRIPT": {
@@ -637,8 +637,7 @@
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT",
"VOICE": "Voice",
"CALLS": "Calls"
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
@@ -649,20 +648,6 @@
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
},
"WHATSAPP_CALLING": {
"ENABLE": {
"LABEL": "Enable WhatsApp Calling",
"DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp."
},
"PHONE_NUMBER": {
"LABEL": "Business phone number",
"HELP_TEXT": "WhatsApp number that customers will call."
},
"HOW_IT_WORKS": {
"LABEL": "How it works",
"DESCRIPTION": "Calls are placed peer-to-peer between the agent's browser and Meta — no extra credentials are required. Make sure the agent's browser has microphone permission for this site."
}
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
@@ -815,10 +800,6 @@
"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_ENABLED": {
"LABEL": "Enable voice calling",
"DESCRIPTION": "Allow agents to start and receive WhatsApp voice calls on this inbox. Available only on embedded-signup WhatsApp Cloud channels with calling permission granted by Meta."
},
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
@@ -1,5 +1,6 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import store from '../../../store';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
'conversation_participating_manage',
];
const isFolderAvailable = async folderId => {
let folders = store.getters['customViews/getConversationCustomViews'];
if (!folders.length) {
await store.dispatch('customViews/get', 'conversation');
folders = store.getters['customViews/getConversationCustomViews'];
}
return folders.some(folder => folder.id === Number(folderId));
};
const redirectFolderListIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({ name: 'home', params: { accountId: to.params.accountId } });
};
const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
if (await isFolderAvailable(to.params.id)) {
next();
return;
}
next({
name: 'inbox_conversation',
params: {
accountId: to.params.accountId,
conversation_id: to.params.conversation_id,
},
});
};
export default {
routes: [
{
@@ -113,6 +145,7 @@ export default {
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
beforeEnter: redirectFolderListIfUnavailable,
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -125,6 +158,7 @@ export default {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
beforeEnter: redirectFolderConversationIfUnavailable,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
@@ -22,7 +22,6 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import WhatsappCallingPage from './settingsPage/WhatsappCallingPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -49,7 +48,6 @@ export default {
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
WhatsappCallingPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -251,16 +249,6 @@ export default {
];
}
if (this.isAWhatsAppCloudChannel && this.isEmbeddedSignupWhatsApp) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'calls-configuration',
name: this.$t('INBOX_MGMT.TABS.CALLS'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -1274,12 +1262,6 @@ export default {
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'calls-configuration'"
class="mx-6 max-w-4xl"
>
<WhatsappCallingPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -11,7 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import CSATTemplate from 'dashboard/components-next/template-preview/CSATTemplate.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -1,91 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
callingEnabled: this.inbox.provider_config?.calling_enabled || false,
isUpdating: false,
};
},
computed: {
phoneNumber() {
return (
this.inbox.provider_config?.phone_number || this.inbox.phone_number
);
},
},
watch: {
'inbox.provider_config.calling_enabled'(val) {
this.callingEnabled = val || false;
},
},
methods: {
async updateCallingSettings() {
this.isUpdating = true;
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'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="callingEnabled"
:header="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.WHATSAPP_CALLING.ENABLE.DESCRIPTION')"
/>
<SettingsFieldSection
v-if="phoneNumber"
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.PHONE_NUMBER.HELP_TEXT')"
>
<woot-code :script="phoneNumber" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.LABEL')"
:help-text="$t('INBOX_MGMT.WHATSAPP_CALLING.HOW_IT_WORKS.DESCRIPTION')"
/>
<div>
<NextButton
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateCallingSettings"
/>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
export const PLATFORMS = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
};
export const WA_COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
FOOTER: 'FOOTER',
BUTTONS: 'BUTTONS',
};
export const WA_HEADER_FORMATS = {
TEXT: 'TEXT',
IMAGE: 'IMAGE',
VIDEO: 'VIDEO',
DOCUMENT: 'DOCUMENT',
};
export const WA_MEDIA_FORMATS = [
WA_HEADER_FORMATS.IMAGE,
WA_HEADER_FORMATS.VIDEO,
WA_HEADER_FORMATS.DOCUMENT,
];
export const WA_BUTTON_TYPES = {
COPY_CODE: 'COPY_CODE',
};
export const WA_PARAM_FORMATS = {
POSITIONAL: 'POSITIONAL',
NAMED: 'NAMED',
};
export const TWILIO_TYPE_PREFIX = 'twilio/';
export const TWILIO_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
CATALOG: 'catalog',
};
export const TEMPLATE_TYPES = {
WHATSAPP_TEXT: 'whatsapp-text',
WHATSAPP_TEXT_HEADER: 'whatsapp-text-header',
WHATSAPP_MEDIA_IMAGE: 'whatsapp-media-image',
WHATSAPP_MEDIA_VIDEO: 'whatsapp-media-video',
WHATSAPP_MEDIA_DOCUMENT: 'whatsapp-media-document',
WHATSAPP_INTERACTIVE: 'whatsapp-interactive',
WHATSAPP_COPY_CODE: 'whatsapp-copy-code',
TWILIO_TEXT: 'twilio-text',
TWILIO_MEDIA: 'twilio-media',
TWILIO_QUICK_REPLY: 'twilio-quick-reply',
TWILIO_CALL_TO_ACTION: 'twilio-call-to-action',
TWILIO_CARD: 'twilio-card',
};
@@ -0,0 +1,114 @@
import { TemplateTypeDetector } from './TemplateTypeDetector';
import {
PLATFORMS,
TWILIO_TYPE_PREFIX,
WA_COMPONENT_TYPES,
WA_PARAM_FORMATS,
} from './TemplateConstants';
/**
* TemplateNormalizer - Convert platform-specific formats to unified structure
*/
export class TemplateNormalizer {
static normalizeWhatsApp(template) {
const components = template.components || [];
return {
id: template.id,
name: template.name,
platform: PLATFORMS.WHATSAPP,
type: TemplateTypeDetector.detectWhatsAppType(template),
parameterFormat: template.parameter_format || WA_PARAM_FORMATS.POSITIONAL,
header: components.find(c => c.type === WA_COMPONENT_TYPES.HEADER),
body: components.find(c => c.type === WA_COMPONENT_TYPES.BODY),
footer: components.find(c => c.type === WA_COMPONENT_TYPES.FOOTER),
buttons:
components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS)?.buttons ||
[],
variables: this.extractWhatsAppVariables(template),
category: template.category,
language: template.language,
originalTemplate: template,
};
}
static normalizeTwilio(template) {
const typeKey =
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
Object.keys(template.types || {})
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
.find(key => key);
const hyphenatedKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/_/g, '-')}`;
const underscoreKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/-/g, '_')}`;
const typeData =
template.types?.[hyphenatedKey] || template.types?.[underscoreKey] || {};
return {
contentSid: template.content_sid,
name: template.friendly_name,
platform: PLATFORMS.TWILIO,
type: TemplateTypeDetector.detectTwilioType(template),
body: template.body || typeData.body,
media: typeData.media || [],
mediaType: template.media_type || null,
actions: typeData.actions || [],
variables: template.variables || {},
category: template.category || 'utility',
language: template.language || 'en',
originalTemplate: template,
};
}
static extractWhatsAppVariables(template) {
const variables = {};
const components = template.components || [];
components.forEach(component => {
if (component.text) {
const matches = component.text.match(/\{\{([^}]+)\}\}/g) || [];
matches.forEach(match => {
const variable = match.replace(/[{}]/g, '');
if (template.parameter_format === WA_PARAM_FORMATS.NAMED) {
const example =
component.example?.body_text_named_params?.find(
p => p.param_name === variable
)?.example || '';
variables[variable] = example;
} else {
const position = parseInt(variable, 10) - 1;
const example = component.example?.body_text?.[0]?.[position] || '';
variables[variable] = example;
}
});
}
if (component.buttons) {
component.buttons.forEach(button => {
if (button.url) {
const matches = button.url.match(/\{\{([^}]+)\}\}/g) || [];
matches.forEach(match => {
const variable = match.replace(/[{}]/g, '');
const example = button.example?.[0] || '';
variables[variable] = example;
});
}
});
}
});
return variables;
}
static normalize(template, platform) {
switch (platform) {
case PLATFORMS.WHATSAPP:
return this.normalizeWhatsApp(template);
case PLATFORMS.TWILIO:
return this.normalizeTwilio(template);
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
}
@@ -0,0 +1,143 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { TemplateNormalizer } from './TemplateNormalizer';
describe('TemplateNormalizer', () => {
it('normalizes Twilio call-to-action templates with hyphenated keys', () => {
const template = {
types: {
'twilio/call-to-action': {
body: 'CTA body for {{date}}',
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
},
},
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX123',
friendly_name: 'cta_example',
template_type: 'call-to-action',
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-call-to-action');
expect(normalized.body).toBe('CTA body for {{date}}');
expect(normalized.actions).toHaveLength(1);
expect(normalized.variables).toEqual({ date: '01-Jan-2026' });
});
it('normalizes Twilio quick replies', () => {
const template = {
template_type: 'quick_reply',
types: {
'twilio/quick-reply': {
body: 'Pick an option',
actions: [{ id: 'a', title: 'Option A' }],
},
},
variables: {},
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-quick-reply');
expect(normalized.body).toBe('Pick an option');
expect(normalized.actions).toEqual([{ id: 'a', title: 'Option A' }]);
});
it('normalizes Twilio media templates', () => {
const template = {
template_type: 'media',
body: 'Media body {{1}}',
types: {
'twilio/media': {
body: 'Media body {{1}}',
media: ['https://example.com/image.jpg'],
},
},
variables: { 1: 'value' },
};
const normalized = TemplateNormalizer.normalizeTwilio(template);
expect(normalized.type).toBe('twilio-media');
expect(normalized.media).toEqual(['https://example.com/image.jpg']);
expect(normalized.body).toBe('Media body {{1}}');
});
it('extracts WhatsApp named variables', () => {
const template = {
parameter_format: 'NAMED',
components: [
{
type: 'BODY',
text: 'Hi {{name}}',
example: {
body_text_named_params: [{ param_name: 'name', example: 'John' }],
},
},
],
};
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
expect(variables).toMatchObject({
name: 'John',
});
});
it('extracts WhatsApp positional variables', () => {
const template = {
parameter_format: 'POSITIONAL',
components: [
{
type: 'BODY',
text: 'Hi {{1}}',
example: {
body_text: [['positional']],
},
},
],
};
const variables = TemplateNormalizer.extractWhatsAppVariables(template);
expect(variables).toMatchObject({
1: 'positional',
});
});
it('normalizes WhatsApp media image templates', () => {
const template = {
id: '1',
name: 'order_confirmation',
parameter_format: 'POSITIONAL',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: { header_handle: ['https://example.com/image.jpg'] },
},
{ type: 'BODY', text: 'Hi {{1}}', example: { body_text: [['John']] } },
],
language: 'en',
};
const normalized = TemplateNormalizer.normalizeWhatsApp(template);
expect(normalized.type).toBe('whatsapp-media-image');
expect(normalized.header.format).toBe('IMAGE');
expect(normalized.body.text).toBe('Hi {{1}}');
expect(normalized.variables).toMatchObject({ 1: 'John' });
});
});
@@ -0,0 +1,63 @@
import {
TEMPLATE_TYPES,
TWILIO_TYPE_PREFIX,
TWILIO_TYPES,
WA_BUTTON_TYPES,
WA_COMPONENT_TYPES,
WA_HEADER_FORMATS,
} from './TemplateConstants';
/**
* TemplateTypeDetector - Unified service to identify template types across platforms
*/
export class TemplateTypeDetector {
static detectWhatsAppType(template) {
const components = template.components || [];
const header = components.find(c => c.type === WA_COMPONENT_TYPES.HEADER);
const buttons = components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS);
if (header?.format === WA_HEADER_FORMATS.IMAGE)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE;
if (header?.format === WA_HEADER_FORMATS.VIDEO)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO;
if (header?.format === WA_HEADER_FORMATS.DOCUMENT)
return TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT;
if (buttons) {
const hasCopyCode = buttons.buttons?.some(
b => b.type === WA_BUTTON_TYPES.COPY_CODE
);
if (hasCopyCode) return TEMPLATE_TYPES.WHATSAPP_COPY_CODE;
return TEMPLATE_TYPES.WHATSAPP_INTERACTIVE;
}
if (header?.format === WA_HEADER_FORMATS.TEXT)
return TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER;
return TEMPLATE_TYPES.WHATSAPP_TEXT;
}
static detectTwilioType(template) {
const typeFromTemplate =
template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
Object.keys(template.types || {})
.map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
.find(typeKey => typeKey);
const templateType = (typeFromTemplate || '')
.replace(/-/g, '_')
.replace(/__/g, '_');
switch (templateType) {
case TWILIO_TYPES.MEDIA:
return TEMPLATE_TYPES.TWILIO_MEDIA;
case TWILIO_TYPES.QUICK_REPLY:
return TEMPLATE_TYPES.TWILIO_QUICK_REPLY;
case TWILIO_TYPES.CALL_TO_ACTION:
return TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION;
case TWILIO_TYPES.CATALOG:
return TEMPLATE_TYPES.TWILIO_CARD;
default:
return TEMPLATE_TYPES.TWILIO_TEXT;
}
}
}
@@ -0,0 +1,106 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { TemplateTypeDetector } from './TemplateTypeDetector';
describe('TemplateTypeDetector', () => {
it('detects Twilio call-to-action from hyphenated template_type', () => {
const template = {
template_type: 'call-to-action',
types: {
'twilio/call-to-action': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-call-to-action'
);
});
it('detects Twilio call-to-action when only types key is present', () => {
const template = {
types: {
'twilio/call_to_action': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-call-to-action'
);
});
it('detects Twilio quick reply', () => {
const template = {
template_type: 'quick_reply',
types: {
'twilio/quick-reply': {},
},
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-quick-reply'
);
});
it('detects WhatsApp text-with-header and media', () => {
const base = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
{ type: 'BODY', text: 'Body' },
],
};
expect(TemplateTypeDetector.detectWhatsAppType(base)).toBe(
'whatsapp-text-header'
);
const withImage = {
...base,
components: [{ type: 'HEADER', format: 'IMAGE' }, base.components[1]],
};
expect(TemplateTypeDetector.detectWhatsAppType(withImage)).toBe(
'whatsapp-media-image'
);
});
it('detects WhatsApp copy code vs call-to-action buttons', () => {
const copyCode = {
components: [
{ type: 'BODY', text: 'Hi' },
{
type: 'BUTTONS',
buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
},
],
};
const callToAction = {
components: [
{ type: 'BODY', text: 'Hi' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', text: 'Visit website' }],
},
],
};
expect(TemplateTypeDetector.detectWhatsAppType(copyCode)).toBe(
'whatsapp-copy-code'
);
expect(TemplateTypeDetector.detectWhatsAppType(callToAction)).toBe(
'whatsapp-interactive'
);
});
it('detects Twilio media', () => {
const template = {
template_type: 'media',
types: { 'twilio/media': {} },
};
expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
'twilio-media'
);
});
});
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -393,19 +394,18 @@ const actions = {
}
},
updateConversation({ commit, dispatch }, conversation) {
const {
meta: { sender },
} = conversation;
updateConversation({ commit, dispatch, rootGetters }, conversation) {
const sender = conversation.meta?.sender;
commit(types.UPDATE_CONVERSATION, conversation);
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
dispatch('contacts/setContact', sender);
if (sender) dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
@@ -368,7 +368,7 @@ export const templates = [
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
@@ -400,7 +400,7 @@ export const templates = [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+16506677566',
phone_number: '+23232323',
},
],
},
+20 -33
View File
@@ -1,16 +1,7 @@
import { defineStore } from 'pinia';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { cleanupWhatsappSession } from 'dashboard/composables/useWhatsappCallSession';
import { TERMINAL_STATUSES } from 'dashboard/helper/voice';
const teardownByProvider = call => {
if (call?.provider === 'whatsapp') {
cleanupWhatsappSession();
} else {
TwilioVoiceClient.endClientCall();
}
};
export const useCallsStore = defineStore('calls', {
state: () => ({
calls: [],
@@ -25,32 +16,15 @@ export const useCallsStore = defineStore('calls', {
actions: {
handleCallStatusChanged({ callSid, status }) {
if (!TERMINAL_STATUSES.includes(status)) return;
const call = this.calls.find(c => c.callSid === callSid);
// For WhatsApp, the upload-and-cleanup must happen before the recorder
// state is wiped — that runs from the voice_call.ended cable handler.
// If we tear down here (race-winning the cable end-event), the recorder
// chunks are gone before they get uploaded, so the recording is lost.
// Just drop the call from the store; voice_call.ended will idempotently
// finish cleanup once it arrives.
if (call?.provider === 'whatsapp') {
this.calls = this.calls.filter(c => c.callSid !== callSid);
return;
if (TERMINAL_STATUSES.includes(status)) {
this.removeCall(callSid);
}
this.removeCall(callSid);
},
addCall(callData) {
if (!callData?.callSid) 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).
Object.assign(existing, callData, { isActive: existing.isActive });
return;
}
const exists = this.calls.some(call => call.callSid === callData.callSid);
if (exists) return;
this.calls.push({
...callData,
@@ -61,7 +35,7 @@ export const useCallsStore = defineStore('calls', {
removeCall(callSid) {
const callToRemove = this.calls.find(c => c.callSid === callSid);
if (callToRemove?.isActive) {
teardownByProvider(callToRemove);
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(c => c.callSid !== callSid);
},
@@ -74,13 +48,26 @@ export const useCallsStore = defineStore('calls', {
},
clearActiveCall() {
const active = this.calls.find(c => c.isActive);
teardownByProvider(active);
TwilioVoiceClient.endClientCall();
this.calls = this.calls.filter(call => !call.isActive);
},
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
removeCallsForConversation(conversationId) {
const callsToRemove = this.calls.filter(
call => call.conversationId === conversationId
);
if (callsToRemove.some(call => call.isActive)) {
TwilioVoiceClient.endClientCall();
}
this.calls = this.calls.filter(
call => call.conversationId !== conversationId
);
},
},
});
-2
View File
@@ -128,5 +128,3 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
-12
View File
@@ -104,23 +104,11 @@ class Attachment < ApplicationRecord
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
# which makes <audio> elements download instead of play. Force inline so the
# call-recording chip (and any other audio bubble) can stream directly.
data_url: inline_audio_url,
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def inline_audio_url
return '' unless file.attached?
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
end
def file_metadata
metadata = {
extension: extension,
-9
View File
@@ -40,15 +40,6 @@ class Channel::Whatsapp < ApplicationRecord
'Whatsapp'
end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# 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' &&
provider_config['calling_enabled'].present?
end
def provider_service
if provider == 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
+1 -3
View File
@@ -46,9 +46,7 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
# voice_call bubbles are call status indicators, not deliverable messages — skip the send pipeline
# otherwise it falls through to "Template not found" / 24-hour-window errors
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
message.private? || outgoing_message_originated_from_channel?
end
def validate_target_channel
+38 -15
View File
@@ -1,6 +1,8 @@
require 'net/imap'
class Imap::BaseFetchEmailService
MAX_MESSAGES_PER_SYNC = 500
pattr_initialize [:channel!, :interval]
def fetch_emails
@@ -77,27 +79,48 @@ class Imap::BaseFetchEmailService
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
seq_nums.each_slice(10).each do |batch|
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
# Check
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
next
end
batch_message_ids.each do |data|
message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
message_ids_with_seq.push([data.seqno, message_id])
seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
append_message_ids_for_batch(batch, message_ids_with_seq)
if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
break
end
end
message_ids_with_seq
end
def append_message_ids_for_batch(batch, message_ids_with_seq)
# Fetch only message-id only without mail body or contents.
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
# .fetch returns an array of Net::IMAP::FetchData or nil
# (instead of an empty array) if there is no matching message.
if batch_message_ids.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
return
end
batch_message_ids.each do |data|
entry = build_message_id_entry(data)
next if entry.nil?
message_ids_with_seq.push(entry)
break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
end
end
def build_message_id_entry(data)
mail = build_mail_from_string(data.attr['BODY[HEADER]'])
return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
message_id = mail.message_id
return nil if message_id.present? && email_already_present?(channel, message_id)
[data.seqno, message_id]
end
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday (or given date) and today and returns message sequence numbers.
# Return <message set>
@@ -205,5 +205,3 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
process_response(response, message)
end
end
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
@@ -142,6 +142,3 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
-13
View File
@@ -1,13 +0,0 @@
# Allow audio attachments (call recordings, voice notes) to serve inline so the
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
# forces Content-Disposition: attachment for any MIME outside the default allowlist
# (images + PDF), which makes the browser download instead of play.
Rails.application.config.active_storage.content_types_allowed_inline += %w[
audio/webm
audio/ogg
audio/mpeg
audio/mp4
audio/x-m4a
audio/wav
audio/x-wav
]
+2 -11
View File
@@ -64,6 +64,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
voice:
call_already_accepted: '%{agent_name} is already handling the call.'
assignment_policy:
not_found: Assignment policy not found
attachments:
@@ -114,13 +116,6 @@ en:
reauthorization:
generic: 'Failed to reauthorize WhatsApp. Please try again.'
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
calls:
not_enabled: 'Calling is not enabled for this inbox'
no_recording: 'No recording file provided'
no_message: 'Call has no associated message'
sdp_offer_required: 'sdp_offer is required'
contact_phone_required: 'Contact phone number is required'
permission_request_failed: 'Failed to send call permission request'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -250,10 +245,6 @@ en:
deleted: This message was deleted
whatsapp:
list_button_label: 'Choose an item'
call_permission_request_body: 'We would like to call you regarding your conversation.'
voice_call:
twilio: 'Voice Call'
whatsapp: 'WhatsApp Call'
delivery_status:
error_code: 'Error code: %{error_code}'
activity:
-15
View File
@@ -215,21 +215,6 @@ Rails.application.routes.draw do
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
if ChatwootApp.enterprise?
resources :whatsapp_calls, only: [:show] do
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
collection do
post :initiate
end
end
end
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -2,12 +2,13 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
super
end
def voice_call_inbox?
@conversation.inbox.channel.try(:voice_enabled?)
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
def token
render json: Voice::Provider::Twilio::TokenService.new(
@@ -54,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
authorize conversation, :show?
conversation
end
def render_call_already_accepted(error)
render json: { error: error.message }, status: :conflict
end
end
@@ -1,166 +0,0 @@
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
PERMISSION_REQUEST_THROTTLE = 5.minutes
before_action :set_call, only: %i[show accept reject terminate upload_recording]
before_action :set_conversation, only: :initiate
before_action :ensure_calling_enabled, only: :initiate
before_action :ensure_sdp_offer, only: :initiate
before_action :ensure_contact_phone, only: :initiate
before_action :ensure_recording_present, only: :upload_recording
before_action :ensure_call_message, only: :upload_recording
rescue_from Voice::CallErrors::NotRinging,
Voice::CallErrors::AlreadyAccepted,
Voice::CallErrors::CallFailed,
with: :render_call_error
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
def show; end
def accept
call_service.accept
end
def reject
call_service.reject
end
def terminate
call_service.terminate
end
def upload_recording
@upload_status = @call.message.with_lock { attach_recording_idempotently }
end
def initiate
@call = create_outbound_call
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
def call_service
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
end
def provider_service
@provider_service ||= @conversation.inbox.channel.provider_service
end
def set_call
@call = Current.account.calls.whatsapp.find(params[:id])
authorize @call.conversation, :show?
end
def set_conversation
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show?
end
# Twilio voice also exposes voice_enabled? but uses a different initiation path.
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
end
def ensure_sdp_offer
return if params[:sdp_offer].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
end
def ensure_contact_phone
return if @conversation.contact&.phone_number.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
def ensure_recording_present
return if params[:recording].present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
end
def ensure_call_message
return if @call.message.present?
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
end
def attach_recording_idempotently
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
'uploaded'
end
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
Current.account.calls.create!(
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
accepted_by_agent_id: Current.user.id,
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
)
end
# 138006 = no call permission yet; send opt-in template (throttled) and surface state to FE.
# Lock the conversation so concurrent initiate requests can't both pass the throttle gate
# and double-send the opt-in template.
def render_permission_request
status = nil
@conversation.with_lock do
if permission_request_throttled?
status = 'permission_pending'
next
end
sent = send_permission_request_safely
if sent
record_permission_request_wamid(sent)
status = 'permission_requested'
else
status = 'failed'
end
end
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
render json: { status: status }
end
def permission_request_throttled?
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Treat transport errors the same as a falsy provider return so the action renders 422
# rather than letting Faraday/HTTParty exceptions bubble up as 500s.
def send_permission_request_safely
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
rescue StandardError => e
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
nil
end
# Record the wamid so the reply webhook can match context.id back to this conversation.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
)
@conversation.update!(additional_attributes: attrs)
end
def render_call_error(error)
render_could_not_create_error(error.message)
end
end
@@ -84,6 +84,7 @@ class Twilio::VoiceController < ApplicationController
case twilio_direction
when 'inbound'
Voice::InboundCallBuilder.perform!(
account: current_account,
inbox: inbox,
from_number: twilio_from,
call_sid: twilio_call_sid
@@ -1,41 +0,0 @@
module Enterprise::Webhooks::WhatsappEventsJob
def handle_message_events(channel, params)
return handle_call_events(channel, params) if call_event?(params)
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
super
end
private
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
def contact_sender_id(params)
return nil if call_event?(params)
super
end
def call_event?(params)
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
end
def call_permission_reply?(params)
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
end
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
def handle_call_events(channel, params)
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
calls.each do |call_payload|
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
with_lock(lock_key, 30.seconds) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
end
end
end
def handle_call_permission_reply(channel, params)
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
end
end
+1 -27
View File
@@ -36,11 +36,6 @@ class Call < ApplicationRecord
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
enum :provider, { twilio: 0, whatsapp: 1 }
enum :direction, { incoming: 0, outgoing: 1 }
@@ -69,28 +64,6 @@ class Call < ApplicationRecord
"conf_account_#{account_id}_call_#{id}"
end
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
def self.default_ice_servers
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
[{ urls: urls }]
end
def direction_label
DISPLAY_DIRECTION[direction]
end
def ringing?
status == 'ringing'
end
def in_progress?
status == 'in_progress'
end
def terminal?
TERMINAL_STATUSES.include?(status)
end
def display_status
status.to_s.tr('_', '-')
end
@@ -119,6 +92,7 @@ class Call < ApplicationRecord
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
accepted_by_agent_name: accepted_by_agent&.available_name,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
-8
View File
@@ -1,8 +0,0 @@
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
# the upstream DropChannelVoice migration moved voice fields onto
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
# so jbuilder `.try` chains short-circuit instead of raising.
class Channel::Voice < ApplicationRecord
self.table_name = 'channel_twilio_sms'
end
@@ -3,11 +3,6 @@ module Enterprise::Concerns::Attachment
included do
after_create_commit :enqueue_audio_transcription
# Broadcast the message update so the FE bubble picks up the new audio
# attachment immediately. Without this, the FE has to wait until Whisper
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
# the bubble never gets the audio at all.
after_create_commit :broadcast_message_update_for_audio
end
private
@@ -17,11 +12,4 @@ module Enterprise::Concerns::Attachment
Messages::AudioTranscriptionJob.perform_later(id)
end
def broadcast_message_update_for_audio
return unless file_type.to_sym == :audio
return unless message
message.reload.send_update_event
end
end
@@ -13,12 +13,6 @@ module Enterprise::Conversation
super + %w[sla_policy_id]
end
# Surface call lifecycle changes to the FE: writes to additional_attributes
# call_status/call_direction should rebroadcast conversation_updated.
def allowed_keys?
super || call_attributes_changed?
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
@@ -36,13 +30,4 @@ module Enterprise::Conversation
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
def call_attributes_changed?
return false if previous_changes['additional_attributes'].blank?
# Compare before/after values for call keys — checking key presence alone
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
before, after = previous_changes['additional_attributes']
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
end
end
@@ -1,96 +0,0 @@
module Enterprise::Whatsapp::Providers::WhatsappCloudService
# Calls API + the call_permission_request interactive message both require Graph
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
def pre_accept_call(call_id, sdp_answer)
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
end
def accept_call(call_id, sdp_answer)
call_api('accept_call', call_action_body(call_id, 'accept', sdp_answer))
end
def reject_call(call_id)
call_api('reject_call', call_action_body(call_id, 'reject'))
end
def terminate_call(call_id)
call_api('terminate_call', call_action_body(call_id, 'terminate'))
end
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
response = HTTParty.post(
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
)
unless response.success?
Rails.logger.error "[WHATSAPP CALL] send_call_permission_request failed: status=#{response.code} body=#{response.body}"
return nil
end
response.parsed_response
end
def initiate_call(to_phone_number, sdp_offer)
response = HTTParty.post(
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
)
process_initiate_call_response(response)
end
private
def calls_phone_id_path
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def call_action_body(call_id, action, sdp_answer = nil)
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
body
end
def call_api(action_name, body)
url = "#{calls_phone_id_path}/calls"
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
response.success?
end
def permission_request_body(to_phone_number, body_text)
{
messaging_product: 'whatsapp', recipient_type: 'individual', to: to_phone_number,
type: 'interactive',
interactive: {
type: 'call_permission_request',
action: { name: 'call_permission_request' },
body: { text: body_text }
}
}.to_json
end
def initiate_call_body(to_phone_number, sdp_offer)
{
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
session: { sdp: sdp_offer, sdp_type: 'offer' }
}.to_json
end
def process_initiate_call_response(response)
return response.parsed_response if response.success?
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
error_code = parsed.dig('error', 'code')
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
raise Voice::CallErrors::CallFailed, error_msg
end
end
@@ -67,9 +67,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
parameters: {
model: WHISPER_MODEL,
file: file,
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
# higher temperatures produce phantom phrases that look like real transcripts.
temperature: 0.0
temperature: 0.4
}
)
transcribed_text = response['text']
@@ -7,30 +7,15 @@ class Voice::CallMessageBuilder
call.message || create_message!
end
def update_status!(status:, agent: nil, duration_seconds: nil)
message = call.message
return unless message
patch = {
'status' => status&.to_s&.tr('_', '-'),
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
'duration_seconds' => duration_seconds
}.compact
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
message
end
private
attr_reader :call
def create_message!
params = {
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
content: 'Voice Call',
message_type: call.outgoing? ? 'outgoing' : 'incoming',
content_type: 'voice_call',
content_attributes: { 'data' => build_data_payload }
content_type: 'voice_call'
}
Messages::MessageBuilder.new(sender, call.conversation, params).perform
end
@@ -38,15 +23,4 @@ class Voice::CallMessageBuilder
def sender
call.outgoing? ? call.accepted_by_agent : call.contact
end
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
def build_data_payload
{
'call_id' => call.id,
'call_sid' => call.provider_call_id,
'call_source' => call.provider,
'call_direction' => call.direction_label,
'status' => call.display_status
}
end
end
@@ -31,10 +31,32 @@ class Voice::Conference::Manager
def join_agent!
user_id = extract_user_id
call.update!(accepted_by_agent_id: user_id) if user_id
claim_for_user!(user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# First-join wins; later joins by other agents are silently ignored so the
# webhook doesn't stomp the original assignee. User-facing rejection happens
# at the API layer.
def claim_for_user!(user_id)
claimed = false
call.with_lock do
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
claimed = true
end
auto_assign_conversation!(user_id) if claimed
end
def auto_assign_conversation!(user_id)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
@@ -1,17 +1,15 @@
class Voice::InboundCallBuilder
attr_reader :inbox, :from_number, :call_sid, :provider, :extra_meta
attr_reader :account, :inbox, :from_number, :call_sid
def self.perform!(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
new(inbox: inbox, from_number: from_number, call_sid: call_sid,
provider: provider, extra_meta: extra_meta).perform!
def self.perform!(account:, inbox:, from_number:, call_sid:)
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
end
def initialize(inbox:, from_number:, call_sid:, provider: :twilio, extra_meta: {})
def initialize(account:, inbox:, from_number:, call_sid:)
@account = account
@inbox = inbox
@from_number = from_number
@call_sid = call_sid
@provider = provider.to_sym
@extra_meta = extra_meta || {}
end
def perform!
@@ -19,8 +17,8 @@ class Voice::InboundCallBuilder
return existing if existing
ActiveRecord::Base.transaction do
contact_inbox = ensure_contact_inbox!
contact = contact_inbox.contact
contact = ensure_contact!
contact_inbox = ensure_contact_inbox!(contact)
conversation = resolve_conversation!(contact, contact_inbox)
call = create_call!(contact, conversation)
message = Voice::CallMessageBuilder.new(call).perform!
@@ -28,30 +26,15 @@ class Voice::InboundCallBuilder
call
end
rescue ActiveRecord::RecordNotUnique
# A concurrent provider retry won the create race; return what now exists.
# A concurrent Twilio retry won the create race; return what now exists.
find_existing_call || raise
end
private
def account
inbox.account
end
def find_existing_call
Call.where(account_id: account.id, inbox_id: inbox.id)
.find_by(provider: provider, provider_call_id: call_sid)
end
# Always look up by (inbox, source_id) first — that pair has a UNIQUE index, so
# creating with a colliding source_id under a different contact would raise
# RecordNotUnique. Reuse the existing ContactInbox (and its contact) when found.
def ensure_contact_inbox!
sid = source_id_for_provider
existing = inbox.contact_inboxes.find_by(source_id: sid)
return existing if existing
ContactInbox.create!(contact: ensure_contact!, inbox: inbox, source_id: sid)
.find_by(provider: :twilio, provider_call_id: call_sid)
end
def ensure_contact!
@@ -60,9 +43,13 @@ class Voice::InboundCallBuilder
end
end
# WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +.
def source_id_for_provider
provider == :whatsapp ? from_number.to_s.delete_prefix('+') : from_number
def ensure_contact_inbox!(contact)
ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
) do |record|
record.source_id = from_number
end
end
def resolve_conversation!(contact, contact_inbox)
@@ -89,14 +76,13 @@ class Voice::InboundCallBuilder
inbox: inbox,
conversation: conversation,
contact: contact,
provider: provider,
provider: :twilio,
direction: :incoming,
status: 'ringing',
provider_call_id: call_sid,
meta: { 'initiated_at' => Time.zone.now.to_i }.merge(extra_meta.stringify_keys)
meta: { 'initiated_at' => Time.zone.now.to_i }
)
# `conference_sid` is a Twilio bridging concept; WhatsApp goes browser↔Meta.
call.update!(conference_sid: call.default_conference_sid) if call.twilio?
call.update!(conference_sid: call.default_conference_sid)
call
end
end
@@ -9,7 +9,8 @@ class Voice::Provider::Twilio::ConferenceService
end
def mark_agent_joined(user:)
call.update!(accepted_by_agent: user)
claim_call!(user)
assign_conversation!(user)
end
def end_conference
@@ -21,4 +22,30 @@ class Voice::Provider::Twilio::ConferenceService
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
private
def claim_call!(user)
call.with_lock do
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
end
end
def claimed_by_other_agent?(user)
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
end
def raise_already_accepted!(agent)
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
end
# Existing assignments win — manual reassignment and pre-call assignment
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
def assign_conversation!(user)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
end
end
@@ -1,61 +0,0 @@
class Whatsapp::CallPermissionReplyService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
reply_data = extract_reply_data
return unless reply_data&.dig(:accepted)
conversation = find_requesting_conversation(reply_data[:context_id])
return unless conversation
clear_permission_flag(conversation)
broadcast_permission_granted(conversation.contact, conversation)
end
private
def extract_reply_data
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
reply = message&.dig(:interactive, :call_permission_reply)
return unless reply
accepted = reply[:response] == 'accept'
Rails.logger.info "[WHATSAPP CALL] call_permission_reply from=#{message[:from]} accepted=#{accepted} permanent=#{reply[:is_permanent]}"
{ from_number: message[:from], accepted: accepted, context_id: message.dig(:context, :id) }
end
# Match the reply to the conversation whose request message it actually points
# at (interactive replies carry context.id = our outbound wamid). Recency-based
# lookup would broadcast to the wrong thread when a contact has multiple
# parallel pending requests.
def find_requesting_conversation(context_id)
return if context_id.blank?
inbox.conversations
.where.not(status: :resolved)
.where("additional_attributes ->> 'call_permission_request_message_id' = ?", context_id)
.first
end
def clear_permission_flag(conversation)
attrs = (conversation.additional_attributes || {}).except(
'call_permission_requested_at', 'call_permission_request_message_id'
)
conversation.update!(additional_attributes: attrs)
end
def broadcast_permission_granted(contact, conversation)
ActionCable.server.broadcast(
"account_#{inbox.account_id}",
{
event: 'voice_call.permission_granted',
data: {
account_id: inbox.account_id, conversation_id: conversation.id,
contact_name: contact.name, contact_phone: contact.phone_number
}
}
)
end
end
@@ -1,102 +0,0 @@
class Whatsapp::CallService
pattr_initialize [:call!, :agent!, :sdp_answer]
def accept
raise Voice::CallErrors::CallFailed, 'sdp_answer is required' if sdp_answer.blank?
# All side effects under the lock so a concurrent terminate cannot finalize
# the call between status update and the message/conversation/broadcast writes.
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
update_conversation_call_status(call.display_status)
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
end
def reject
call.with_lock do
next if call.terminal? || call.in_progress?
invoke_provider!(:reject_call)
finalize_call('failed')
end
call
end
def terminate
call.with_lock do
next if call.terminal?
invoke_provider!(:terminate_call)
# Agent hangs up before contact picks up → no_answer; mirrors the webhook terminate path.
finalize_call(call.in_progress? ? 'completed' : 'no_answer')
end
call
end
private
def transition_to_in_progress!
# Order matters: in_progress and terminal both make ringing? false, so we have to
# branch on in_progress? first to surface the distinct AlreadyAccepted state.
raise Voice::CallErrors::AlreadyAccepted, 'Call already accepted by another agent' if call.in_progress?
raise Voice::CallErrors::NotRinging, 'Call is not in ringing state' unless call.ringing?
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
claim_conversation_for_agent
end
def forward_answer_to_meta!
invoke_provider!(:pre_accept_call, sdp_answer)
invoke_provider!(:accept_call, sdp_answer)
end
# Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
def claim_conversation_for_agent
call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
end
# Raise on Meta failure (bool false or transport error) so callers bail before
# finalizing local state — otherwise we'd mark a still-active call as ended
# and broadcast voice_call.ended while Meta thinks it's live.
def invoke_provider!(method, *)
success = call.inbox.channel.provider_service.public_send(method, call.provider_call_id, *)
raise Voice::CallErrors::CallFailed, "Meta #{method} failed" unless success
rescue Voice::CallErrors::CallFailed
raise
rescue StandardError => e
Rails.logger.error "[WHATSAPP CALL] #{method} failed: #{e.class} #{e.message}"
raise Voice::CallErrors::CallFailed, "Meta #{method} failed"
end
def finalize_call(status)
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
call.update!(status: status, meta: meta)
update_message_status(status)
update_conversation_call_status(call.display_status)
broadcast(:ended, status: call.display_status)
end
def update_message_status(status)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: agent)
end
def update_conversation_call_status(status)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge('call_status' => status)
)
end
def broadcast(event, **extra)
payload = {
event: "voice_call.#{event}",
data: { id: call.id, call_id: call.provider_call_id, provider: call.provider,
conversation_id: call.conversation_id, account_id: call.account_id }.merge(extra)
}
ActionCable.server.broadcast("account_#{call.account_id}", payload)
end
end
@@ -1,122 +0,0 @@
class Whatsapp::IncomingCallService
pattr_initialize [:inbox!, :params!]
def perform
return unless inbox.channel.voice_enabled?
Array(params[:calls]).each { |c| handle_event(c.with_indifferent_access) }
end
private
def handle_event(payload)
case payload[:event]
when 'connect' then handle_connect(payload)
when 'terminate' then handle_terminate(payload)
else Rails.logger.warn "[WHATSAPP CALL] Unknown call event: #{payload[:event]}"
end
end
def handle_connect(payload)
call = Call.whatsapp.find_by(provider_call_id: payload[:id])
return create_inbound_call(payload) if call.nil?
return accept_outbound_call(call, payload) if call.outgoing?
Rails.logger.info "[WHATSAPP CALL] Duplicate inbound connect for #{payload[:id]}; ignoring"
rescue ActiveRecord::RecordNotUnique
Rails.logger.warn "[WHATSAPP CALL] Duplicate provider_call_id received: #{payload[:id]}"
end
def create_inbound_call(payload)
sdp_offer = payload.dig(:session, :sdp)
call = Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
)
update_conversation(call)
broadcast_incoming(call, sdp_offer)
end
def accept_outbound_call(call, payload)
return if call.in_progress? || call.terminal?
# Pin setup:active so browsers don't renegotiate when Meta echoes actpass.
sdp_answer = payload.dig(:session, :sdp)&.gsub('a=setup:actpass', 'a=setup:active')
update_call!(call, 'in_progress',
started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
broadcast(call, 'voice_call.outbound_connected', sdp_answer: sdp_answer)
end
def handle_terminate(payload)
# Webhooks can arrive out of order (terminate before connect under tunnel/network
# delays). Materialise a missed-call record so the contact's "called and hung up"
# still surfaces in the dashboard instead of being silently dropped.
call = Call.whatsapp.find_by(provider_call_id: payload[:id]) || build_missed_inbound_call(payload)
return unless call
duration = payload[:duration]&.to_i
status = answered?(call, duration) ? 'completed' : 'no_answer'
meta = (call.meta || {}).merge('ended_at' => Time.zone.now.to_i)
update_call!(call, status, duration_seconds: duration, end_reason: payload[:terminate_reason], meta: meta)
broadcast(call, 'voice_call.ended', status: call.display_status, duration_seconds: call.duration_seconds)
end
# No connect was ever processed (webhook reordering or never delivered) — build a
# bare Call+Message for the contact so the UI shows the missed-call bubble.
def build_missed_inbound_call(payload)
Voice::InboundCallBuilder.perform!(
inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id],
provider: :whatsapp,
extra_meta: { 'ice_servers' => Call.default_ice_servers }
)
rescue ActiveRecord::RecordNotUnique
Call.whatsapp.find_by(provider_call_id: payload[:id])
end
# accepted_by_agent_id is the initiating agent on outbound calls, so it only signals "answered" for inbound.
def answered?(call, duration)
call.in_progress? || duration.to_i.positive? || (call.incoming? && call.accepted_by_agent_id.present?)
end
def update_call!(call, status, **attrs)
call.update!(status: status, **attrs)
Voice::CallMessageBuilder.new(call).update_status!(status: status, agent: call.accepted_by_agent,
duration_seconds: attrs[:duration_seconds])
update_conversation(call)
end
def update_conversation(call)
call.conversation.update!(
additional_attributes: (call.conversation.additional_attributes || {}).merge(
'call_status' => call.display_status, 'call_direction' => call.direction_label
)
)
end
# Ring the assignee if assigned; otherwise account-wide so any agent can pick up.
def broadcast_incoming(call, sdp_offer)
contact = call.contact
token = call.conversation.assignee&.pubsub_token
broadcast(call, 'voice_call.incoming',
streams: token ? [token] : account_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 broadcast(call, event, streams: account_streams, **extra)
payload = { event: event, data: base_payload(call).merge(extra) }
streams.each { |s| ActionCable.server.broadcast(s, payload) }
end
def account_streams
["account_#{inbox.account_id}"]
end
def base_payload(call)
{ account_id: inbox.account_id, id: call.id, call_id: call.provider_call_id,
provider: 'whatsapp', conversation_id: call.conversation_id }
end
end
@@ -1 +0,0 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -1,5 +0,0 @@
json.status 'calling'
json.call_id @call.provider_call_id
json.id @call.id
json.message_id @message.id
json.provider 'whatsapp'
@@ -1,2 +0,0 @@
json.id @call.id
json.status @call.display_status
@@ -1 +0,0 @@
json.partial! 'api/v1/models/whatsapp_call', call: @call
@@ -1,2 +0,0 @@
json.id @call.id
json.status @call.display_status
@@ -1,2 +0,0 @@
json.id @call.id
json.status @upload_status
@@ -1,24 +0,0 @@
contact = call.conversation&.contact
json.id call.id
json.call_id call.provider_call_id
json.provider call.provider
json.status call.display_status
json.direction call.direction_label
json.conversation_id call.conversation_id
json.inbox_id call.inbox_id
json.message_id call.message_id
json.accepted_by_agent_id call.accepted_by_agent_id
json.elapsed_seconds(call.started_at ? (Time.current - call.started_at).to_i : 0)
json.sdp_offer call.meta&.dig('sdp_offer')
json.ice_servers(call.meta&.dig('ice_servers') || Call.default_ice_servers)
if contact
json.caller do
json.name contact.name
json.phone contact.phone_number
json.avatar contact.avatar_url
end
else
json.caller({})
end
-11
View File
@@ -1,11 +0,0 @@
module Voice::CallErrors
# Meta WhatsApp Cloud Calling error code returned when the contact has not
# granted call permission yet. See `initiate_call` in
# Enterprise::Whatsapp::Providers::WhatsappCloudService.
NO_CALL_PERMISSION_CODE = 138_006
class NoCallPermission < StandardError; end
class CallFailed < StandardError; end
class NotRinging < StandardError; end
class AlreadyAccepted < StandardError; end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
def message
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
end
def http_status
409
end
end
@@ -32,6 +32,7 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
call.update!(conference_sid: call.default_conference_sid)
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
@@ -1,69 +0,0 @@
require 'rails_helper'
describe Whatsapp::Providers::WhatsappCloudService do
subject(:service) { described_class.new(whatsapp_channel: whatsapp_channel) }
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
let(:calls_url) { 'https://graph.facebook.com/v13.0/123456789/calls' }
let(:messages_url) { 'https://graph.facebook.com/v13.0/123456789/messages' }
let(:headers) { { 'Content-Type' => 'application/json' } }
before { stub_request(:get, /message_templates/) }
describe 'call action methods' do
it 'POSTs the action body with the SDP answer and returns true on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', call_id: 'WACALL', action: 'pre_accept',
session: { sdp: 'sdp_answer', sdp_type: 'answer' } }.to_json)
.to_return(status: 200, body: '{}', headers: headers)
expect(service.pre_accept_call('WACALL', 'sdp_answer')).to be true
end
it 'returns false when Meta responds with a non-success status' do
stub_request(:post, calls_url).to_return(status: 400, body: '{}', headers: headers)
expect(service.reject_call('WACALL')).to be false
end
end
describe '#send_call_permission_request' do
it 'returns the parsed body on success' do
stub_request(:post, messages_url)
.with(body: hash_including(messaging_product: 'whatsapp', to: '15551234567', type: 'interactive'))
.to_return(status: 200, body: { messages: [{ id: 'wamid' }] }.to_json, headers: headers)
expect(service.send_call_permission_request('15551234567')).to eq('messages' => [{ 'id' => 'wamid' }])
end
end
describe '#initiate_call' do
it 'returns the parsed body on success' do
stub_request(:post, calls_url)
.with(body: { messaging_product: 'whatsapp', to: '15551234567', type: 'audio',
session: { sdp: 'sdp_offer', sdp_type: 'offer' } }.to_json)
.to_return(status: 200, body: { messages: [{ id: 'wacall_1' }] }.to_json, headers: headers)
expect(service.initiate_call('15551234567', 'sdp_offer')).to eq('messages' => [{ 'id' => 'wacall_1' }])
end
it 'raises Voice::CallErrors::NoCallPermission when Meta returns error code 138006' do
stub_request(:post, calls_url).to_return(
status: 400,
body: { error: { code: 138_006, error_user_msg: 'No call permission' } }.to_json,
headers: headers
)
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::NoCallPermission, 'No call permission')
end
it 'raises Voice::CallErrors::CallFailed with a fallback message when the error body is non-JSON' do
stub_request(:post, calls_url).to_return(status: 502, body: '<html>502 Bad Gateway</html>',
headers: { 'Content-Type' => 'text/html' })
expect { service.initiate_call('15551234567', 'sdp_offer') }
.to raise_error(Voice::CallErrors::CallFailed, 'Failed to initiate call')
end
end
end
@@ -16,6 +16,7 @@ RSpec.describe Voice::InboundCallBuilder do
def perform_builder
described_class.perform!(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
@@ -86,20 +87,6 @@ RSpec.describe Voice::InboundCallBuilder do
end
end
context 'when a ContactInbox already exists for the source_id (different contact)' do
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
let!(:original_contact_inbox) do
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
end
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
call = perform_builder
expect(call.contact).to eq(original_contact)
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
end
end
context 'when the inbox has lock_to_single_conversation enabled' do
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
@@ -1,97 +0,0 @@
require 'rails_helper'
describe Whatsapp::CallPermissionReplyService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15550001111') }
let(:request_wamid) { 'wamid.permission_request_abc' }
let!(:conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open,
additional_attributes: {
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => request_wamid
})
end
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
end
def reply_params(response:, context_id: request_wamid)
interactive = { type: 'call_permission_reply',
call_permission_reply: { response: response, is_permanent: false } }
message = { from: '15550001111', type: 'interactive', interactive: interactive }
message[:context] = { id: context_id } if context_id
{ entry: [{ changes: [{ value: { messages: [message] } }] }] }
end
it 'clears both permission flags and broadcasts voice_call.permission_granted on accept' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
attrs = conversation.reload.additional_attributes
expect(attrs).not_to include('call_permission_requested_at')
expect(attrs).not_to include('call_permission_request_message_id')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.permission_granted',
data: hash_including(conversation_id: conversation.id))
)
end
it 'is a no-op when the contact rejected the request' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'reject')).perform
expect(conversation.reload.additional_attributes).to include('call_permission_requested_at')
expect(ActionCable.server).not_to have_received(:broadcast)
end
it 'is a no-op when calling is disabled on the channel' do
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
channel.save!
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept')).perform
expect(ActionCable.server).not_to have_received(:broadcast)
end
it 'matches the originating conversation by context.id when the contact has multiple pending requests' do
other_request_wamid = 'wamid.permission_request_xyz'
other_open = create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox,
status: :open,
additional_attributes: {
'call_permission_requested_at' => Time.current.iso8601,
'call_permission_request_message_id' => other_request_wamid
})
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: other_request_wamid)).perform
# The reply pointed at other_open's request — it should be the cleared one, not `conversation`
expect(other_open.reload.additional_attributes).not_to include('call_permission_request_message_id')
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(data: hash_including(conversation_id: other_open.id))
)
end
it 'is a no-op when the reply has no context.id' do
allow(ActionCable.server).to receive(:broadcast)
described_class.new(inbox: inbox, params: reply_params(response: 'accept', context_id: nil)).perform
expect(conversation.reload.additional_attributes).to include('call_permission_request_message_id')
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
@@ -1,192 +0,0 @@
require 'rails_helper'
describe Whatsapp::IncomingCallService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:from_number) { '15550001111' }
let(:provider_call_id) { 'wacid_abc' }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
end
def call_payload(event:, **extra)
{ calls: [{ id: provider_call_id, from: from_number, event: event, **extra }] }
end
context 'when calling is disabled on the channel' do
it 'is a no-op' do
channel.provider_config = channel.provider_config.merge('calling_enabled' => false)
channel.save!
expect { described_class.new(inbox: inbox, params: call_payload(event: 'connect')).perform }
.not_to change(Call, :count)
end
end
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: sdp_offer, sdp_type: 'offer' })
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
call = Call.last
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
provider_call_id: provider_call_id)
expect(call.meta['sdp_offer']).to eq(sdp_offer)
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
)
end
end
describe 'outbound connect (existing call)' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'ringing', provider_call_id: provider_call_id)
end
it 'transitions the call to in_progress and broadcasts voice_call.outbound_connected with the SDP answer' do
allow(ActionCable.server).to receive(:broadcast)
sdp_answer = "v=0\r\na=setup:actpass\r\n"
params = call_payload(event: 'connect', session: { sdp: sdp_answer, sdp_type: 'answer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'in_progress', started_at: be_present)
expect(call.meta['sdp_answer']).to include('a=setup:active')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.outbound_connected')
)
end
end
describe 'terminate' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'in_progress', provider_call_id: provider_call_id)
end
it 'marks the call completed when the call had been answered' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'terminate', duration: 42, terminate_reason: 'completed_normally')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'completed', duration_seconds: 42, end_reason: 'completed_normally')
expect(call.ended_at).to be_present
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.ended', data: hash_including(status: 'completed'))
)
end
it 'marks unanswered ringing calls as no_answer' do
call.update!(status: 'ringing')
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('no_answer')
end
end
describe 'duplicate inbound connect' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: provider_call_id)
end
it 'logs and ignores rather than treating it as outbound' do
allow(Rails.logger).to receive(:info)
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: 'sdp_x', sdp_type: 'offer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload).to have_attributes(status: 'ringing')
expect(Rails.logger).to have_received(:info).with(/Duplicate inbound connect/)
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
describe 'connect arriving after terminal status' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'completed', provider_call_id: provider_call_id)
end
it 'does not reopen a completed outbound call' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'connect', session: { sdp: 'late_sdp', sdp_type: 'answer' })
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('completed')
expect(ActionCable.server).not_to have_received(:broadcast)
end
end
describe 'unanswered outbound call terminate' do
let!(:agent) { create(:user, account: account) }
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :outgoing, status: 'ringing',
accepted_by_agent: agent, provider_call_id: provider_call_id)
end
it 'marks the call as no_answer even though accepted_by_agent_id is set' do
allow(ActionCable.server).to receive(:broadcast)
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
described_class.new(inbox: inbox, params: params).perform
expect(call.reload.status).to eq('no_answer')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}",
hash_including(event: 'voice_call.ended', data: hash_including(status: 'no-answer'))
)
end
end
describe 'unknown event' do
it 'logs a warning and does not raise' do
allow(Rails.logger).to receive(:warn)
params = call_payload(event: 'mystery')
expect { described_class.new(inbox: inbox, params: params).perform }.not_to raise_error
expect(Rails.logger).to have_received(:warn).with(/Unknown call event: mystery/)
end
end
describe 'multiple calls in one webhook payload' do
it 'processes every call in the array' do
allow(ActionCable.server).to receive(:broadcast)
params = {
calls: [
{ id: 'wacid_a', from: from_number, event: 'connect', session: { sdp: 'sdp_a', sdp_type: 'offer' } },
{ id: 'wacid_b', from: '15550002222', event: 'connect', session: { sdp: 'sdp_b', sdp_type: 'offer' } }
]
}
expect { described_class.new(inbox: inbox, params: params).perform }.to change(Call, :count).by(2)
expect(Call.where(provider_call_id: %w[wacid_a wacid_b]).pluck(:provider_call_id)).to contain_exactly('wacid_a', 'wacid_b')
end
end
end
+5
View File
@@ -423,5 +423,10 @@ export const icons = {
width: 16,
height: 16,
},
'quick-reply': {
body: `<path d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z" fill="none" stroke="currentColor" stroke-width="1.333" stroke-linecap="round" stroke-linejoin="round"/>`,
width: 15,
height: 15,
},
/** Ends */
};