Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0370fbe1 | ||
|
|
e415b0576a | ||
|
|
353089473e | ||
|
|
1124c1b4c2 | ||
|
|
cd9c8e3303 | ||
|
|
80fccbc526 | ||
|
|
bcdb73502e | ||
|
|
d634ced4e9 | ||
|
|
e723c6b6f2 | ||
|
|
5325e05143 | ||
|
|
c09206c22e |
@@ -22,6 +22,7 @@ gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
+5
-3
@@ -108,8 +108,8 @@ GEM
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
@@ -349,6 +349,7 @@ GEM
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
gemoji (4.1.0)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -676,7 +677,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -1075,6 +1076,7 @@ DEPENDENCIES
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
|
||||
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||
}
|
||||
end
|
||||
|
||||
def active_platform_banners
|
||||
return [] unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
|
||||
end
|
||||
|
||||
def allowed_login_methods
|
||||
methods = ['email']
|
||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
|
||||
before_action :ensure_chatwoot_cloud
|
||||
|
||||
private
|
||||
|
||||
def ensure_chatwoot_cloud
|
||||
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require 'administrate/base_dashboard'
|
||||
|
||||
class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
banner_message: Field::Text.with_options(truncate: 200),
|
||||
banner_type: Field::Select.with_options(collection: %w[info warning error]),
|
||||
active: Field::Boolean,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
end
|
||||
@@ -48,3 +48,5 @@ class MessageFinder
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
MessageFinder.prepend_mod_with('MessageFinder')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import StatusBanner from './components/app/StatusBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -27,6 +28,7 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
StatusBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
@@ -137,6 +139,7 @@ export default {
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<StatusBanner />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
async joinClientCall({ to, conversationId, callSid }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
|
||||
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference(inboxId, conversationId) {
|
||||
leaveConference({ inboxId, conversationId, callSid }) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId },
|
||||
params: { conversation_id: conversationId, call_sid: callSid },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
+10
-4
@@ -35,10 +35,16 @@ const emit = defineEmits([
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
slate:
|
||||
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||
amber:
|
||||
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -113,6 +113,7 @@ const props = defineProps({
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: { type: Array, default: () => [] },
|
||||
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { canCurrentUserJoinCall } from 'dashboard/helper/voice';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -11,11 +15,6 @@ const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
@@ -30,15 +29,50 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
const JOINABLE_STATUSES = [
|
||||
VOICE_CALL_STATUS.RINGING,
|
||||
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
];
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId, inboxId, sender } =
|
||||
useMessageContext();
|
||||
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
|
||||
useCallSession({
|
||||
manageSessionState: false,
|
||||
});
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.accepted_by_agent_name)
|
||||
return call.value.accepted_by_agent_name;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
@@ -52,16 +86,28 @@ const labelKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -70,11 +116,83 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const callSid = computed(() => call.value?.provider_call_id);
|
||||
const isAlreadyOnThisCall = computed(
|
||||
() => !!callSid.value && activeCall.value?.callSid === callSid.value
|
||||
);
|
||||
const resolvedInboxId = computed(() => {
|
||||
if (inboxId?.value) return inboxId.value;
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.inbox_id || null;
|
||||
});
|
||||
const conversationForVisibility = computed(() =>
|
||||
store.getters.getConversationById?.(conversationId?.value)
|
||||
);
|
||||
const isVisibleToCurrentUser = computed(() =>
|
||||
canCurrentUserJoinCall({
|
||||
call: call.value,
|
||||
conversation: conversationForVisibility.value,
|
||||
senderId: sender?.value?.id,
|
||||
currentUserId: currentUserId?.value,
|
||||
})
|
||||
);
|
||||
const canJoin = computed(
|
||||
() =>
|
||||
JOINABLE_STATUSES.includes(status.value) &&
|
||||
!!callSid.value &&
|
||||
!!resolvedInboxId.value &&
|
||||
!!conversationId?.value &&
|
||||
!isAlreadyOnThisCall.value &&
|
||||
isVisibleToCurrentUser.value
|
||||
);
|
||||
const joinLabel = computed(() =>
|
||||
status.value === VOICE_CALL_STATUS.IN_PROGRESS && didCurrentUserAnswer.value
|
||||
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.JOIN_CALL')
|
||||
);
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
const id = activeCall.value?.conversationId;
|
||||
return id ? store.getters.getConversationById?.(id) : null;
|
||||
});
|
||||
|
||||
const handleJoinClick = async () => {
|
||||
if (!canJoin.value || isJoining.value) return;
|
||||
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||
const activeInboxId = activeConversation.value?.inbox_id;
|
||||
if (activeCall.value?.conversationId && activeInboxId) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeInboxId,
|
||||
callSid: activeCall.value.callSid,
|
||||
});
|
||||
}
|
||||
}
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: resolvedInboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!canJoin || isJoining"
|
||||
data-test-id="voice-call-join"
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs text-left bg-transparent border-none"
|
||||
:class="{
|
||||
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
|
||||
canJoin,
|
||||
'cursor-default': !canJoin,
|
||||
}"
|
||||
@click="handleJoinClick"
|
||||
>
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
@@ -95,10 +213,13 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<span v-if="canJoin" class="mt-1 text-xs font-medium text-n-teal-10">
|
||||
{{ joinLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const BANNER_COLOR_MAP = {
|
||||
info: 'blue',
|
||||
warning: 'amber',
|
||||
error: 'ruby',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const dismissedBannerIds = ref(
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
|
||||
);
|
||||
|
||||
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
|
||||
|
||||
const visibleBanners = computed(() => {
|
||||
const banners = globalConfig.value?.activePlatformBanners || [];
|
||||
return banners.filter(
|
||||
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
|
||||
);
|
||||
});
|
||||
|
||||
const formattedMessage = message =>
|
||||
new MessageFormatter(message).formattedMessage;
|
||||
|
||||
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
|
||||
|
||||
const dismissBanner = banner => {
|
||||
const key = dismissKey(banner);
|
||||
if (dismissedBannerIds.value.includes(key)) return;
|
||||
|
||||
dismissedBannerIds.value.push(key);
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
|
||||
dismissedBannerIds.value
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-for="banner in visibleBanners"
|
||||
:key="banner.id"
|
||||
:color="bannerColor(banner.banner_type)"
|
||||
:action-label="t('GENERAL_SETTINGS.DISMISS')"
|
||||
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
|
||||
@action="dismissBanner(banner)"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="formattedMessage(banner.banner_message)"
|
||||
class="text-xs"
|
||||
/>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
// `manageSessionState: false` lets sub-consumers (like the timeline VoiceCall
|
||||
// bubble) reuse joinCall/activeCall without registering duplicate Twilio
|
||||
// listeners or running their own duration timer — only the floating widget
|
||||
// owns the session lifecycle.
|
||||
export function useCallSession({ manageSessionState = true } = {}) {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@@ -16,36 +23,38 @@ export function useCallSession() {
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
if (manageSessionState) {
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
if (manageSessionState) durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
@@ -66,13 +75,19 @@ export function useCallSession() {
|
||||
await TwilioVoiceClient.joinClientCall({
|
||||
to: joinResponse?.conference_sid,
|
||||
conversationId,
|
||||
callSid,
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
if (manageSessionState) durationTimer.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
|
||||
@@ -22,13 +22,55 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
if (currentUserId == null) return false;
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
||||
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
||||
// conversation may be auto-assigned to a different agent on creation, so
|
||||
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
||||
if (callDirection === 'outbound') return true;
|
||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||
};
|
||||
|
||||
// Whether the current user is allowed to take over / join an existing call.
|
||||
// Mirrors the floating-widget visibility rules so the bubble's join action
|
||||
// stays in sync.
|
||||
export const canCurrentUserJoinCall = ({
|
||||
call,
|
||||
conversation,
|
||||
senderId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (!call) return false;
|
||||
return shouldShowCall({
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
senderId,
|
||||
assigneeId: extractAssigneeId(conversation),
|
||||
currentUserId,
|
||||
});
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
callSid: contentData.call_sid,
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
callSid: call.provider_call_id,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -36,10 +78,19 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@@ -53,22 +104,38 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
callSid,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCall(callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewCall) {
|
||||
if (status === 'ringing') {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
@@ -77,3 +144,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJOIN_CALL": "Rejoin call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
@@ -393,19 +394,18 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateConversation({ commit, dispatch }, conversation) {
|
||||
const {
|
||||
meta: { sender },
|
||||
} = conversation;
|
||||
updateConversation({ commit, dispatch, rootGetters }, conversation) {
|
||||
const sender = conversation.meta?.sender;
|
||||
|
||||
commit(types.UPDATE_CONVERSATION, conversation);
|
||||
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
|
||||
|
||||
dispatch('conversationLabels/setConversationLabel', {
|
||||
id: conversation.id,
|
||||
data: conversation.labels,
|
||||
});
|
||||
|
||||
dispatch('contacts/setContact', sender);
|
||||
if (sender) dispatch('contacts/setContact', sender);
|
||||
},
|
||||
|
||||
updateConversationLastActivity(
|
||||
|
||||
@@ -307,34 +307,21 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](
|
||||
_state,
|
||||
{ conversationId, callStatus }
|
||||
{ conversationId, callStatus, callSid }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.additional_attributes = {
|
||||
...chat.additional_attributes,
|
||||
call_status: callStatus,
|
||||
};
|
||||
},
|
||||
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
const lastCall = (chat.messages || []).findLast(
|
||||
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
||||
const message = (chat.messages || []).find(
|
||||
m =>
|
||||
m.content_type === CONTENT_TYPES.VOICE_CALL &&
|
||||
m.call?.provider_call_id === callSid
|
||||
);
|
||||
if (!message?.call) return;
|
||||
|
||||
if (!lastCall) return;
|
||||
|
||||
lastCall.content_attributes ??= {};
|
||||
lastCall.content_attributes.data = {
|
||||
...lastCall.content_attributes.data,
|
||||
status: callStatus,
|
||||
};
|
||||
message.call = { ...message.call, status: callStatus };
|
||||
},
|
||||
|
||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||
|
||||
@@ -2,55 +2,18 @@ import { mutations } from '../index';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates call_status preserving existing additional_attributes', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
other_attr: 'value',
|
||||
call_status: 'in-progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates additional_attributes if it does not exist', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
call_status: 'completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing if no voice call message exists', () => {
|
||||
it('does nothing if no matching voice call message exists', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||
@@ -59,6 +22,7 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations[0].messages[0]).toEqual({
|
||||
id: 1,
|
||||
@@ -66,7 +30,7 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the last voice call message status', () => {
|
||||
it('updates only the voice call message matching the given callSid', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
@@ -75,12 +39,12 @@ describe('#mutations', () => {
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
call: { provider_call_id: 'CA111', status: 'ringing' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
call: { provider_call_id: 'CA222', status: 'ringing' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,34 +53,15 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
callSid: 'CA111',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('ringing');
|
||||
expect(
|
||||
state.allConversations[0].messages[1].content_attributes.data.status
|
||||
).toBe('in-progress');
|
||||
expect(state.allConversations[0].messages[0].call.status).toBe(
|
||||
'in-progress'
|
||||
);
|
||||
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
|
||||
});
|
||||
|
||||
it('creates content_attributes.data if it does not exist', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [{ id: 1, content_type: 'voice_call' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('completed');
|
||||
});
|
||||
|
||||
it('preserves existing data in content_attributes.data', () => {
|
||||
it('preserves existing call fields when updating status', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
@@ -125,8 +70,11 @@ describe('#mutations', () => {
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
data: { call_sid: 'CA123', status: 'ringing' },
|
||||
call: {
|
||||
provider_call_id: 'CA123',
|
||||
status: 'ringing',
|
||||
direction: 'incoming',
|
||||
duration_seconds: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -136,12 +84,13 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data
|
||||
).toEqual({
|
||||
call_sid: 'CA123',
|
||||
expect(state.allConversations[0].messages[0].call).toEqual({
|
||||
provider_call_id: 'CA123',
|
||||
status: 'in-progress',
|
||||
direction: 'incoming',
|
||||
duration_seconds: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,8 +101,34 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations[0].messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing if matching message has no call object yet', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
call: { provider_call_id: 'CA-OTHER' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
callSid: 'CA-MISSING',
|
||||
});
|
||||
expect(state.allConversations[0].messages[0].call).toEqual({
|
||||
provider_call_id: 'CA-OTHER',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,6 @@ export default {
|
||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
|
||||
|
||||
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
|
||||
dismissCall(callSid) {
|
||||
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||
},
|
||||
|
||||
removeCallsForConversation(conversationId) {
|
||||
const callsToRemove = this.calls.filter(
|
||||
call => call.conversationId === conversationId
|
||||
);
|
||||
|
||||
if (callsToRemove.some(call => call.isActive)) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
|
||||
this.calls = this.calls.filter(
|
||||
call => call.conversationId !== conversationId
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
const state = {
|
||||
@@ -49,6 +50,7 @@ const state = {
|
||||
termsURL,
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
activePlatformBanners: activePlatformBanners || [],
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: platform_banners
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active :boolean default(TRUE)
|
||||
# banner_message :text not null
|
||||
# banner_type :integer default("info"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class PlatformBanner < ApplicationRecord
|
||||
enum :banner_type, { info: 0, warning: 1, error: 2 }
|
||||
|
||||
validates :banner_message, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
end
|
||||
@@ -50,6 +50,8 @@ class Messages::MentionService
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
next if self_mention?(user_id)
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: User.find(user_id),
|
||||
@@ -60,6 +62,10 @@ class Messages::MentionService
|
||||
end
|
||||
end
|
||||
|
||||
def self_mention?(user_id)
|
||||
message.sender_type == 'User' && user_id.to_i == message.sender_id
|
||||
end
|
||||
|
||||
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
|
||||
|
||||
@@ -12,3 +12,5 @@ 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?
|
||||
|
||||
json.set! :call, message.call.push_event_data if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-megaphone-line" viewBox="0 0 20 20">
|
||||
<path d="M15.8333 3.33334C16.0543 3.33334 16.2663 3.42114 16.4226 3.57742C16.5789 3.7337 16.6667 3.94566 16.6667 4.16668V15.8333C16.6667 16.0544 16.5789 16.2663 16.4226 16.4226C16.2663 16.5789 16.0543 16.6667 15.8333 16.6667C14.3967 16.6667 12.9408 16.2917 11.6575 15.5267C10.5833 14.8842 9.6675 14.0317 8.9575 13.0217L8.33333 14.8933C8.26541 15.0989 8.12416 15.2722 7.93666 15.3803C7.74917 15.4884 7.52804 15.5239 7.31583 15.48L5.67083 15.0392C5.45946 14.9842 5.27514 14.8558 5.15098 14.677C5.02683 14.4982 4.97074 14.2812 4.99249 14.065L5.5375 10.8333H4.16667C3.94565 10.8333 3.72369 10.7455 3.56741 10.5893C3.41113 10.433 3.32333 10.221 3.32333 10V7.50001C3.32333 7.27899 3.41113 7.06703 3.56741 6.91075C3.72369 6.75447 3.94565 6.66668 4.16667 6.66668H8.0625C9.43167 4.56834 12.2483 3.33334 15.8333 3.33334ZM7.60667 8.33334H5V9.16668H7.29167L7.60667 8.33334ZM15 5.07501C12.4167 5.24168 10.3483 6.23168 9.2675 7.82168L7.97833 10.8975L6.63417 14.0508L7.12 14.1817L7.78917 12.1817L8.335 11.3683C8.335 11.3683 8.37167 11.4092 8.42667 11.4783C9.265 12.5483 10.3342 13.4267 11.565 14.0617C12.5833 14.6275 13.7608 14.935 15 14.99V5.07501Z" fill="currentColor"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
@@ -16,6 +16,7 @@ as defined by the routes in the `admin/` namespace
|
||||
users: 'icon-user-follow-line',
|
||||
platform_apps: 'icon-apps-2-line',
|
||||
agent_bots: 'icon-robot-line',
|
||||
platform_banners: 'icon-megaphone-line',
|
||||
}
|
||||
%>
|
||||
|
||||
@@ -33,6 +34,7 @@ as defined by the routes in the `admin/` namespace
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
|
||||
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
|
||||
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
|
||||
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
|
||||
<%= render partial: "nav_item", locals: {
|
||||
icon: sidebar_icons[resource.resource.to_sym],
|
||||
url: resource_index_route(resource),
|
||||
|
||||
@@ -64,6 +64,8 @@ en:
|
||||
email_already_exists: 'You have already signed up for an account with %{email}'
|
||||
invalid_params: 'Invalid, please check the signup paramters and try again'
|
||||
failed: Signup failed
|
||||
voice:
|
||||
call_already_accepted: '%{agent_name} is already handling the call.'
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
attachments:
|
||||
|
||||
@@ -653,6 +653,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member, action: :destroy_avatar
|
||||
end
|
||||
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
||||
resources :platform_banners
|
||||
resource :instance_status, only: [:show]
|
||||
|
||||
resource :settings, only: [:show] do
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class CreatePlatformBanners < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :platform_banners do |t|
|
||||
t.text :banner_message, null: false
|
||||
t.integer :banner_type, null: false, default: 0
|
||||
t.boolean :active, default: true, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1087,6 +1087,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "platform_banners", force: :cascade do |t|
|
||||
t.text "banner_message", null: false
|
||||
t.integer "banner_type", default: 0, null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "portals", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox_for_conference
|
||||
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
|
||||
|
||||
def token
|
||||
render json: Voice::Provider::Twilio::TokenService.new(
|
||||
@@ -10,36 +11,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
call = resolve_call!
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
conference_service.mark_agent_joined(user: current_user)
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
id: conversation.display_id,
|
||||
id: call.conversation.display_id,
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true
|
||||
}
|
||||
end
|
||||
|
||||
def destroy
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
||||
render json: { status: 'success', id: conversation.display_id }
|
||||
call = resolve_call!
|
||||
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
|
||||
render json: { status: 'success', id: call.conversation.display_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
def resolve_call!
|
||||
sid = params[:call_sid].presence
|
||||
raise ActionController::ParameterMissing, :call_sid if sid.blank?
|
||||
|
||||
incoming_sid = params.require(:call_sid)
|
||||
|
||||
conversation.update!(identifier: incoming_sid)
|
||||
incoming_sid
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
|
||||
.find_by!(provider_call_id: sid)
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
@@ -55,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
authorize conversation, :show?
|
||||
conversation
|
||||
end
|
||||
|
||||
def render_call_already_accepted(error)
|
||||
render json: { error: error.message }, status: :conflict
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
authorize contact, :show?
|
||||
authorize voice_inbox, :show?
|
||||
|
||||
result = Voice::OutboundCallBuilder.perform!(
|
||||
call = Voice::OutboundCallBuilder.perform!(
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
conversation = result[:conversation]
|
||||
|
||||
render json: {
|
||||
conversation_id: conversation.display_id,
|
||||
conversation_id: call.conversation.display_id,
|
||||
inbox_id: voice_inbox.id,
|
||||
call_sid: result[:call_sid],
|
||||
conference_sid: conversation.additional_attributes['conference_sid']
|
||||
call_sid: call.provider_call_id,
|
||||
conference_sid: call.conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
account = current_account
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
conversation = resolve_conversation
|
||||
conference_sid = ensure_conference_sid!(conversation)
|
||||
|
||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||
call = resolve_call
|
||||
render xml: conference_twiml(call)
|
||||
end
|
||||
|
||||
def conference_status
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
if event.nil?
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
|
||||
)
|
||||
return head :no_content
|
||||
end
|
||||
|
||||
conversation = find_conversation_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
call: call,
|
||||
event: event,
|
||||
call_sid: twilio_call_sid,
|
||||
participant_label: participant_label
|
||||
).process
|
||||
|
||||
@@ -80,8 +78,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
def resolve_conversation
|
||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||
def resolve_call
|
||||
return find_call_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
@@ -92,79 +90,72 @@ class Twilio::VoiceController < ApplicationController
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
when 'outbound-api', 'outbound-dial'
|
||||
sync_outbound_leg(
|
||||
call_sid: twilio_call_sid,
|
||||
from_number: twilio_from,
|
||||
direction: twilio_direction
|
||||
)
|
||||
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
|
||||
else
|
||||
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_for_agent
|
||||
if params[:conversation_id].present?
|
||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
else
|
||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||
end
|
||||
def find_call_for_agent
|
||||
sid = params[:call_sid].presence
|
||||
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
|
||||
|
||||
inbox_calls.find_by!(provider_call_id: sid)
|
||||
end
|
||||
|
||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||
def sync_outbound_leg(call_sid:, direction:)
|
||||
parent_sid = params['ParentCallSid'].presence
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: twilio_to,
|
||||
direction: 'outbound'
|
||||
}
|
||||
).perform
|
||||
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
|
||||
call
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(conversation)
|
||||
attrs = conversation.additional_attributes || {}
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
attrs['conference_sid']
|
||||
def inbox_calls
|
||||
Call.where(inbox_id: inbox.id, provider: :twilio)
|
||||
end
|
||||
|
||||
def conference_twiml(conference_sid, agent_leg)
|
||||
def conference_twiml(call)
|
||||
conference_sid = ensure_conference_sid!(call)
|
||||
|
||||
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg,
|
||||
start_conference_on_enter: agent_leg?(twilio_from),
|
||||
end_conference_on_exit: false,
|
||||
status_callback: conference_status_callback_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST',
|
||||
participant_label: agent_leg ? 'agent' : 'contact'
|
||||
participant_label: participant_label_for(twilio_from)
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(call)
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
def participant_label_for(from_number)
|
||||
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
|
||||
|
||||
'contact'
|
||||
end
|
||||
|
||||
def conference_status_callback_url
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
def find_call_for_conference!(friendly_name, call_sid)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
|
||||
if name.present?
|
||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
scope.find_by!(identifier: call_sid)
|
||||
call = inbox_calls.by_conference_sid(name).first if name.present?
|
||||
call || inbox_calls.find_by!(provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::MessageFinder
|
||||
def conversation_messages
|
||||
super.includes(call: [:contact, { inbox: :channel }])
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,8 @@ class Call < ApplicationRecord
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -41,7 +43,7 @@ class Call < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :contact
|
||||
belongs_to :message, optional: true
|
||||
belongs_to :message, optional: true, inverse_of: :call
|
||||
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||
|
||||
has_one_attached :recording
|
||||
@@ -52,4 +54,51 @@ class Call < ApplicationRecord
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
|
||||
def self.find_by_provider_call_id(provider, sid)
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
end
|
||||
|
||||
def default_conference_sid
|
||||
"conf_account_#{account_id}_call_#{id}"
|
||||
end
|
||||
|
||||
def display_status
|
||||
status.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
def from_number
|
||||
incoming? ? contact.phone_number : inbox.channel&.phone_number
|
||||
end
|
||||
|
||||
def to_number
|
||||
incoming? ? inbox.channel&.phone_number : contact.phone_number
|
||||
end
|
||||
|
||||
def recording_url
|
||||
return nil unless recording.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_url(recording)
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
provider_call_id: provider_call_id,
|
||||
provider: provider,
|
||||
direction: direction,
|
||||
status: display_status,
|
||||
duration_seconds: duration_seconds,
|
||||
conference_sid: conference_sid,
|
||||
accepted_by_agent_id: accepted_by_agent_id,
|
||||
accepted_by_agent_name: accepted_by_agent&.available_name,
|
||||
started_at: started_at&.to_i,
|
||||
ended_at: ended_at,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
recording_url: recording_url,
|
||||
transcript: transcript
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,17 +25,6 @@ module Enterprise::Conversation
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
module Enterprise::Message
|
||||
def self.prepended(base)
|
||||
base.class_eval do
|
||||
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
|
||||
|
||||
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
|
||||
end
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
data = super
|
||||
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
|
||||
@@ -1,90 +1,26 @@
|
||||
class Voice::CallMessageBuilder
|
||||
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
||||
new(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: payload,
|
||||
user: user,
|
||||
timestamps: timestamps
|
||||
).perform!
|
||||
end
|
||||
|
||||
def initialize(conversation:, direction:, payload:, user:, timestamps:)
|
||||
@conversation = conversation
|
||||
@direction = direction
|
||||
@payload = payload
|
||||
@user = user
|
||||
@timestamps = timestamps
|
||||
def initialize(call)
|
||||
@call = call
|
||||
end
|
||||
|
||||
def perform!
|
||||
validate_sender!
|
||||
message = latest_message
|
||||
message ? update_message!(message) : create_message!
|
||||
call.message || create_message!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||
|
||||
def latest_message
|
||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
end
|
||||
|
||||
def update_message!(message)
|
||||
message.update!(
|
||||
message_type: message_type,
|
||||
content_attributes: { 'data' => base_payload },
|
||||
sender: sender
|
||||
)
|
||||
end
|
||||
attr_reader :call
|
||||
|
||||
def create_message!
|
||||
params = {
|
||||
content: 'Voice Call',
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => base_payload }
|
||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||
content_type: 'voice_call'
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||
end
|
||||
|
||||
def base_payload
|
||||
@base_payload ||= begin
|
||||
data = payload.slice(
|
||||
:call_sid,
|
||||
:status,
|
||||
:call_direction,
|
||||
:conference_sid,
|
||||
:from_number,
|
||||
:to_number
|
||||
).stringify_keys
|
||||
data['call_direction'] = direction
|
||||
data['meta'] = {
|
||||
'created_at' => timestamps[:created_at] || current_timestamp,
|
||||
'ringing_at' => timestamps[:ringing_at] || current_timestamp
|
||||
}.compact
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if direction == 'outbound'
|
||||
|
||||
conversation.contact
|
||||
end
|
||||
|
||||
def validate_sender!
|
||||
return unless direction == 'outbound'
|
||||
|
||||
raise ArgumentError, 'Agent sender required for outbound calls' unless user
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= Time.zone.now.to_i
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
class Voice::CallSessionSyncService
|
||||
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||
|
||||
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||
@conversation = conversation
|
||||
@call_sid = call_sid
|
||||
@message_call_sid = message_call_sid || call_sid
|
||||
@from_number = leg[:from_number]
|
||||
@to_number = leg[:to_number]
|
||||
@direction = leg[:direction]
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
attrs = refreshed_attributes
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
sync_voice_call_message!(attrs)
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refreshed_attributes
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_direction'] = direction
|
||||
attrs['call_status'] ||= 'ringing'
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
attrs['meta'] ||= {}
|
||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||
attrs
|
||||
end
|
||||
|
||||
def sync_voice_call_message!(attrs)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: {
|
||||
call_sid: message_call_sid,
|
||||
status: attrs['call_status'],
|
||||
conference_sid: attrs['conference_sid'],
|
||||
from_number: origin_number_for(direction),
|
||||
to_number: target_number_for(direction)
|
||||
},
|
||||
user: agent_for(attrs),
|
||||
timestamps: {
|
||||
created_at: attrs.dig('meta', 'initiated_at'),
|
||||
ringing_at: attrs.dig('meta', 'ringing_at')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def origin_number_for(current_direction)
|
||||
return outbound_origin if current_direction == 'outbound'
|
||||
|
||||
from_number.presence || inbox_number
|
||||
end
|
||||
|
||||
def target_number_for(current_direction)
|
||||
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
|
||||
|
||||
to_number || conversation.contact&.phone_number
|
||||
end
|
||||
|
||||
def agent_for(attrs)
|
||||
agent_id = attrs['agent_id']
|
||||
return nil unless agent_id
|
||||
|
||||
agent = conversation.account.users.find_by(id: agent_id)
|
||||
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
|
||||
|
||||
agent
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
|
||||
def outbound_origin
|
||||
inbox_number || from_number
|
||||
end
|
||||
|
||||
def inbox_number
|
||||
conversation.inbox&.channel&.phone_number
|
||||
end
|
||||
end
|
||||
@@ -1,66 +1,44 @@
|
||||
class Voice::CallStatus::Manager
|
||||
pattr_initialize [:conversation!, :call_sid]
|
||||
|
||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||
pattr_initialize [:call!]
|
||||
|
||||
def process_status_update(status, duration: nil, timestamp: nil)
|
||||
return unless ALLOWED_STATUSES.include?(status)
|
||||
return unless Call::STATUSES.include?(status)
|
||||
return if call.status == status
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
update_message(status)
|
||||
apply_call_updates!(status, duration: duration, timestamp: timestamp)
|
||||
call.conversation.update!(last_activity_at: Time.zone.now)
|
||||
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
|
||||
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_status(status, duration:, timestamp:)
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_status'] = status
|
||||
def apply_call_updates!(status, duration:, timestamp:)
|
||||
attrs = { status: status }
|
||||
ts = timestamp || now_seconds
|
||||
|
||||
if status == 'in-progress'
|
||||
attrs['call_started_at'] ||= timestamp || now_seconds
|
||||
elsif TERMINAL_STATUSES.include?(status)
|
||||
attrs['call_ended_at'] = timestamp || now_seconds
|
||||
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
||||
if status == 'in_progress'
|
||||
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
|
||||
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
|
||||
started_at = Time.zone.at(ts)
|
||||
attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
|
||||
elsif Call::TERMINAL_STATUSES.include?(status)
|
||||
call.ended_at = ts
|
||||
attrs[:meta] = call.meta
|
||||
attrs[:duration_seconds] = resolved_duration(duration, ts)
|
||||
end
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
call.update!(attrs)
|
||||
end
|
||||
|
||||
def resolved_duration(attrs, provided_duration, timestamp)
|
||||
def resolved_duration(provided_duration, timestamp)
|
||||
return provided_duration if provided_duration
|
||||
return unless call.started_at
|
||||
|
||||
started_at = attrs['call_started_at']
|
||||
return unless started_at && timestamp
|
||||
|
||||
[timestamp - started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = status
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
[timestamp - call.started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +1,93 @@
|
||||
class Voice::Conference::Manager
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||
pattr_initialize [:call!, :event!, :participant_label]
|
||||
|
||||
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
|
||||
|
||||
def process
|
||||
case event
|
||||
when 'start'
|
||||
ensure_conference_sid!
|
||||
mark_ringing!
|
||||
when 'join'
|
||||
mark_in_progress! if agent_participant?
|
||||
join_agent! if agent_participant?
|
||||
when 'leave'
|
||||
handle_leave!
|
||||
when 'end'
|
||||
finalize_conference!
|
||||
finalize!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_manager
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_conference_sid!
|
||||
attrs = conversation.additional_attributes || {}
|
||||
return if attrs['conference_sid'].present?
|
||||
|
||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||
end
|
||||
|
||||
def mark_ringing!
|
||||
return if current_status
|
||||
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
|
||||
return unless call.status == 'ringing'
|
||||
|
||||
status_manager.process_status_update('ringing')
|
||||
end
|
||||
|
||||
def mark_in_progress!
|
||||
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
||||
def join_agent!
|
||||
user_id = extract_user_id
|
||||
claim_for_user!(user_id) if user_id
|
||||
status_manager.process_status_update('in_progress', timestamp: now)
|
||||
end
|
||||
|
||||
# First-join wins; later joins by other agents are silently ignored so the
|
||||
# webhook doesn't stomp the original assignee. User-facing rejection happens
|
||||
# at the API layer.
|
||||
def claim_for_user!(user_id)
|
||||
claimed = false
|
||||
call.with_lock do
|
||||
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
|
||||
|
||||
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
|
||||
claimed = true
|
||||
end
|
||||
|
||||
auto_assign_conversation!(user_id) if claimed
|
||||
end
|
||||
|
||||
def auto_assign_conversation!(user_id)
|
||||
conversation = call.conversation
|
||||
return if conversation.assignee_id.present?
|
||||
|
||||
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
|
||||
end
|
||||
|
||||
# Parses agent user_id from participant_label. Only returns an id when the
|
||||
# label's embedded account id matches the call's account — protects against
|
||||
# a spoofed/cross-account label attaching a foreign user to the call.
|
||||
def extract_user_id
|
||||
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
|
||||
return unless match
|
||||
return unless match[2].to_i == call.account_id
|
||||
|
||||
match[1].to_i
|
||||
end
|
||||
|
||||
def handle_leave!
|
||||
case current_status
|
||||
case call.status
|
||||
when 'ringing'
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||
when 'in-progress'
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
status_manager.process_status_update('no_answer', timestamp: now)
|
||||
when 'in_progress'
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_conference!
|
||||
return if %w[completed no-answer failed].include?(current_status)
|
||||
def finalize!
|
||||
return if Call::TERMINAL_STATUSES.include?(call.status)
|
||||
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
end
|
||||
|
||||
def current_status
|
||||
conversation.additional_attributes&.dig('call_status')
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
|
||||
def agent_participant?
|
||||
participant_label.to_s.start_with?('agent')
|
||||
participant_label.to_s.start_with?('agent-')
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
def now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
end
|
||||
@@ -13,21 +13,30 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
|
||||
def perform!
|
||||
timestamp = current_timestamp
|
||||
existing = find_existing_call
|
||||
return existing if existing
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||
conversation.reload
|
||||
update_conversation!(conversation, timestamp)
|
||||
build_voice_message!(conversation, timestamp)
|
||||
conversation
|
||||
conversation = resolve_conversation!(contact, contact_inbox)
|
||||
call = create_call!(contact, conversation)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# A concurrent Twilio retry won the create race; return what now exists.
|
||||
find_existing_call || raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_existing_call
|
||||
Call.where(account_id: account.id, inbox_id: inbox.id)
|
||||
.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = from_number if record.name.blank?
|
||||
@@ -43,57 +52,37 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation
|
||||
return if call_sid.blank?
|
||||
def resolve_conversation!(contact, contact_inbox)
|
||||
if inbox.lock_to_single_conversation
|
||||
reusable = account.conversations
|
||||
.where(contact_id: contact.id, inbox_id: inbox.id)
|
||||
.where.not(status: :resolved)
|
||||
.order(last_activity_at: :desc)
|
||||
.first
|
||||
return reusable if reusable
|
||||
end
|
||||
|
||||
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||
end
|
||||
|
||||
def create_conversation!(contact, contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
identifier: call_sid
|
||||
status: :open
|
||||
)
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(contact, conversation)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'inbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||
from_number: from_number,
|
||||
to_number: inbox.channel&.phone_number
|
||||
},
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
|
||||
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||
raise ArgumentError, 'Agent required' if user.blank?
|
||||
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation.reload
|
||||
conference_sid = Voice::Conference::Name.for(conversation)
|
||||
call_sid = initiate_call!
|
||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
{ conversation: conversation, call_sid: call_sid }
|
||||
call = create_call!(conversation, call_sid)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
|
||||
end
|
||||
|
||||
def initiate_call!
|
||||
inbox.channel.initiate_call(
|
||||
to: contact.phone_number
|
||||
)[:call_sid]
|
||||
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => conference_sid,
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(conversation, call_sid)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'outbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conference_sid,
|
||||
from_number: inbox.channel&.phone_number,
|
||||
to_number: contact.phone_number
|
||||
},
|
||||
user: user,
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
accepted_by_agent: user,
|
||||
provider: :twilio,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!]
|
||||
pattr_initialize [:call!]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
return existing if existing.present?
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
sid = Voice::Conference::Name.for(conversation)
|
||||
merge_attributes('conference_sid' => sid)
|
||||
sid
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
merge_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
)
|
||||
claim_call!(user)
|
||||
assign_conversation!(user)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
client = conversation.inbox.channel.client
|
||||
return if call.conference_sid.blank?
|
||||
|
||||
client = call.inbox.channel.client
|
||||
client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.list(friendly_name: call.conference_sid, status: 'in-progress')
|
||||
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_attributes(attrs)
|
||||
current = conversation.additional_attributes || {}
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
def claim_call!(user)
|
||||
call.with_lock do
|
||||
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
|
||||
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
|
||||
end
|
||||
end
|
||||
|
||||
def claimed_by_other_agent?(user)
|
||||
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
|
||||
end
|
||||
|
||||
def raise_already_accepted!(agent)
|
||||
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
|
||||
end
|
||||
|
||||
# Existing assignments win — manual reassignment and pre-call assignment
|
||||
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
|
||||
def assign_conversation!(user)
|
||||
conversation = call.conversation
|
||||
return if conversation.assignee_id.present?
|
||||
|
||||
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
|
||||
'queued' => 'ringing',
|
||||
'initiated' => 'ringing',
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in-progress',
|
||||
'inprogress' => 'in-progress',
|
||||
'answered' => 'in-progress',
|
||||
'in-progress' => 'in_progress',
|
||||
'inprogress' => 'in_progress',
|
||||
'answered' => 'in_progress',
|
||||
'completed' => 'completed',
|
||||
'busy' => 'no-answer',
|
||||
'no-answer' => 'no-answer',
|
||||
'busy' => 'no_answer',
|
||||
'no-answer' => 'no_answer',
|
||||
'failed' => 'failed',
|
||||
'canceled' => 'failed'
|
||||
}.freeze
|
||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
||||
normalized_status = normalize_status(call_status)
|
||||
return if normalized_status.blank?
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
return unless call
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
).process_status_update(
|
||||
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||
normalized_status,
|
||||
duration: payload_duration,
|
||||
timestamp: payload_timestamp
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
|
||||
end
|
||||
|
||||
def http_status
|
||||
409
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Slack::EmojiFormatter
|
||||
def self.format(text)
|
||||
return text if text.blank?
|
||||
|
||||
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
|
||||
short_code = Regexp.last_match(1)
|
||||
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
|
||||
# rubocop:disable Rails/DynamicFindBy
|
||||
emoji = Emoji.find_by_alias(short_code)
|
||||
# rubocop:enable Rails/DynamicFindBy
|
||||
emoji ? emoji.raw : match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -35,7 +35,7 @@ module Integrations::Slack::SlackMessageHelper
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
|
||||
content: formatted_message_content,
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: resolved_sender,
|
||||
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
|
||||
[nil, nil, nil]
|
||||
end
|
||||
|
||||
def formatted_message_content
|
||||
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
|
||||
Integrations::Slack::EmojiFormatter.format(text)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:voice_inbox) { voice_channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
before do
|
||||
create(:inbox_member, inbox: voice_inbox, user: agent)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: voice_inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: 'CALL123'
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates conference and sets identifier' do
|
||||
it 'resolves the Call by call_sid and invokes the conference service' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['conference_sid']).to be_present
|
||||
conversation.reload
|
||||
expect(conversation.identifier).to eq('CALL123')
|
||||
expect(body['conference_sid']).to eq('CF123')
|
||||
expect(body['id']).to eq(conversation.display_id)
|
||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).to have_received(:mark_agent_joined)
|
||||
end
|
||||
|
||||
it 'does not allow accessing conversations from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
other_conversation.reload
|
||||
expect(other_conversation.identifier).to be_nil
|
||||
end
|
||||
|
||||
it 'returns conflict when call_sid missing' do
|
||||
it 'rejects the request when call_sid is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(conference_service).not_to have_received(:ensure_conference_sid)
|
||||
end
|
||||
|
||||
it 'does not allow accessing calls from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: other_inbox,
|
||||
conversation: other_conversation,
|
||||
contact: other_conversation.contact,
|
||||
provider_call_id: 'OTHER123'
|
||||
)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
before do
|
||||
create(:inbox_member, inbox: voice_inbox, user: agent)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: voice_inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: 'CALL123'
|
||||
)
|
||||
end
|
||||
|
||||
it 'ends conference and returns success' do
|
||||
it 'ends the conference for the resolved call' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
expect(conference_service).to have_received(:end_conference)
|
||||
end
|
||||
|
||||
it 'does not allow ending conferences for conversations from inboxes without access' do
|
||||
it 'does not allow ending conferences for calls from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: other_inbox,
|
||||
conversation: other_conversation,
|
||||
contact: other_conversation.contact,
|
||||
provider_call_id: 'OTHER123'
|
||||
)
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id }
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
@@ -19,15 +19,24 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||
instance_double(Voice::InboundCallBuilder)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
contact = conversation.contact
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
).and_return(conversation)
|
||||
).and_return(call)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
@@ -39,64 +48,59 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include('<Dial>')
|
||||
expect(response.body).to include(call.conference_sid)
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
direction: :outgoing,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'From' => to_number,
|
||||
'To' => from_number,
|
||||
'Direction' => 'outbound-api'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include(call.conference_sid)
|
||||
expect(call.reload.parent_call_sid).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||
it 'records the parent call SID when syncing outbound-dial legs' do
|
||||
parent_sid = 'CA_parent'
|
||||
child_sid = 'CA_child'
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: child_sid,
|
||||
message_call_sid: parent_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
direction: :outgoing,
|
||||
provider_call_id: parent_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => child_sid,
|
||||
'ParentCallSid' => parent_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'From' => to_number,
|
||||
'To' => from_number,
|
||||
'Direction' => 'outbound-dial'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.parent_call_sid).to eq(parent_sid)
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
|
||||
@@ -62,17 +62,17 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
end
|
||||
|
||||
it 'marks the due document as syncing before queueing' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
|
||||
@@ -7,7 +7,6 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550001111' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
@@ -24,98 +23,88 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when no existing conversation matches call_sid' do
|
||||
it 'creates a new inbound conversation with ringing status' do
|
||||
conversation = nil
|
||||
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
||||
context 'when no existing call matches call_sid' do
|
||||
it 'creates a new conversation and Call with ringing status' do
|
||||
call = nil
|
||||
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
|
||||
|
||||
attrs = conversation.additional_attributes
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs['call_direction']).to eq('inbound')
|
||||
expect(attrs['call_status']).to eq('ringing')
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
||||
expect(conversation.contact.phone_number).to eq(from_number)
|
||||
aggregate_failures do
|
||||
expect(call).to be_a(Call)
|
||||
expect(call.provider_call_id).to eq(call_sid)
|
||||
expect(call.provider).to eq('twilio')
|
||||
expect(call.direction).to eq('incoming')
|
||||
expect(call.status).to eq('ringing')
|
||||
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
expect(call.meta['initiated_at']).to be_present
|
||||
expect(call.contact.phone_number).to eq(from_number)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a single voice_call message marked as incoming' do
|
||||
conversation = perform_builder
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
it 'creates a voice_call message matched to the call and linked via message_id' do
|
||||
call = perform_builder
|
||||
voice_message = call.conversation.messages.voice_calls.last
|
||||
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
data = voice_message.content_attributes['data']
|
||||
expect(data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'status' => 'ringing',
|
||||
'call_direction' => 'inbound',
|
||||
'conference_sid' => conversation.additional_attributes['conference_sid'],
|
||||
'from_number' => from_number,
|
||||
'to_number' => inbox.channel.phone_number
|
||||
)
|
||||
expect(data['meta']['created_at']).to be_present
|
||||
expect(data['meta']['ringing_at']).to be_present
|
||||
aggregate_failures do
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
expect(call.message_id).to eq(voice_message.id)
|
||||
expect(voice_message.call).to eq(call)
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets the contact name to the phone number for new callers' do
|
||||
conversation = perform_builder
|
||||
call = perform_builder
|
||||
|
||||
expect(conversation.contact.name).to eq(from_number)
|
||||
expect(call.contact.name).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
it 'does not set conversation.identifier or write call state to additional_attributes' do
|
||||
call = perform_builder
|
||||
conversation = call.conversation
|
||||
|
||||
perform_builder
|
||||
expect(conversation.identifier).to be_nil
|
||||
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a conversation already exists for the call_sid' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
context 'when a Call already exists for the call_sid' do
|
||||
let(:existing_call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the existing call without creating a duplicate' do
|
||||
existing_call
|
||||
expect { perform_builder }.not_to change(Call, :count)
|
||||
expect(perform_builder).to eq(existing_call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox has lock_to_single_conversation enabled' do
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let!(:existing_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
|
||||
)
|
||||
end
|
||||
let(:existing_message) do
|
||||
create(
|
||||
:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: existing_conversation,
|
||||
message_type: :incoming,
|
||||
content_type: :voice_call,
|
||||
sender: contact,
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
|
||||
)
|
||||
let!(:existing_open_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
|
||||
end
|
||||
|
||||
it 'reuses the conversation without creating a duplicate' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to change(account.conversations, :count)
|
||||
existing_conversation.reload
|
||||
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
before { inbox.update!(lock_to_single_conversation: true) }
|
||||
|
||||
it 'reuses the most recent non-resolved conversation' do
|
||||
call = nil
|
||||
expect { call = perform_builder }.not_to change(account.conversations, :count)
|
||||
expect(call.conversation).to eq(existing_open_conversation)
|
||||
end
|
||||
|
||||
it 'updates the existing voice call message instead of creating a new one' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
||||
updated_message = existing_conversation.reload.messages.voice_calls.last
|
||||
|
||||
data = updated_message.content_attributes['data']
|
||||
expect(data['status']).to eq('ringing')
|
||||
expect(data['call_direction']).to eq('inbound')
|
||||
it 'still creates a new Call and voice_call message on the reused conversation' do
|
||||
expect { perform_builder }.to change(Call, :count).by(1)
|
||||
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
||||
end
|
||||
|
||||
describe '.perform!' do
|
||||
it 'creates a conversation and voice call message' do
|
||||
conversation_count = account.conversations.count
|
||||
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
||||
it 'creates a conversation, Call, and voice_call message' do
|
||||
call = nil
|
||||
expect do
|
||||
call = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
|
||||
|
||||
result = described_class.perform!(
|
||||
aggregate_failures do
|
||||
expect(call).to be_a(Call)
|
||||
expect(call.provider_call_id).to eq(call_sid)
|
||||
expect(call.direction).to eq('outgoing')
|
||||
expect(call.status).to eq('ringing')
|
||||
expect(call.accepted_by_agent_id).to eq(user.id)
|
||||
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
|
||||
voice_message = call.conversation.messages.voice_calls.last
|
||||
expect(call.message_id).to eq(voice_message.id)
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
expect(voice_message.call).to eq(call)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not set conversation.identifier or write call state to additional_attributes' do
|
||||
call = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
expect(account.conversations.count).to eq(conversation_count + 1)
|
||||
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
||||
|
||||
conversation = result[:conversation].reload
|
||||
attrs = conversation.additional_attributes
|
||||
|
||||
aggregate_failures do
|
||||
expect(result[:call_sid]).to eq(call_sid)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
|
||||
expect(attrs['agent_id']).to eq(user.id)
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
|
||||
message_data = voice_message.content_attributes['data']
|
||||
expect(message_data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'conference_sid' => attrs['conference_sid'],
|
||||
'from_number' => channel.phone_number,
|
||||
'to_number' => contact.phone_number
|
||||
)
|
||||
end
|
||||
expect(call.conversation.identifier).to be_nil
|
||||
expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
|
||||
end
|
||||
|
||||
it 'raises an error when contact is missing a phone number' do
|
||||
@@ -79,19 +79,5 @@ RSpec.describe Voice::OutboundCallBuilder do
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Agent required')
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,17 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
||||
let(:call) do
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: channel.inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact
|
||||
)
|
||||
end
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
let(:service) { described_class.new(call: call) }
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
|
||||
before do
|
||||
@@ -14,42 +23,37 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
end
|
||||
|
||||
describe '#ensure_conference_sid' do
|
||||
it 'returns existing sid if present' do
|
||||
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
|
||||
it 'returns existing sid if present on the Call' do
|
||||
call.update!(conference_sid: 'CF_EXISTING')
|
||||
|
||||
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
|
||||
end
|
||||
|
||||
it 'sets and returns generated sid when missing' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
|
||||
|
||||
sid = service.ensure_conference_sid
|
||||
|
||||
expect(sid).to eq('CF_GEN')
|
||||
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
|
||||
expect(service.ensure_conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
expect(call.reload.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe '#mark_agent_joined' do
|
||||
it 'stores agent join metadata' do
|
||||
it 'sets accepted_by_agent on the Call' do
|
||||
agent = create(:user, account: account)
|
||||
|
||||
service.mark_agent_joined(user: agent)
|
||||
|
||||
attrs = conversation.reload.additional_attributes
|
||||
expect(attrs['agent_joined']).to be true
|
||||
expect(attrs['joined_by']['id']).to eq(agent.id)
|
||||
expect(call.reload.accepted_by_agent_id).to eq(agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#end_conference' do
|
||||
it 'completes in-progress conferences' do
|
||||
it 'completes in-progress conferences matching the call conference_sid' do
|
||||
call.update!(conference_sid: 'CF123_FRIENDLY')
|
||||
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
|
||||
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
|
||||
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
|
||||
|
||||
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
|
||||
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
|
||||
allow(conferences_proxy).to receive(:list).with(friendly_name: 'CF123_FRIENDLY', status: 'in-progress').and_return([conf_instance])
|
||||
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
|
||||
allow(conf_context).to receive(:update).with(status: 'completed')
|
||||
|
||||
@@ -57,5 +61,11 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
|
||||
expect(conf_context).to have_received(:update).with(status: 'completed')
|
||||
end
|
||||
|
||||
it 'no-ops when call has no conference_sid' do
|
||||
allow(twilio_client).to receive(:conferences)
|
||||
service.end_conference
|
||||
expect(twilio_client).not_to have_received(:conferences)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,43 +4,47 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::StatusUpdateService do
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550002222' }
|
||||
let(:call_sid) { 'CATESTSTATUS123' }
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let(:conversation) do
|
||||
Conversation.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||
end
|
||||
let!(:call) do
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
end
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
let!(:message) do
|
||||
msg = conversation.messages.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: contact,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'ringing' } }
|
||||
)
|
||||
call.update!(message_id: msg.id)
|
||||
msg
|
||||
end
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550002222' }
|
||||
let(:call_sid) { 'CATESTSTATUS123' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
|
||||
it 'updates conversation and last voice message with call status' do
|
||||
# Ensure records are created after stub setup
|
||||
conversation
|
||||
message
|
||||
it 'updates the Call and touches the linked message on status transition' do
|
||||
previous_updated_at = message.updated_at
|
||||
travel 1.second
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
call_status: 'completed'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
call.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('completed')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||
expect(call.status).to eq('completed')
|
||||
expect(message.updated_at).to be > previous_updated_at
|
||||
end
|
||||
|
||||
it 'normalizes busy to no-answer' do
|
||||
conversation
|
||||
message
|
||||
|
||||
it 'normalizes busy to no_answer on the Call' do
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'busy'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
|
||||
it 'no-ops when conversation not found' do
|
||||
it 'no-ops when no Call matches the provided call_sid' do
|
||||
expect do
|
||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||
end.not_to raise_error
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FactoryBot.define do
|
||||
factory :call do
|
||||
association :conversation
|
||||
account { conversation.account }
|
||||
inbox { conversation.inbox }
|
||||
contact { conversation.contact }
|
||||
provider { :twilio }
|
||||
direction { :incoming }
|
||||
status { 'ringing' }
|
||||
sequence(:provider_call_id) { |n| "CA#{SecureRandom.hex(15)}#{n}" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::EmojiFormatter do
|
||||
describe '.format' do
|
||||
it 'replaces emoji shortcodes with unicode characters' do
|
||||
expect(described_class.format('Hello :smile:')).to eq('Hello 😄')
|
||||
expect(described_class.format('Good job :+1:')).to eq('Good job 👍')
|
||||
expect(described_class.format('Unknown :unknown_emoji:')).to eq('Unknown :unknown_emoji:')
|
||||
end
|
||||
|
||||
it 'handles nil or empty text' do
|
||||
expect(described_class.format(nil)).to be_nil
|
||||
expect(described_class.format('')).to eq('')
|
||||
end
|
||||
|
||||
it 'replaces multiple emojis' do
|
||||
expect(described_class.format(':smile: :+1:')).to eq('😄 👍')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -165,6 +165,36 @@ describe Messages::MentionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the message sender mentions themselves' do
|
||||
it 'skips the sender notification while notifying other mentioned users' do
|
||||
message = build(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://user/#{second_agent.id}/#{second_agent.name})",
|
||||
private: true,
|
||||
sender: first_agent
|
||||
)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(NotificationBuilder).not_to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: first_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
expect(NotificationBuilder).to have_received(:new).with(
|
||||
notification_type: 'conversation_mention',
|
||||
user: second_agent,
|
||||
account: account,
|
||||
primary_actor: message.conversation,
|
||||
secondary_actor: message
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mentioned user is not an inbox member' do
|
||||
let!(:non_member_user) { create(:user, account: account) }
|
||||
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5
-5
@@ -57,8 +57,8 @@ importers:
|
||||
specifier: ^8.59.1
|
||||
version: 8.59.1(eslint@9.39.4)(typescript@6.0.3)
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -645,8 +645,8 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
uuid@14.0.0:
|
||||
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||
hasBin: true
|
||||
|
||||
which@2.0.2:
|
||||
@@ -1234,7 +1234,7 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
uuid@13.0.0: {}
|
||||
uuid@14.0.0: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user