Compare commits

..
171 changed files with 1550 additions and 5217 deletions
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
@account = Account.create!(name: account_name, locale: I18n.locale)
Current.account = @account
end
+1 -2
View File
@@ -29,9 +29,8 @@ class AgentBuilder
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -61,8 +61,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def process_attached_logo
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
@portal.logo.attach(blob)
end
private
@@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -72,10 +71,9 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
@@ -111,7 +109,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
params.permit(:industry, :company_size, :timezone)
end
def settings_params
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
before_action :check_mfa_feature_available
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
before_action :check_mfa_disabled, only: [:create, :verify]
before_action :validate_password, only: [:destroy]
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
before_action :validate_password, only: [:destroy]
def show; end
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
def validate_otp
authenticated = Mfa::AuthenticationService.new(
user: current_user,
otp_code: mfa_params[:otp_code],
backup_code: mfa_params[:backup_code]
otp_code: mfa_params[:otp_code]
).authenticate
return if authenticated
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
end
def mfa_params
params.permit(:otp_code, :backup_code, :password)
params.permit(:otp_code, :password)
end
end
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end
def show; end
+1 -3
View File
@@ -11,9 +11,7 @@ class MessageFinder
private
def conversation_messages
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
scope
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
end
def messages
+3 -3
View File
@@ -59,6 +59,7 @@ export default {
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
}),
hideOnOnboardingView() {
return !isOnOnboardingView(this.$route);
@@ -106,9 +107,8 @@ export default {
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const account = this.getAccount(this.currentAccountId);
const { locale, latest_chatwoot_version: latestChatwootVersion } =
account;
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
// If user locale is set, use it; otherwise use account locale
this.setLocale(this.uiSettings?.locale || locale);
@@ -131,7 +131,7 @@ export default {
<template>
<div
v-if="!authUIFlags.isFetching"
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null;
}
async joinClientCall({ to, conversationId, callSid }) {
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
@@ -76,7 +76,6 @@ class TwilioVoiceClient extends EventTarget {
To: to,
is_agent: 'true',
conversation_id: conversationId,
call_sid: callSid,
};
const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference({ inboxId, conversationId, callSid }) {
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId, call_sid: callSid },
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
+2 -2
View File
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
}
disable(password, { otpCode, backupCode } = {}) {
disable(password, otpCode) {
return axios.delete(this.url, {
data: { password, otp_code: otpCode, backup_code: backupCode },
data: { password, otp_code: otpCode },
});
}
@@ -1,68 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class VoiceCallsAPI extends ApiClient {
constructor() {
super('voice_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`);
}
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
// it to Meta via pre_accept_call+accept_call.
accept(callId, { sdpAnswer } = {}) {
return axios.post(`${this.url}/${callId}/accept`, {
sdp_answer: sdpAnswer,
});
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`);
}
// Outbound: browser builds the offer first; Rails ships it to Meta and
// creates the Call record. Meta delivers its SDP answer later via the
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
initiate(conversationId, provider, { sdpOffer } = {}) {
return axios.post(`${this.url}/initiate`, {
conversation_id: conversationId,
provider,
sdp_offer: sdpOffer,
});
}
// Get the current agent's active call (if any). Used on page load to detect
// a stale active session so we can terminate it cleanly.
active() {
return axios.get(`${this.url}/active`);
}
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
// controller attaches it to the call's voice_call message; the after-create
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
uploadRecording(callId, blob, filename) {
const fd = new FormData();
fd.append('recording', blob, filename);
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
// needs the absolute account-scoped path).
uploadRecordingUrl(callId) {
return `${this.url}/${callId}/upload_recording`;
}
terminateUrl(callId) {
return `${this.url}/${callId}/terminate`;
}
}
export default new VoiceCallsAPI();
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -40,6 +40,7 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -67,16 +68,15 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.setActiveCall({
id: response.call_id,
callId: response.call_sid,
provider: response.provider || 'twilio',
direction: 'outbound',
status: 'ringing',
conversationId: response.conversation_id,
caller: null,
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -23,6 +23,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
refreshTick: {
type: Number,
default: 0,
},
});
const emit = defineEmits([
@@ -41,14 +45,27 @@ const isNewArticle = computed(() => !props.article?.id);
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
const syncLocalState = () => {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
};
// Sync local state when navigating to a different article or on initial fetch
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
if (newId) syncLocalState();
}
);
// Parent bumps refreshTick after re-fetching on tab visibility. Skip
// the sync while a save is in flight so we don't overwrite edits the
// user just made.
watch(
() => props.refreshTick,
() => {
if (props.isUpdating) return;
syncLocalState();
}
);
@@ -8,6 +8,8 @@ import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
import ContentTemplateSelector from './ContentTemplateSelector.vue';
@@ -28,7 +30,6 @@ const props = defineProps({
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
voiceEnabled: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -81,13 +82,11 @@ const isRegularMessageMode = computed(() => {
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
});
const voiceCallEnabled = computed(() => props.voiceEnabled);
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
const shouldShowSignatureButton = computed(() => {
return (
props.hasSelectedInbox &&
isRegularMessageMode.value &&
!voiceCallEnabled.value
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
);
});
@@ -112,7 +111,7 @@ watch(
() => props.hasSelectedInbox,
newValue => {
nextTick(() => {
if (newValue && !voiceCallEnabled.value) setSignature();
if (newValue && !isVoiceInbox.value) setSignature();
});
},
{ immediate: true }
@@ -2,7 +2,7 @@
import { ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
@@ -100,8 +100,6 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
@@ -444,7 +442,6 @@ useKeyboardEvents({
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:voice-enabled="voiceCallEnabled"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
@@ -135,16 +135,6 @@ export function useContactFilterContext() {
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
inputType: 'plainText',
dataType: 'text',
filterOperators: containmentOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
value: CONTACT_ATTRIBUTES.CREATED_AT,
@@ -23,7 +23,6 @@ export const CONTACT_ATTRIBUTES = {
IDENTIFIER: 'identifier',
COUNTRY_CODE: 'country_code',
CITY: 'city',
COMPANY_NAME: 'company_name',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
REFERER: 'referer',
@@ -1,5 +1,4 @@
import { computed } from 'vue';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
@@ -15,6 +14,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
};
const providerIconMap = {
@@ -38,11 +38,6 @@ export function useChannelIcon(inbox) {
icon = 'i-woot-whatsapp';
}
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
if (isVoiceCallEnabled(inboxDetails)) {
icon = 'i-woot-voice';
}
return icon ?? 'i-ri-global-fill';
});
@@ -19,11 +19,8 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-whatsapp');
});
it('returns correct icon for voice-enabled Twilio channel', () => {
const inbox = {
channel_type: 'Channel::TwilioSms',
voice_enabled: true,
};
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
});
@@ -30,10 +30,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
@@ -86,7 +82,6 @@ onMounted(() => {
defineExpose({
focus: () => inlineInputRef.value?.focus(),
blur: () => inlineInputRef.value?.blur(),
});
</script>
@@ -111,7 +106,6 @@ defineExpose({
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@@ -113,7 +113,6 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value),
},
attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) },
contentType: {
@@ -1,10 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
@@ -33,11 +30,8 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
};
const router = useRouter();
const { contentAttributes, messageType, attachments } = useMessageContext();
const { contentAttributes, messageType } = useMessageContext();
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
// applies useCamelCase(messages, { deep: true }) before rendering.
const data = computed(() => contentAttributes.value?.data);
const status = computed(() => data.value?.status?.toString());
@@ -46,52 +40,6 @@ const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
);
// Call source and metadata — all camelCase due to deep transform
const callSource = computed(() => data.value?.callSource);
const isVoiceCall = computed(() =>
['whatsapp', 'twilio'].includes(callSource.value)
);
const callId = computed(() => data.value?.callId);
const acceptedBy = computed(() => data.value?.acceptedBy);
const durationSeconds = computed(() => data.value?.durationSeconds);
// Recording and transcript live on the first audio attachment. After the
// call ends, CallRecordingFetchJob creates an Attachment on this message;
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
const audioAttachment = computed(
() => attachments.value?.find(a => a.fileType === 'audio') || null
);
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
const transcript = computed(() => audioAttachment.value?.transcribedText);
const isJoining = ref(false);
const isRejecting = ref(false);
const showTranscript = ref(false);
// Reject is shown only for ringing inbound calls — outbound calls and
// in-progress calls don't need a reject button (initiator hangs up via the
// floating widget).
const showRejectButton = computed(
() =>
isVoiceCall.value &&
!isOutbound.value &&
status.value === VOICE_CALL_STATUS.RINGING
);
const formattedDuration = computed(() => {
const seconds = durationSeconds.value;
if (!seconds || seconds <= 0) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
});
// Show join/accept button logic
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
// DTLS fingerprint), so only ringing calls show the Accept button.
const showJoinButton = computed(
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -105,19 +53,6 @@ const labelKey = computed(() => {
});
const subtextKey = computed(() => {
// acceptedBy on outbound calls is the initiator, not the contact — so keep
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
// should suppress the subtext in favor of the acceptedBy line.
if (
!isOutbound.value &&
acceptedBy.value?.name &&
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
status.value
)
) {
return null;
}
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return isOutbound.value
@@ -129,50 +64,12 @@ const subtextKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
});
const answeredByText = computed(() => {
if (isOutbound.value) return '';
if (!acceptedBy.value?.name) return '';
return acceptedBy.value.name;
});
const iconName = computed(() => {
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const handleJoinCall = async () => {
if (isJoining.value || !isVoiceCall.value) return;
isJoining.value = true;
try {
const result = await acceptVoiceCallById(callId.value);
if (result?.success && result.call) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: result.call.conversationId },
});
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Accept from bubble failed:', err);
} finally {
isJoining.value = false;
}
};
const handleRejectCall = async () => {
if (isRejecting.value || !callId.value) return;
isRejecting.value = true;
try {
await VoiceCallsAPI.reject(callId.value);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[Voice Call] Reject from bubble failed:', err);
} finally {
isRejecting.value = false;
}
};
</script>
<template>
@@ -193,90 +90,14 @@ const handleRejectCall = async () => {
/>
</div>
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
<div class="flex overflow-hidden flex-col flex-grow">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }}
</span>
<span v-if="answeredByText" class="text-xs text-n-slate-11">
{{
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
name: answeredByText,
})
}}
</span>
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
<span class="text-xs text-n-slate-11">
{{ $t(subtextKey) }}
</span>
<span
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
class="text-xs text-n-slate-10"
>
{{ formattedDuration }}
</span>
</div>
<button
v-if="showRejectButton"
:disabled="isRejecting"
:title="$t('WHATSAPP_CALL.REJECT')"
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isRejecting }"
@click="handleRejectCall"
>
<i
v-if="isRejecting"
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
/>
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
</button>
<button
v-if="showJoinButton"
:disabled="isJoining"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
:class="{ 'opacity-75 cursor-wait': isJoining }"
@click="handleJoinCall"
>
<i
v-if="isJoining"
class="i-ph-circle-notch-bold text-sm animate-spin"
/>
<i v-else class="i-ph-phone-bold text-sm" />
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
</button>
</div>
<div
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-2"
>
<audio controls class="w-full h-8" :src="recordingUrl">
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
</audio>
</div>
<div
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
class="px-3 pb-3"
>
<button
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
@click="showTranscript = !showTranscript"
>
<i
class="text-sm"
:class="
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
"
/>
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
</button>
<p
v-if="showTranscript"
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
>
{{ transcript }}
</p>
</div>
</div>
</BaseBubble>
@@ -44,7 +44,6 @@ const handleEndCall = async () => {
await endCallSession({
conversationId: call.conversationId,
inboxId,
callSid: call.callSid,
});
};
@@ -1,244 +0,0 @@
<script setup>
import { watch, onUnmounted, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const { t } = useI18n();
const router = useRouter();
const {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
} = useVoiceCallSession();
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
// completes (not when the agent clicks accept). Listen for this event.
const onAgentWebRTCConnected = () => {
startDurationTimer();
};
const onPermissionGranted = ({ contactName }) => {
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
type: 'success',
});
};
onMounted(() => {
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.on('voice_call:permission_granted', onPermissionGranted);
});
// Auto-dismiss ringing calls after 30 seconds
const autoRejectTimers = new Map();
const startAutoRejectTimer = call => {
if (autoRejectTimers.has(call.callId)) return;
const timer = setTimeout(() => {
dismissIncomingCall(call);
autoRejectTimers.delete(call.callId);
}, 30000);
autoRejectTimers.set(call.callId, timer);
};
const clearAutoRejectTimer = callId => {
const timer = autoRejectTimers.get(callId);
if (timer) {
clearTimeout(timer);
autoRejectTimers.delete(callId);
}
};
const handleAccept = async call => {
clearAutoRejectTimer(call.callId);
await acceptCall(call);
if (activeCall.value) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
const handleReject = async call => {
clearAutoRejectTimer(call.callId);
await rejectCall(call);
};
const handleEndCall = async () => {
await endActiveCall();
};
// Start auto-reject timers for each newly added incoming call
watch(
incomingCalls,
calls => {
calls.forEach(call => startAutoRejectTimer(call));
},
{ immediate: true, deep: true }
);
onUnmounted(() => {
autoRejectTimers.forEach(timer => clearTimeout(timer));
autoRejectTimers.clear();
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
emitter.off('voice_call:permission_granted', onPermissionGranted);
});
</script>
<template>
<div
v-if="hasIncomingCall || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
>
<!-- Error banner -->
<div
v-if="callError"
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
>
{{ callError }}
</div>
<!-- Incoming calls (shown when there's no active call yet) -->
<template v-if="!hasActiveCall">
<div
v-for="call in incomingCalls"
:key="call.callId"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="call.caller?.avatar"
:name="call.caller?.name || call.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
call.caller?.name ||
call.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{
call.provider === 'twilio'
? 'Incoming voice call'
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.REJECT')"
@click="handleReject(call)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
:disabled="isAccepting"
:title="t('WHATSAPP_CALL.ACCEPT')"
@click="handleAccept(call)"
>
<i
v-if="isAccepting"
class="text-lg text-white i-ph-circle-notch animate-spin"
/>
<i v-else class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</template>
<!-- Active call widget -->
<div
v-if="hasActiveCall"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': isOutboundRinging }"
>
<Avatar
:src="activeCall.caller?.avatar"
:name="activeCall.caller?.name || activeCall.caller?.phone"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{
activeCall.caller?.name ||
activeCall.caller?.phone ||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
}}
</p>
<p
class="text-sm"
:class="
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
"
>
<template v-if="isOutboundRinging">
{{ t('WHATSAPP_CALL.RINGING') }}
</template>
<template v-else>
{{ formattedCallDuration }}
</template>
</p>
</div>
<div class="flex shrink-0 gap-2">
<!-- Mute toggle -->
<button
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-4 hover:bg-n-slate-5'
"
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
@click="toggleMute"
>
<i
class="text-base text-white"
:class="
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
"
/>
</button>
<!-- Hang up -->
<button
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
:title="t('WHATSAPP_CALL.HANG_UP')"
@click="handleEndCall"
>
<i class="text-base text-white i-ph-phone-x-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -38,16 +38,10 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => {
const last = lastMessageInChat.value;
if (last?.content_type !== 'voice_call' || !last.call) {
return { status: null, direction: null };
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const showMetaSection = computed(() => {
return (
@@ -13,14 +13,6 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import {
prepareOutboundOffer,
cleanupInboundWebRTC,
} from 'dashboard/composables/useVoiceCallSession';
const props = defineProps({
chat: {
@@ -38,10 +30,7 @@ const store = useStore();
const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
useInbox();
const voiceCallsStore = useVoiceCallsStore();
const isInitiatingCall = ref(false);
const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@@ -102,99 +91,6 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
// Returns the provider to use for an outbound call, based on the inbox
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
// use :twilio. Returns null if outbound calling isn't supported on this
// channel.
const callProvider = computed(() => {
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
return 'whatsapp';
}
if (voiceCallEnabled.value) {
return 'twilio';
}
return null;
});
const canInitiateVoiceCall = computed(() => {
if (!callProvider.value) return false;
if (voiceCallsStore.hasVoiceCall) return false;
return true;
});
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
// the browser builds the SDP offer locally before any network call, so Meta
// gets the agent's real fingerprint and the eventual answer applies cleanly
// in the cable handler. Twilio still POSTs first and waits for the
// synchronous agent_offer in the response (its OutboundCallBuilder produces
// the SDP server-side). On 138006 (no call permission) we tear down the local
// PC immediately so the mic light goes off while the contact's phone shows
// the consent prompt.
const initiateWhatsappCall = async () => {
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
return;
isInitiatingCall.value = true;
const isWhatsApp = callProvider.value === 'whatsapp';
try {
let sdpOffer = null;
if (isWhatsApp) {
sdpOffer = await prepareOutboundOffer({});
}
const response = await VoiceCallsAPI.initiate(
currentChat.value.id,
callProvider.value,
isWhatsApp ? { sdpOffer } : {}
);
const callStatus = response.data?.status;
if (
callStatus === 'permission_requested' ||
callStatus === 'permission_pending'
) {
// Tear down the local PC; we'll rebuild from scratch on retry.
cleanupInboundWebRTC();
const message =
callStatus === 'permission_requested'
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
: t('WHATSAPP_CALL.PERMISSION_PENDING');
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
return;
}
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: t('WHATSAPP_CALL.CALLING'),
type: 'success',
});
const callData = response.data || {};
voiceCallsStore.setActiveCall({
id: callData.id,
callId: callData.call_id,
provider: callData.provider || callProvider.value,
direction: 'outbound',
status: 'ringing',
conversationId: currentChat.value.id,
caller: {
name: currentContact.value?.name,
phone: currentContact.value?.phone_number,
avatar: currentContact.value?.thumbnail,
},
});
} catch (err) {
cleanupInboundWebRTC();
const errorMessage =
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
message: errorMessage,
type: 'error',
});
} finally {
isInitiatingCall.value = false;
}
};
</script>
<template>
@@ -256,19 +152,6 @@ const initiateWhatsappCall = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<button
v-if="canInitiateVoiceCall"
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
:disabled="isInitiatingCall"
@click="initiateWhatsappCall"
>
<i
v-if="isInitiatingCall"
class="text-base i-ph-circle-notch animate-spin"
/>
<i v-else class="text-base i-ph-phone-bold" />
</button>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -47,11 +47,7 @@ const mockStore = createStore({
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: {
id: 14,
channel_type: INBOX_TYPES.TWILIO,
voice_enabled: true,
},
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
@@ -215,11 +211,11 @@ describe('useInbox', () => {
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice (Twilio with voice_enabled)
// Test Voice
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.voiceCallEnabled).toBe(true);
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
@@ -278,8 +274,7 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'voiceCallEnabled',
'voiceCallProvider',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
@@ -1,36 +0,0 @@
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import { useAlert } from 'dashboard/composables';
/**
* Detects a stale active call on page load and cleans it up. Direct
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
* Best we can do is terminate the stranded Call so the agent isn't shown
* "in a call" forever.
*/
export function useCallReconnection() {
const callsStore = useVoiceCallsStore();
const { t } = useI18n();
onMounted(async () => {
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
try {
const { data } = await VoiceCallsAPI.active();
if (!data?.call && !data?.id) return;
const activeCallData = data.call || data;
try {
await VoiceCallsAPI.terminate(activeCallData.id);
} catch {
// best-effort — Rails-side cleanup
}
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
} catch {
callsStore.clearActiveCall();
}
});
}
@@ -42,8 +42,8 @@ export function useCallSession() {
);
});
const endCall = async ({ conversationId, inboxId, callSid }) => {
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
@@ -66,7 +66,6 @@ export function useCallSession() {
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
callSid,
});
callsStore.setCallActive(callSid);
@@ -1,11 +1,7 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import {
INBOX_TYPES,
isVoiceCallEnabled,
getVoiceCallProvider,
} from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -138,9 +134,9 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
@@ -160,7 +156,6 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
voiceCallEnabled,
voiceCallProvider,
isAVoiceChannel,
};
};
@@ -1,654 +0,0 @@
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
import Timer from 'dashboard/helper/Timer';
import { emitter } from 'shared/helpers/mitt';
// ─────────────────────────────────────────────────────────────────────────────
// Module-level WebRTC + recorder state. One active peer per browser tab.
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
// disambiguates so beforeunload doesn't warn during a clean hangup.
// ─────────────────────────────────────────────────────────────────────────────
let inboundPc = null;
let inboundStream = null;
let inboundAudio = null;
let audioContext = null;
let mediaRecorder = null;
let recordingMime = null;
let recordingChunks = [];
let recordingFilename = null;
let intentionallyClosing = false;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
function pickRecorderMime() {
if (typeof MediaRecorder === 'undefined') return null;
return (
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
);
}
function teardownRecorder() {
mediaRecorder = null;
recordingChunks = [];
recordingMime = null;
recordingFilename = null;
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
}
function extensionFromMime(mime) {
if (!mime) return 'webm';
if (mime.startsWith('audio/webm')) return 'webm';
if (mime.startsWith('audio/ogg')) return 'ogg';
return 'webm';
}
// Mix the agent's mic and Meta's remote audio into a single stream so the
// resulting Blob is a real conversation rather than two interleaved channels.
// Web Audio handles the mix; we never apply gain (raw levels matter for
// Whisper's speech-detection floor).
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
if (mediaRecorder || !localStream || !remoteStream) return;
const mime = pickRecorderMime();
if (!mime) {
// eslint-disable-next-line no-console
console.warn(
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
);
return;
}
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000,
});
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const localSource = audioContext.createMediaStreamSource(localStream);
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
const mixDest = audioContext.createMediaStreamDestination();
localSource.connect(mixDest);
remoteSource.connect(mixDest);
recordingMime = mime;
recordingChunks = [];
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size) recordingChunks.push(event.data);
};
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
// beacon to capture most of the call if the user closes the tab.
mediaRecorder.start(5000);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recorder setup failed:', err);
teardownRecorder();
}
}
// stop() returns nothing useful; the final dataavailable fires on the next
// task and onstop fires after that. Wait for onstop, then assemble the blob.
async function stopRecorderAndGetBlob() {
if (!mediaRecorder) return null;
const mr = mediaRecorder;
if (mr.state === 'inactive') {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
teardownRecorder();
return { blob, filename: recordingFilename };
}
return new Promise(resolve => {
const cleanup = () => {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const filename = recordingFilename;
teardownRecorder();
resolve({ blob, filename });
};
mr.onstop = cleanup;
try {
mr.stop();
} catch {
cleanup();
}
});
}
function cleanupInboundWebRTC() {
// Stop recorder first so the source nodes still see live tracks during
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
teardownRecorder();
if (inboundStream) {
inboundStream.getTracks().forEach(track => track.stop());
inboundStream = null;
}
if (inboundPc) {
inboundPc.close();
inboundPc = null;
}
if (inboundAudio) {
inboundAudio.srcObject = null;
if (inboundAudio.parentNode) {
inboundAudio.parentNode.removeChild(inboundAudio);
}
inboundAudio = null;
}
}
function waitForIceGatheringComplete(pc) {
return new Promise((resolve, reject) => {
if (pc.iceGatheringState === 'complete') {
resolve();
return;
}
let timeout = null;
const cleanup = () => {
clearTimeout(timeout);
pc.onicegatheringstatechange = null;
pc.oniceconnectionstatechange = null;
};
timeout = setTimeout(() => {
cleanup();
// eslint-disable-next-line no-console
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
resolve();
}, 10000);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
cleanup();
resolve();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
cleanup();
reject(new Error('ICE connection failed'));
}
};
});
}
function attachRemoteTrackHandler(pc, callId, providerCallId) {
pc.ontrack = event => {
let remoteStream = event.streams && event.streams[0];
if (!remoteStream) {
if (!event.track) return;
remoteStream = new MediaStream([event.track]);
}
if (!inboundAudio) {
const audio = document.createElement('audio');
audio.autoplay = true;
document.body.appendChild(audio);
inboundAudio = audio;
}
inboundAudio.srcObject = remoteStream;
inboundAudio.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[Voice Call] audio.play() rejected:', err);
});
if (inboundStream) {
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
}
};
}
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
// cable event, opens its mic, builds the answer, and ships it back so Rails
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
async function prepareInboundAnswer({
callId,
providerCallId,
sdpOffer,
iceServers,
}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Outbound: browser builds the offer locally, before any network call. Rails
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
// later via the voice_call.outbound_connected cable event.
async function prepareOutboundOffer({
callId,
providerCallId,
iceServers,
} = {}) {
cleanupInboundWebRTC();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
inboundStream = stream;
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
const pc = new RTCPeerConnection({ iceServers: servers });
inboundPc = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
attachRemoteTrackHandler(pc, callId, providerCallId);
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
}
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
// from the actionCable handler when voice_call.outbound_connected lands.
async function applyOutboundAnswer(sdpAnswer) {
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
if (inboundPc.signalingState !== 'have-local-offer') {
// Already applied — duplicate cable delivery. No-op rather than detonate.
return;
}
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
// Some browsers fire ontrack synchronously inside setRemoteDescription;
// others delay it. If we already have a remote stream, kick the recorder.
if (inboundAudio?.srcObject && inboundStream) {
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
}
}
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
// Standalone for the message bubble's Accept button. Branches on whether the
// store has an SDP offer (WhatsApp direct mode = always yes).
export async function acceptVoiceCallById(callId) {
const callsStore = useVoiceCallsStore();
if (callsStore.hasActiveCall) {
return { success: false, error: 'active_call_exists' };
}
let call = callsStore.incomingCalls.find(
c => c.id === callId || c.callId === String(callId)
);
let sdpOffer = call?.sdpOffer;
let iceServers = call?.iceServers;
let providerCallId = call?.callId;
if (!sdpOffer) {
const { data } = await VoiceCallsAPI.show(callId);
if (data.status !== 'ringing') {
return { success: false, error: 'not_ringing' };
}
sdpOffer = data.sdp_offer;
iceServers = data.ice_servers;
providerCallId = data.call_id;
call = {
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
sdpOffer,
iceServers,
};
callsStore.addIncomingCall(call);
}
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
// network call so the cable handler doesn't drop a frame on a null guard.
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
return { success: true, call: activeCallData };
} catch (err) {
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall?.();
throw err;
}
}
// Best-effort upload. Errors are swallowed because the call has already
// completed by the time we get here; surfacing failures isn't actionable.
async function uploadRecordingBlob(callId) {
if (!mediaRecorder && recordingChunks.length === 0) return;
try {
const result = await stopRecorderAndGetBlob();
if (!result?.blob || result.blob.size === 0) return;
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[Voice Call] Recording upload failed:', err);
}
}
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
export function useVoiceCallSession() {
const { t } = useI18n();
const callsStore = useVoiceCallsStore();
const getters = useStoreGetters();
const accountId = computed(() => getters.getCurrentAccountId.value);
const isAccepting = ref(false);
const isMuted = ref(false);
const callError = ref(null);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
const isOutboundRinging = computed(
() =>
activeCall.value?.direction === 'outbound' &&
activeCall.value?.status === 'ringing'
);
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
// the only one that ships audio to the server. uploadRecordingBlob
// gathers the blob via the recorder's onstop listener and then frees the
// recorder — only AFTER that completes do we close the PC and stop tracks.
callsStore.registerCleanupCallback(async callId => {
const idForUpload = callId || activeCall.value?.id;
durationTimer.stop();
callDuration.value = 0;
try {
if (idForUpload) await uploadRecordingBlob(idForUpload);
} finally {
cleanupInboundWebRTC();
}
});
// Warn the user if they try to navigate away mid-call. The actual cleanup
// happens in the pagehide handler — beforeunload is purely the warning.
const handleBeforeUnload = e => {
if (!callsStore.activeCall || intentionallyClosing) return undefined;
e.preventDefault();
e.returnValue = '';
// Some browsers (older Safari/Firefox) only honour a returned string.
// eslint-disable-next-line consistent-return
return '';
};
// Fires after the user confirms close (or on tab switch). We use fetch
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
// authenticates via devise-token-auth headers (`access-token`, `client`,
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
// fetch+keepalive sends both cookies AND custom headers and the request
// survives page unload (capped at ~64KB body, fine for terminate).
const handlePageHide = () => {
const call = callsStore.activeCall;
if (!call || intentionallyClosing) return;
// Stop the recorder synchronously; final dataavailable already fired via
// the 5s timeslice, so chunks[] is approximately current.
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch {
// ignore
}
}
const sessionHeaders = (() => {
try {
return JSON.parse(
decodeURIComponent(
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
[])[2] || '%7B%7D'
)
);
} catch {
return {};
}
})();
const authHeaders = {
'access-token': sessionHeaders['access-token'] || '',
client: sessionHeaders.client || '',
uid: sessionHeaders.uid || '',
expiry: sessionHeaders.expiry || '',
'token-type': sessionHeaders['token-type'] || 'Bearer',
};
if (recordingChunks.length > 0 && accountId.value) {
try {
const blob = new Blob(recordingChunks, {
type: recordingMime || 'audio/webm',
});
const fd = new FormData();
fd.append(
'recording',
blob,
recordingFilename || `call_${call.id}.webm`
);
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
{
method: 'POST',
body: fd,
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
if (accountId.value) {
try {
fetch(
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
{
method: 'POST',
credentials: 'include',
keepalive: true,
headers: authHeaders,
}
);
} catch {
// ignore
}
}
cleanupInboundWebRTC();
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
// Start timer when an outbound call transitions to connected.
watch(activeCall, call => {
if (
call?.direction === 'outbound' &&
call?.status === 'connected' &&
!durationTimer.intervalId
) {
durationTimer.start();
}
});
// Floating-widget Accept button. Same WhatsApp-direct flow as
// acceptVoiceCallById — see comments there.
const acceptCall = async call => {
if (isAccepting.value) return;
isAccepting.value = true;
callError.value = null;
const sdpOffer = call.sdpOffer;
const iceServers = call.iceServers;
if (!sdpOffer) {
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
isAccepting.value = false;
return;
}
const activeCallData = { ...call };
callsStore.setActiveCall(activeCallData);
try {
const sdpAnswer = await prepareInboundAnswer({
callId: call.id,
providerCallId: call.callId,
sdpOffer,
iceServers,
});
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
callsStore.removeIncomingCall(call.callId);
callsStore.markActiveCallConnected();
durationTimer.start();
emitter.emit('voice_call:agent_webrtc_connected');
} catch (err) {
callError.value =
err?.name === 'NotAllowedError'
? t('WHATSAPP_CALL.MIC_DENIED')
: t('WHATSAPP_CALL.CALL_FAILED');
// eslint-disable-next-line no-console
console.error('[Voice Call] acceptCall error:', err);
cleanupInboundWebRTC();
callsStore.removeIncomingCall(call.callId);
callsStore.clearActiveCall();
} finally {
isAccepting.value = false;
}
};
const rejectCall = async call => {
try {
await VoiceCallsAPI.reject(call.id);
} catch {
// Best effort
} finally {
callsStore.removeIncomingCall(call.callId);
}
};
const endActiveCall = async () => {
const call = activeCall.value;
if (!call) return;
intentionallyClosing = true;
try {
await uploadRecordingBlob(call.id);
} catch {
// Best effort
}
try {
await VoiceCallsAPI.terminate(call.id);
} catch {
// Best effort
} finally {
cleanupInboundWebRTC();
callsStore.clearActiveCall();
durationTimer.stop();
callDuration.value = 0;
intentionallyClosing = false;
}
};
const toggleMute = () => {
if (!inboundStream) return;
const audioTrack = inboundStream.getAudioTracks()[0];
if (!audioTrack) return;
audioTrack.enabled = !audioTrack.enabled;
isMuted.value = !audioTrack.enabled;
};
const dismissIncomingCall = call => {
callsStore.removeIncomingCall(call.callId);
};
const startDurationTimer = () => {
durationTimer.start();
};
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
durationTimer.stop();
});
return {
activeCall,
incomingCalls,
hasActiveCall,
hasIncomingCall,
firstIncomingCall,
isAccepting,
isMuted,
isOutboundRinging,
callError,
formattedCallDuration,
acceptCall,
rejectCall,
endActiveCall,
toggleMute,
dismissIncomingCall,
startDurationTimer,
};
}
@@ -109,6 +109,11 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
-1
View File
@@ -36,7 +36,6 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CHANNEL_VOICE: 'channel_voice',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
@@ -153,8 +153,3 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
});
@@ -4,8 +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 { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
const { isImpersonating } = useImpersonation();
@@ -36,11 +34,6 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.accepted': this.onVoiceCallAccepted,
'voice_call.ended': this.onVoiceCallEnded,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
};
}
@@ -207,69 +200,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 => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.addIncomingCall({
id: data.id,
callId: data.call_id,
provider: data.provider,
direction: data.direction,
inboxId: data.inbox_id,
conversationId: data.conversation_id,
caller: data.caller,
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
// the bubble's Accept button can build the WebRTC answer locally
// without re-fetching from /show.
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
});
};
onVoiceCallAccepted = data => {
const voiceCallsStore = useVoiceCallsStore();
const currentUserId = this.app.$store.getters.getCurrentUserID;
if (data.accepted_by_agent_id !== currentUserId) {
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
}
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = data => {
const voiceCallsStore = useVoiceCallsStore();
voiceCallsStore.handleCallEnded(data.call_id);
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = data => {
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
// contact picks up. Apply it to the existing local RTCPeerConnection so
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
// by prepareOutboundOffer when the agent clicked the call button.
const voiceCallsStore = useVoiceCallsStore();
const activeCall = voiceCallsStore.activeCall;
if (!activeCall || activeCall.callId !== data.call_id) return;
if (!data.sdp_answer) return;
applyOutboundAnswer(data.sdp_answer)
.then(() => {
voiceCallsStore.markActiveCallConnected();
emitter.emit('voice_call:agent_webrtc_connected');
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
});
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallPermissionGranted = data => {
emitter.emit('voice_call:permission_granted', {
contactName: data.contact_name,
});
};
}
export default {
+10 -20
View File
@@ -11,28 +11,9 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
};
export const getVoiceCallProvider = inbox => {
if (!inbox) return null;
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
return null;
};
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
@@ -49,6 +30,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -63,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
@@ -75,6 +58,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -113,6 +97,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -155,6 +142,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
+7 -9
View File
@@ -23,11 +23,11 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
};
function extractCallData(message) {
const call = message?.call || {};
const contentData = message?.content_attributes?.data || {};
return {
callSid: call.provider_call_id,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
@@ -60,11 +60,9 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
conversationId,
callStatus: status,
callSid,
});
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
@@ -182,7 +182,6 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Company",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -387,7 +387,6 @@
"IDENTIFIER": "Identifier",
"COUNTRY": "Country",
"CITY": "City",
"COMPANY": "Company",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity",
"REFERER_LINK": "Referer link",
@@ -83,11 +83,7 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"ANSWERED_BY": "Answered by {name}",
"ACCEPT_CALL": "Accept",
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
"TRANSCRIPT": "Transcript"
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -636,17 +636,7 @@
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
}
"CSAT": "CSAT"
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -36,11 +36,9 @@ import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappCall from './whatsappCall.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import yearInReview from './yearInReview.json';
export default {
@@ -82,10 +80,8 @@ export default {
...sla,
...snooze,
...teamsSettings,
...whatsappCall,
...whatsappTemplates,
...contentTemplates,
...mfa,
...onboarding,
...yearInReview,
};
@@ -51,14 +51,10 @@
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -1,34 +0,0 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "Select timezone",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "Saving...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -1,20 +0,0 @@
{
"WHATSAPP_CALL": {
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
"ACCEPT": "Accept",
"REJECT": "Reject",
"HANG_UP": "Hang Up",
"MUTE": "Mute",
"UNMUTE": "Unmute",
"INITIATE_CALL": "Call via WhatsApp",
"CALLING": "Calling…",
"RINGING": "Ringing…",
"CALL_FAILED": "Call failed. Please try again.",
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
"UNKNOWN_CALLER": "Unknown caller",
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
}
}
@@ -20,17 +20,11 @@ const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
const WhatsappCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
export default {
components: {
@@ -42,7 +36,6 @@ export default {
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
WhatsappCallWidget,
MobileSidebarLauncher,
},
setup() {
@@ -51,13 +44,6 @@ export default {
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
const voiceCallsStore = useVoiceCallsStore();
// Detect a stale active WhatsApp call left over from a previous tab /
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
// path because Meta caches our DTLS fingerprint; the only safe action
// is to clean up server-side state and tell the agent the call dropped.
useCallReconnection();
return {
uiSettings,
@@ -65,15 +51,8 @@ export default {
accountId,
upgradePageRef,
windowWidth,
// Twilio voice still flows through useCallSession + the calls store.
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
// voiceCalls store. Both widgets can mount simultaneously; only one
// ever has state at a time because they listen to disjoint cable
// events.
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
};
},
data() {
@@ -184,7 +163,6 @@ export default {
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@@ -53,14 +53,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'company_name',
attributeI18nKey: 'COMPANY',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'standard',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
@@ -132,10 +124,6 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
{
key: 'company_name',
i18nKey: 'COMPANY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
@@ -12,7 +12,6 @@ import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
import OnboardingAccountDetails from './onboarding/Index.vue';
export default {
routes: [
@@ -32,14 +31,6 @@ export default {
...campaignsRoutes.routes,
],
},
{
path: frontendURL('accounts/:accountId/onboarding'),
name: 'onboarding_account_details',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: OnboardingAccountDetails,
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useDocumentVisibility } from '@vueuse/core';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
@@ -13,6 +14,7 @@ const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const visibility = useDocumentVisibility();
const { articleSlug, portalSlug } = route.params;
@@ -26,6 +28,7 @@ const portal = computed(() => portalBySlug.value(portalSlug));
const isUpdating = ref(false);
const isSaved = ref(false);
const refreshTick = ref(0);
const articleLink = computed(() => {
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
@@ -84,11 +87,12 @@ const goBackToArticles = () => {
}
};
const fetchArticleDetails = () => {
store.dispatch('articles/show', {
const fetchArticleDetails = async () => {
await store.dispatch('articles/show', {
id: articleSlug,
portalSlug,
});
refreshTick.value += 1;
};
const previewArticle = () => {
@@ -98,6 +102,11 @@ const previewArticle = () => {
});
};
// Re-fetch article when user returns to this tab
watch(visibility, state => {
if (state === 'visible') fetchArticleDetails();
});
onMounted(fetchArticleDetails);
</script>
@@ -106,6 +115,7 @@ onMounted(fetchArticleDetails);
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
:refresh-tick="refreshTick"
@save-article="saveArticle"
@preview-article="previewArticle"
@go-back="goBackToArticles"
@@ -1,417 +0,0 @@
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import OnboardingLayout from './OnboardingLayout.vue';
import OnboardingSection from './OnboardingSection.vue';
import OnboardingFormRow from './OnboardingFormRow.vue';
import OnboardingFormSelect from './OnboardingFormSelect.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import {
COMPANY_SIZE_OPTIONS,
INDUSTRY_OPTIONS,
REFERRAL_SOURCE_OPTIONS,
USER_ROLE_OPTIONS,
} from './constants';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
const userRole = ref('');
const website = ref('');
const locale = ref('');
const timezone = ref('');
const companySize = ref('');
const industry = ref('');
const referralSource = ref('');
const isSubmitting = ref(false);
const isEditingWebsite = ref(false);
const websiteInput = ref(null);
const showErrorOnFields = ref(false);
const validationRules = {
userRole: {},
website: {},
locale: {},
timezone: {},
companySize: {},
industry: {},
referralSource: {},
};
const v$ = useVuelidate(validationRules, {
userRole,
website,
locale,
timezone,
companySize,
industry,
referralSource,
});
const userName = computed(() => currentUser.value?.name || '');
const userEmail = computed(() => currentUser.value?.email || '');
const accountName = computed(() => currentAccount.value?.name || '');
const enrichmentTimedOut = ref(false);
const isEnriching = computed(
() =>
!enrichmentTimedOut.value &&
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
);
const companyLogo = computed(() => {
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
if (!logos?.length) return '';
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
return (square || logos[0])?.url || '';
});
const languageOptions = computed(() => {
const langs = [...(enabledLanguages || [])];
return langs
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
});
const timezoneOptions = computed(() => {
try {
return Intl.supportedValuesOf('timeZone').map(tz => ({
value: tz,
label: tz.replace(/_/g, ' '),
}));
} catch {
return [];
}
});
// Best-effort match browser language to enabled Chatwoot locales.
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
// falls back to account locale or 'en'.
const detectBestLocale = () => {
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
const browserLang = navigator.language?.replace('-', '_');
const accountLocale = currentAccount.value?.locale || 'en';
if (!browserLang) return accountLocale;
if (codes.includes(browserLang)) return browserLang;
const base = browserLang.split('_')[0];
if (codes.includes(base)) return base;
return accountLocale;
};
// Snapshot of auto-populated values to detect user edits at submit time
const initialValues = ref({});
const snapshotInitialValues = () => {
initialValues.value = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
};
// Idempotent: only fills empty fields, so late-arriving enrichment data
// populates untouched fields without clobbering user edits.
const populateFormFields = () => {
const account = currentAccount.value;
const attrs = account?.custom_attributes || {};
const brandInfo = attrs.brand_info;
if (!locale.value) locale.value = detectBestLocale();
if (!website.value) {
website.value = account?.domain || brandInfo?.domain || '';
}
if (!timezone.value) {
timezone.value =
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
if (!companySize.value) companySize.value = attrs.company_size || '';
if (!industry.value) {
industry.value =
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
}
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
snapshotInitialValues();
};
let enrichmentTimer = null;
const startEnrichmentTimer = () => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
enrichmentTimer = setTimeout(() => {
enrichmentTimedOut.value = true;
populateFormFields();
}, 30000);
};
onMounted(() => {
populateFormFields();
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
if (isEnriching.value) startEnrichmentTimer();
});
onUnmounted(() => {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
});
watch(isEnriching, newVal => {
if (newVal) {
startEnrichmentTimer();
} else {
if (enrichmentTimer) clearTimeout(enrichmentTimer);
populateFormFields();
}
});
// Re-populate when account data arrives after mount, or when brand_info
// appears after enrichment. populateFormFields is idempotent so this is safe.
watch(
() => currentAccount.value?.custom_attributes,
() => populateFormFields()
);
const enableWebsiteEditing = () => {
isEditingWebsite.value = true;
nextTick(() => websiteInput.value?.focus());
};
const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
// onboarding_step and persist incomplete data.
if (isEnriching.value) return;
v$.value.$touch();
if (v$.value.$invalid) {
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
showErrorOnFields.value = true;
setTimeout(() => {
showErrorOnFields.value = false;
}, 600);
return;
}
isSubmitting.value = true;
try {
await updateAccount({
name: accountName.value,
locale: locale.value,
domain: website.value,
industry: industry.value,
company_size: companySize.value,
timezone: timezone.value,
referral_source: referralSource.value,
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
referral_source: referralSource.value,
});
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
store.commit('RESET_ONBOARDING', accountId.value);
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
} catch {
useAlert(t('ONBOARDING_NEXT.ERROR'));
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<OnboardingLayout
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
:is-loading="isSubmitting"
:disabled="isEnriching"
>
<OnboardingSection
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
icon="i-lucide-user"
>
<div class="flex items-center gap-2 px-3 py-3">
<Avatar :name="userName" :size="16" rounded-full />
<span class="text-sm font-medium text-n-slate-12">
{{ userName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
icon="i-lucide-mail"
>
<div class="flex items-center justify-end gap-1.5">
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
<Icon
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
icon="i-lucide-circle-check"
class="size-4 text-n-teal-11 flex-shrink-0"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
icon="i-lucide-user"
>
<OnboardingFormSelect
v-model="userRole"
:has-error="showErrorOnFields && v$.userRole.$error"
:options="USER_ROLE_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
/>
</OnboardingFormRow>
</OnboardingSection>
<OnboardingSection
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
icon="i-lucide-briefcase-business"
>
<div
v-if="isEnriching"
class="flex items-center justify-center gap-3 py-8"
>
<Spinner :size="16" class="text-n-blue-10" />
<span class="text-sm text-n-slate-11">
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
</span>
</div>
<template v-else>
<div class="flex items-center gap-2 px-3 py-3">
<img
v-if="companyLogo"
:src="companyLogo"
:alt="accountName"
class="size-4 object-contain"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ accountName }}
</span>
</div>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
icon="i-lucide-globe"
>
<div class="flex items-center justify-end gap-2">
<InlineInput
ref="websiteInput"
v-model="website"
:readonly="!isEditingWebsite"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
:custom-input-class="[
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
{ 'animate-shake': showErrorOnFields && v$.website.$error },
]"
@enter-press="handleWebsiteEnter"
@blur="isEditingWebsite = false"
/>
<NextButton
type="button"
icon="i-lucide-pencil"
slate
xs
ghost
@click="enableWebsiteEditing"
/>
</div>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
icon="i-lucide-languages"
>
<OnboardingFormSelect
v-model="locale"
:has-error="showErrorOnFields && v$.locale.$error"
:options="languageOptions"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
icon="i-lucide-clock"
>
<OnboardingFormSelect
v-model="timezone"
:has-error="showErrorOnFields && v$.timezone.$error"
:options="timezoneOptions"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
icon="i-lucide-factory"
>
<OnboardingFormSelect
v-model="industry"
:has-error="showErrorOnFields && v$.industry.$error"
:options="INDUSTRY_OPTIONS"
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
icon="i-lucide-users"
>
<OnboardingFormSelect
v-model="companySize"
:has-error="showErrorOnFields && v$.companySize.$error"
:options="COMPANY_SIZE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
"
/>
</OnboardingFormRow>
<OnboardingFormRow
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
icon="i-lucide-megaphone"
>
<OnboardingFormSelect
v-model="referralSource"
:has-error="showErrorOnFields && v$.referralSource.$error"
:options="REFERRAL_SOURCE_OPTIONS"
:placeholder="
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
"
/>
</OnboardingFormRow>
</template>
</OnboardingSection>
</OnboardingLayout>
</form>
</template>
@@ -1,18 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
<div class="flex items-center gap-2">
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
<span class="text-n-slate-11">{{ title }}</span>
</div>
<slot />
</div>
</template>
@@ -1,37 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
modelValue: { type: String, default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
hasError: { type: Boolean, default: false },
});
defineEmits(['update:modelValue']);
</script>
<template>
<div class="relative flex items-center justify-end">
<select
:value="modelValue"
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
:class="[
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
{ 'animate-shake': hasError },
]"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled>
{{ placeholder }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<Icon
icon="i-lucide-chevron-down"
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
/>
</div>
</template>
@@ -1,120 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
greeting: { type: String, required: true },
subtitle: { type: String, default: '' },
continueLabel: { type: String, default: 'Continue' },
isLoading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
defineEmits(['continue']);
</script>
<template>
<div
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
>
<!-- Grid background with corner fade -->
<div
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
/>
<div class="relative w-full max-w-[580px]">
<div class="relative ps-12">
<!-- Timeline dotted line -->
<svg
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
width="1"
height="100%"
preserveAspectRatio="none"
>
<line
x1="0"
y1="0"
x2="0"
y2="97%"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="3 3"
/>
</svg>
<!-- Greeting -->
<div class="mb-6 -ms-12 flex items-start gap-4">
<div
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
>
<slot name="greeting-icon">
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
</slot>
</div>
<div>
<h1 class="text-heading-1 text-n-slate-12">
{{ greeting }}
</h1>
<p v-if="subtitle" class="text-sm text-n-slate-11">
{{ subtitle }}
</p>
</div>
</div>
<!-- Sections -->
<slot />
</div>
<!-- Continue button with curved connector -->
<div class="relative ps-12 overflow-visible">
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
<svg
width="48"
height="40"
viewBox="0 0 47 40"
fill="none"
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
>
<defs>
<linearGradient
id="line-gradient"
x1="15"
y1="0"
x2="48"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
</linearGradient>
</defs>
<path
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
stroke="url(#line-gradient)"
stroke-width="1"
stroke-dasharray="3 3"
fill="none"
/>
</svg>
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
>
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
</svg>
<NextButton
type="submit"
blue
:is-loading="isLoading"
:disabled="disabled"
class="w-full justify-center"
@click="$emit('continue')"
>
{{ continueLabel }}
</NextButton>
</div>
</div>
</div>
</template>
@@ -1,49 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
title: { type: String, required: true },
icon: { type: String, required: true },
});
</script>
<template>
<div class="mb-5">
<!-- Section header with icon + triangles -->
<div class="flex items-center gap-4 mb-3 -ms-12">
<div class="flex flex-col items-center z-10 flex-shrink-0">
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
</svg>
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
>
<Icon :icon="icon" class="size-4 text-n-slate-11" />
</div>
<svg
width="6"
height="5"
viewBox="0 0 6 5"
fill="none"
class="text-n-slate-5"
>
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
</svg>
</div>
<span class="text-heading-3 text-n-slate-12">
{{ title }}
</span>
</div>
<!-- Card -->
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
<slot />
</div>
</div>
</template>
@@ -1,68 +0,0 @@
export const COMPANY_SIZE_OPTIONS = [
{ value: '1-10', label: '1 - 10' },
{ value: '11-50', label: '11 - 50' },
{ value: '51-200', label: '51 - 200' },
{ value: '201-500', label: '201 - 500' },
{ value: '500+', label: '500+' },
];
export const INDUSTRY_OPTIONS = [
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
{
value: 'Automotive & Transportation',
label: 'Automotive & Transportation',
},
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
{
value: 'Construction & Built Environment',
label: 'Construction & Built Environment',
},
{
value: 'Consumer Packaged Goods (CPG)',
label: 'Consumer Packaged Goods (CPG)',
},
{ value: 'Education', label: 'Education' },
{ value: 'Entertainment', label: 'Entertainment' },
{ value: 'Finance', label: 'Finance' },
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
{ value: 'Healthcare', label: 'Healthcare' },
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
{ value: 'News & Media', label: 'News & Media' },
{
value: 'Professional Services & Agencies',
label: 'Professional Services & Agencies',
},
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
{ value: 'Sports', label: 'Sports' },
{ value: 'Technology', label: 'Technology' },
{ value: 'Telecommunications', label: 'Telecommunications' },
{ value: 'Other', label: 'Other' },
];
export const REFERRAL_SOURCE_OPTIONS = [
{ value: 'google', label: 'Google' },
{ value: 'reddit', label: 'Reddit' },
{ value: 'twitter', label: 'Twitter/X' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'friend', label: 'Friend/Colleague' },
{ value: 'blog', label: 'Blog/Article' },
{ value: 'github', label: 'GitHub' },
{ value: 'other', label: 'Other' },
];
export const USER_ROLE_OPTIONS = [
{ value: 'founder', label: 'Founder/CEO' },
{ value: 'product_manager', label: 'Product Manager' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'support_lead', label: 'Support Lead' },
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'other', label: 'Other' },
];
@@ -74,12 +74,6 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'labels',
name: 'LABELS',
@@ -186,12 +180,6 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'referer',
name: 'REFERER_LINK',
@@ -326,12 +314,6 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
@@ -478,12 +460,6 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -614,12 +590,6 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'company_name',
name: 'COMPANY_NAME',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'team_id',
name: 'TEAM_NAME',
@@ -145,7 +145,6 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -21,7 +21,6 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -47,7 +46,6 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
VoiceConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
@@ -171,17 +169,19 @@ export default {
},
];
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (!this.isAVoiceChannel) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'business-hours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
}
if (this.isAWebWidgetInbox) {
visibleToAllChannelTabs = [
@@ -197,6 +197,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -231,24 +232,6 @@ export default {
];
}
if (
this.isATwilioChannel &&
this.inbox.phone_number &&
this.inbox.medium === 'sms' &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
)
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
{
key: 'voice-configuration',
name: this.$t('INBOX_MGMT.TABS.VOICE'),
},
];
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -829,6 +812,7 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1256,12 +1240,6 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -12,10 +12,6 @@ const props = defineProps({
type: String,
default: '',
},
voiceEnabled: {
type: Boolean,
default: false,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
@@ -34,6 +30,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
@@ -48,9 +45,6 @@ const readableChannelName = computed(() => {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
if (props.voiceEnabled) {
return t('INBOX_MGMT.CHANNELS.VOICE');
}
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
@@ -208,6 +208,24 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -1,156 +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 NextInput from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
NextInput,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
voiceEnabled: this.inbox.voice_enabled || false,
apiKeySid: this.inbox.api_key_sid || '',
apiKeySecret: '',
isUpdating: false,
};
},
computed: {
isVoiceConfigured() {
return !!this.inbox.voice_configured;
},
hasApiKeySid() {
return !!this.inbox.api_key_sid;
},
hasExistingCredentials() {
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
},
needsCredentials() {
return (
this.voiceEnabled &&
!this.isVoiceConfigured &&
!this.hasExistingCredentials
);
},
needsApiKeySid() {
return this.needsCredentials && !this.hasApiKeySid;
},
isSubmitDisabled() {
if (!this.voiceEnabled) return false;
if (this.needsCredentials) {
if (this.needsApiKeySid && !this.apiKeySid) return true;
return !this.apiKeySecret;
}
return false;
},
},
watch: {
'inbox.voice_enabled'(val) {
this.voiceEnabled = val || false;
},
'inbox.api_key_sid'(val) {
this.apiKeySid = val || '';
},
},
methods: {
async updateVoiceSettings() {
this.isUpdating = true;
try {
const channelPayload = { voice_enabled: this.voiceEnabled };
if (this.needsCredentials) {
if (this.needsApiKeySid) {
channelPayload.api_key_sid = this.apiKeySid;
}
channelPayload.api_key_secret = this.apiKeySecret;
}
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel: channelPayload,
});
this.apiKeySecret = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
@@ -31,8 +31,6 @@ const backupCodesDialogRef = ref(null);
// Form values
const disablePassword = ref('');
const disableOtpCode = ref('');
const disableBackupCode = ref('');
const useBackupCodeToDisable = ref(false);
const regenerateOtpCode = ref('');
// Utility functions
@@ -56,17 +54,10 @@ const downloadBackupCodes = () => {
const handleDisableMfa = async () => {
emit('disableMfa', {
password: disablePassword.value,
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
otpCode: disableOtpCode.value,
});
};
const toggleDisableMethod = () => {
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
disableOtpCode.value = '';
disableBackupCode.value = '';
};
const handleRegenerateBackupCodes = async () => {
emit('regenerateBackupCodes', {
otpCode: regenerateOtpCode.value,
@@ -77,8 +68,6 @@ const handleRegenerateBackupCodes = async () => {
const resetDisableForm = () => {
disablePassword.value = '';
disableOtpCode.value = '';
disableBackupCode.value = '';
useBackupCodeToDisable.value = false;
disableDialogRef.value?.close();
};
@@ -168,32 +157,12 @@ defineExpose({
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
/>
<Input
v-if="!useBackupCodeToDisable"
v-model="disableOtpCode"
type="text"
maxlength="6"
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
/>
<Input
v-else
v-model="disableBackupCode"
type="text"
maxlength="8"
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
/>
<Button
link
sm
type="button"
:label="
useBackupCodeToDisable
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
"
@click="toggleDisableMethod"
/>
</div>
</Dialog>
@@ -104,9 +104,9 @@ const cancelSetup = () => {
};
// Disable MFA
const disableMfa = async ({ password, otpCode, backupCode }) => {
const disableMfa = async ({ password, otpCode }) => {
try {
await mfaAPI.disable(password, { otpCode, backupCode });
await mfaAPI.disable(password, otpCode);
mfaEnabled.value = false;
backupCodesGenerated.value = false;
managementActionsRef.value?.resetDisableForm();
+6 -24
View File
@@ -4,15 +4,13 @@ import { frontendURL } from '../helper/URLHelper';
import dashboard from './dashboard/dashboard.routes';
import store from 'dashboard/store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
const routes = [...dashboard.routes];
export const router = createRouter({ history: createWebHistory(), routes });
export const validateAuthenticateRoutePermission = async (to, next) => {
export const validateAuthenticateRoutePermission = (to, next) => {
const { isLoggedIn, getCurrentUser: user } = store.getters;
if (!isLoggedIn) {
@@ -29,25 +27,8 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
return next(frontendURL('no-accounts'));
}
const routeAccountId = Number(to.params?.accountId || accountId);
const userAccount = accounts.find(a => a.id === routeAccountId);
const isAdmin = userAccount?.role === 'administrator';
const isActive = userAccount?.status === 'active';
const needsOnboarding =
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
isAdmin &&
isActive;
if (to.name === 'no_accounts' || !to.name) {
const target = needsOnboarding ? 'onboarding' : 'dashboard';
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
}
if (needsOnboarding && !isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
}
if (!needsOnboarding && isOnOnboardingView(to)) {
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
return next(frontendURL(`accounts/${accountId}/dashboard`));
}
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
@@ -57,14 +38,15 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach(async (to, _from, next) => {
router.beforeEach((to, _from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
await userAuthentication;
await validateAuthenticateRoutePermission(to, next, store);
userAuthentication.then(() => {
return validateAuthenticateRoutePermission(to, next, store);
});
});
};
@@ -12,9 +12,7 @@ vi.mock('../store', () => ({
id: null,
accounts: [],
},
'accounts/getAccount': () => ({}),
},
dispatch: vi.fn(() => Promise.resolve()),
},
}));
@@ -62,14 +60,14 @@ describe('#validateAuthenticateRoutePermission', () => {
});
describe('when route is not accessible to current user', () => {
it('should redirect to dashboard', async () => {
it('should redirect to dashboard', () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
await validateAuthenticateRoutePermission(to, next);
validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
});
@@ -92,14 +90,14 @@ describe('#validateAuthenticateRoutePermission', () => {
};
});
it('should go to the intended route', async () => {
it('should go to the intended route', () => {
const to = {
name: 'general_settings_index',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
};
await validateAuthenticateRoutePermission(to, next);
validateAuthenticateRoutePermission(to, next);
expect(next).toHaveBeenCalledWith();
});
@@ -54,19 +54,18 @@ export const getters = {
};
export const actions = {
get: async ({ commit }, { silent } = {}) => {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
}
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
} catch {
// silent failure
} finally {
if (!silent) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
}
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
}
},
update: async ({ commit }, { options, ...updateObj }) => {
@@ -269,20 +269,6 @@ export const mutations = {
accounts,
};
},
[types.RESET_ONBOARDING](_state, accountId) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === accountId) {
const { onboarding_step, ...rest } = account;
return rest;
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
@@ -307,21 +307,34 @@ export const mutations = {
}
},
[types.UPDATE_MESSAGE_CALL_STATUS](
[types.UPDATE_CONVERSATION_CALL_STATUS](
_state,
{ conversationId, callStatus, callSid }
{ conversationId, callStatus }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const message = (chat.messages || []).find(
m =>
m.content_type === CONTENT_TYPES.VOICE_CALL &&
m.call?.provider_call_id === callSid
);
if (!message?.call) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
message.call = { ...message.call, status: callStatus };
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
);
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
@@ -2,18 +2,55 @@ import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no matching voice call message exists', () => {
it('does nothing if no voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
@@ -22,7 +59,6 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
@@ -30,7 +66,7 @@ describe('#mutations', () => {
});
});
it('updates only the voice call message matching the given callSid', () => {
it('updates the last voice call message status', () => {
const state = {
allConversations: [
{
@@ -39,12 +75,12 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA111', status: 'ringing' },
content_attributes: { data: { status: 'ringing' } },
},
{
id: 2,
content_type: 'voice_call',
call: { provider_call_id: 'CA222', status: 'ringing' },
content_attributes: { data: { status: 'ringing' } },
},
],
},
@@ -53,15 +89,34 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA111',
});
expect(state.allConversations[0].messages[0].call.status).toBe(
'in-progress'
);
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
});
it('preserves existing call fields when updating status', () => {
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = {
allConversations: [
{
@@ -70,11 +125,8 @@ describe('#mutations', () => {
{
id: 1,
content_type: 'voice_call',
call: {
provider_call_id: 'CA123',
status: 'ringing',
direction: 'incoming',
duration_seconds: null,
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
},
},
],
@@ -84,13 +136,12 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
callSid: 'CA123',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA123',
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
status: 'in-progress',
direction: 'incoming',
duration_seconds: null,
});
});
@@ -101,34 +152,8 @@ describe('#mutations', () => {
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
callSid: 'CA123',
});
expect(state.allConversations[0].messages).toEqual([]);
});
it('does nothing if matching message has no call object yet', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
call: { provider_call_id: 'CA-OTHER' },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
callSid: 'CA-MISSING',
});
expect(state.allConversations[0].messages[0].call).toEqual({
provider_call_id: 'CA-OTHER',
});
});
});
});
@@ -57,46 +57,4 @@ describe('#mutations', () => {
);
});
});
describe('#RESET_ONBOARDING', () => {
it('removes onboarding_step from the targeted account', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{
id: 1,
onboarding_step: 'account_details',
role: 'administrator',
},
],
},
};
mutations[types.RESET_ONBOARDING](state, 1);
expect(state.currentUser.accounts[0]).not.toHaveProperty(
'onboarding_step'
);
expect(state.currentUser.accounts[0].role).toEqual('administrator');
});
it('targets the route account, not currentUser.account_id', () => {
const state = {
currentUser: {
id: 1,
account_id: 1,
accounts: [
{ id: 1, onboarding_step: 'account_details' },
{ id: 2, onboarding_step: 'account_details' },
],
},
};
mutations[types.RESET_ONBOARDING](state, 2);
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
'account_details'
);
expect(state.currentUser.accounts[1]).not.toHaveProperty(
'onboarding_step'
);
});
});
});
@@ -4,7 +4,6 @@ export default {
SET_CURRENT_USER: 'SET_CURRENT_USER',
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
RESET_ONBOARDING: 'RESET_ONBOARDING',
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
@@ -52,6 +51,7 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
@@ -1,65 +0,0 @@
import { defineStore } from 'pinia';
export const useVoiceCallsStore = defineStore('voiceCalls', {
state: () => ({
// Incoming ringing calls waiting for agent action (per account)
incomingCalls: [],
// The single active call (accepted + audio connected)
activeCall: null,
// Cleanup callback registered by the composable — called when a call ends externally
cleanupCallback: null,
}),
getters: {
hasIncomingCall: state => state.incomingCalls.length > 0,
hasActiveCall: state => state.activeCall !== null,
hasVoiceCall: state =>
state.incomingCalls.length > 0 || state.activeCall !== null,
firstIncomingCall: state => state.incomingCalls[0] || null,
},
actions: {
addIncomingCall(callData) {
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
if (exists) return;
this.incomingCalls.push(callData);
},
removeIncomingCall(callId) {
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
},
setActiveCall(callData) {
this.activeCall = callData;
},
clearActiveCall() {
this.activeCall = null;
},
markActiveCallConnected() {
if (this.activeCall) {
this.activeCall = { ...this.activeCall, status: 'connected' };
}
},
registerCleanupCallback(callback) {
this.cleanupCallback = callback;
},
handleCallAcceptedByOther(callId) {
this.removeIncomingCall(callId);
},
handleCallEnded(callId) {
this.removeIncomingCall(callId);
if (this.activeCall?.callId === callId) {
// Snapshot the DB id before nulling activeCall so the composable can
// POST upload_recording with the right path even after we clear state.
const dbCallId = this.activeCall?.id;
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
this.activeCall = null;
}
},
},
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
@@ -59,8 +59,8 @@ export default {
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
voiceCallEnabled() {
return isVoiceCallEnabled(this.inbox);
isAVoiceChannel() {
return this.channelType === INBOX_TYPES.VOICE;
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
+2 -2
View File
@@ -1,6 +1,6 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :high
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
rescue Webhooks::Trigger::RetryableError => e
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end
-2
View File
@@ -100,5 +100,3 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
end
Webhooks::WhatsappEventsJob.prepend_mod_with('Webhooks::WhatsappEventsJob')
-8
View File
@@ -157,14 +157,6 @@ class Account < ApplicationRecord
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
end
def onboarding_step
step = custom_attributes['onboarding_step']
return nil if step.blank?
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
end
private
def notify_creation
-1
View File
@@ -32,7 +32,6 @@ class AgentBot < ApplicationRecord
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
+1 -1
View File
@@ -35,7 +35,7 @@ class AutomationRule < ApplicationRecord
scope :active, -> { where(active: true) }
def conditions_attributes
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
mail_subject phone_number priority conversation_language labels private_note]
end
-5
View File
@@ -4,7 +4,6 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_secret :string
# api_key_sid :string
# auth_token :string not null
# content_templates :jsonb
@@ -12,8 +11,6 @@
# medium :integer default("sms")
# messaging_service_sid :string
# phone_number :string
# twiml_app_sid :string
# voice_enabled :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -79,5 +76,3 @@ class Channel::TwilioSms < ApplicationRecord
end
end
end
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
+1 -1
View File
@@ -25,7 +25,7 @@ class CustomAttributeDefinition < ApplicationRecord
STANDARD_ATTRIBUTES = {
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
last_activity_at],
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
}.freeze
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
-5
View File
@@ -152,14 +152,9 @@ class Message < ApplicationRecord
)
data[:echo_id] = echo_id if echo_id.present?
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
data[:call] = call.push_event_data if voice_call? && respond_to?(:call) && call.present?
merge_sender_attributes(data)
end
def voice_call?
content_type == 'voice_call'
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
-1
View File
@@ -75,7 +75,6 @@ class User < ApplicationRecord
# work because :validatable in devise overrides this.
# validates_uniqueness_of :email, scope: :account_id
validates :name, presence: true
validates :email, presence: true
serialize :otp_backup_codes, type: Array
+1 -4
View File
@@ -46,10 +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 in-app system events for the call lifecycle —
# dispatching them to the channel would deliver "WhatsApp Call" as a
# text message to the contact every time the agent placed a call.
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
message.private? || outgoing_message_originated_from_channel?
end
def validate_target_channel
+1 -1
View File
@@ -61,7 +61,7 @@ class DataImport::ContactManager
def update_contact_attributes(params, contact)
contact.name = params[:name] if params[:name].present?
contact.additional_attributes ||= {}
contact.additional_attributes[:company_name] = params[:company_name] if params[:company_name].present?
contact.additional_attributes[:company] = params[:company] if params[:company].present?
contact.additional_attributes[:city] = params[:city] if params[:city].present?
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
end
@@ -1,6 +1,4 @@
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
prepend Whatsapp::Providers::WhatsappCloudCallMethods if defined?(Whatsapp::Providers::WhatsappCloudCallMethods)
def send_message(phone_number, message)
@message = message
@@ -10,9 +10,7 @@ if resource.custom_attributes.present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
json.logo resource.custom_attributes['logo'] if resource.custom_attributes['logo'].present?
json.referral_source resource.custom_attributes['referral_source'] if resource.custom_attributes['referral_source'].present?
json.brand_info resource.custom_attributes['brand_info'] if resource.custom_attributes['brand_info'].present?
json.onboarding_step resource.onboarding_step if resource.onboarding_step.present?
json.onboarding_step resource.custom_attributes['onboarding_step'] if resource.custom_attributes['onboarding_step'].present?
json.marked_for_deletion_at resource.custom_attributes['marked_for_deletion_at'] if resource.custom_attributes['marked_for_deletion_at'].present?
if resource.custom_attributes['marked_for_deletion_reason'].present?
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
+4 -13
View File
@@ -72,7 +72,6 @@ if resource.twilio?
if Current.account_user&.administrator?
json.auth_token resource.channel.try(:auth_token)
json.account_sid resource.channel.try(:account_sid)
json.api_key_sid resource.channel.try(:api_key_sid)
end
end
@@ -130,18 +129,10 @@ if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
# `calling_enabled` is needed by every agent (not just admins) so the
# ConversationHeader can decide whether to render the outbound call button.
json.calling_enabled resource.channel.try(:provider_config)&.fetch('calling_enabled', false) || false
end
## Voice attributes for TwilioSms
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
json.voice_configured resource.channel.try(:twiml_app_sid).present?
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
if resource.channel.try(:twiml_app_sid).present?
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
## Voice Channel Attributes
if resource.channel_type == 'Channel::Voice'
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
end
@@ -12,7 +12,3 @@ json.private message.private
json.source_id message.source_id
json.sender message.sender.push_event_data if message.sender
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
json.set! :call, message.call.push_event_data
end
@@ -22,7 +22,6 @@ json.accounts do
json.id account_user.account_id
json.name account_user.account.name
json.status account_user.account.status
json.onboarding_step account_user.account.onboarding_step
json.active_at account_user.active_at
json.role account_user.role
json.permissions account_user.permissions
@@ -1,6 +1,3 @@
json.array! @resources do |resource|
bot = resource.permissible
next if bot.nil?
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: bot
json.partial! 'platform/api/v1/models/agent_bot', formats: [:json], resource: resource.permissible
end
-13
View File
@@ -303,19 +303,6 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
resources :voice_calls, only: [:show] do
collection do
get :active
post :initiate
end
member do
post :accept
post :reject
post :terminate
post :upload_recording
end
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -1,7 +0,0 @@
class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0]
def change
add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false
add_column :channel_twilio_sms, :twiml_app_sid, :string
add_column :channel_twilio_sms, :api_key_secret, :string
end
end
@@ -1,20 +0,0 @@
class DropChannelVoice < ActiveRecord::Migration[7.0]
def up
drop_table :channel_voice, if_exists: true
end
def down
create_table :channel_voice do |t|
t.string :phone_number, null: false
t.string :provider, null: false, default: 'twilio'
t.jsonb :provider_config, null: false
t.integer :account_id, null: false
t.jsonb :additional_attributes, default: {}
t.timestamps
end
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
end
@@ -1,47 +0,0 @@
class RenameCompanyConditionKeyInAutomationRules < ActiveRecord::Migration[7.1]
def up
migrate_automation_rule_conditions
migrate_contact_custom_filter_queries
end
def down; end
private
def migrate_automation_rule_conditions
AutomationRule.find_each do |rule|
conditions = rename_company_attribute_key(rule.conditions)
next if conditions == rule.conditions
rule.update_column(:conditions, conditions) # rubocop:disable Rails/SkipsModelValidations
end
end
def migrate_contact_custom_filter_queries
CustomFilter.contact.find_each do |filter|
query = filter.query.deep_dup
payload = rename_company_attribute_key(query['payload'])
next if payload == query['payload']
query['payload'] = payload
filter.update_column(:query, query) # rubocop:disable Rails/SkipsModelValidations
end
end
def rename_company_attribute_key(conditions)
return conditions unless conditions.is_a?(Array)
conditions.map do |condition|
next condition unless standard_company_condition?(condition)
condition.merge('attribute_key' => 'company_name')
end
end
def standard_company_condition?(condition)
condition['attribute_key'] == 'company' &&
condition['custom_attribute_type'].blank? &&
condition['attribute_model'].in?([nil, '', 'standard'])
end
end
+13 -4
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
ActiveRecord::Schema[7.1].define(version: 2026_04_10_092753) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -552,9 +552,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.string "api_key_sid"
t.jsonb "content_templates", default: {}
t.datetime "content_templates_last_updated"
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -571,6 +568,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
private
def generate_source_id
return super unless twilio_voice_inbox?
return super unless @inbox.channel_type == 'Channel::Voice'
phone_source_id
end
def phone_source_id
return super unless twilio_voice_inbox?
return super unless @inbox.channel_type == 'Channel::Voice'
return SecureRandom.uuid if @contact.phone_number.blank?
@@ -16,10 +16,6 @@ module Enterprise::ContactInboxBuilder
end
def allowed_channels?
super || twilio_voice_inbox?
end
def twilio_voice_inbox?
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
super || @inbox.channel_type == 'Channel::Voice'
end
end
@@ -2,13 +2,8 @@ module Enterprise::Messages::MessageBuilder
private
def message_type
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
super
end
def twilio_voice_inbox?
inbox = @conversation.inbox
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
end
end
@@ -10,35 +10,36 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
end
def create
call = resolve_call!
conversation = fetch_conversation_by_display_id
ensure_call_sid!(conversation)
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
conference_sid = conference_service.ensure_conference_sid
conference_service.mark_agent_joined(user: current_user)
render json: {
status: 'success',
id: call.conversation.display_id,
id: conversation.display_id,
conference_sid: conference_sid,
using_webrtc: true
}
end
def destroy
call = resolve_call!
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
render json: { status: 'success', id: call.conversation.display_id }
conversation = fetch_conversation_by_display_id
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
render json: { status: 'success', id: conversation.display_id }
end
private
def resolve_call!
sid = params[:call_sid].presence
raise ActionController::ParameterMissing, :call_sid if sid.blank?
def ensure_call_sid!(conversation)
return conversation.identifier if conversation.identifier.present?
conversation = fetch_conversation_by_display_id
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
.find_by!(provider_call_id: sid)
incoming_sid = params.require(:call_sid)
conversation.update!(identifier: incoming_sid)
incoming_sid
end
def set_voice_inbox_for_conference

Some files were not shown because too many files have changed in this diff Show More