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 'tzinfo-data'
|
||||||
gem 'valid_email2'
|
gem 'valid_email2'
|
||||||
gem 'email-provider-info'
|
gem 'email-provider-info'
|
||||||
|
gem 'gemoji'
|
||||||
# compress javascript config.assets.js_compressor
|
# compress javascript config.assets.js_compressor
|
||||||
gem 'uglifier'
|
gem 'uglifier'
|
||||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
##-- 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)
|
acts-as-taggable-on (12.0.0)
|
||||||
activerecord (>= 7.1, < 8.1)
|
activerecord (>= 7.1, < 8.1)
|
||||||
zeitwerk (>= 2.4, < 3.0)
|
zeitwerk (>= 2.4, < 3.0)
|
||||||
addressable (2.8.7)
|
addressable (2.9.0)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
administrate (0.20.1)
|
administrate (0.20.1)
|
||||||
actionpack (>= 6.0, < 8.0)
|
actionpack (>= 6.0, < 8.0)
|
||||||
actionview (>= 6.0, < 8.0)
|
actionview (>= 6.0, < 8.0)
|
||||||
@@ -349,6 +349,7 @@ GEM
|
|||||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||||
googleauth (~> 1.0)
|
googleauth (~> 1.0)
|
||||||
grpc (~> 1.36)
|
grpc (~> 1.36)
|
||||||
|
gemoji (4.1.0)
|
||||||
geocoder (1.8.1)
|
geocoder (1.8.1)
|
||||||
gli (2.22.2)
|
gli (2.22.2)
|
||||||
ostruct
|
ostruct
|
||||||
@@ -676,7 +677,7 @@ GEM
|
|||||||
method_source (~> 1.0)
|
method_source (~> 1.0)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (6.0.2)
|
public_suffix (7.0.5)
|
||||||
puma (6.4.3)
|
puma (6.4.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.0)
|
pundit (2.3.0)
|
||||||
@@ -1075,6 +1076,7 @@ DEPENDENCIES
|
|||||||
fcm
|
fcm
|
||||||
flag_shih_tzu
|
flag_shih_tzu
|
||||||
foreman
|
foreman
|
||||||
|
gemoji
|
||||||
geocoder
|
geocoder
|
||||||
gmail_xoauth
|
gmail_xoauth
|
||||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||||
|
|||||||
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
|
|||||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||||
GIT_SHA: GIT_HASH,
|
GIT_SHA: GIT_HASH,
|
||||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||||
|
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||||
}
|
}
|
||||||
end
|
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
|
def allowed_login_methods
|
||||||
methods = ['email']
|
methods = ['email']
|
||||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
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
|
messages.reorder('created_at desc').limit(20).reverse
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
MessageFinder.prepend_mod_with('MessageFinder')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
|||||||
import LoadingState from './components/widgets/LoadingState.vue';
|
import LoadingState from './components/widgets/LoadingState.vue';
|
||||||
import NetworkNotification from './components/NetworkNotification.vue';
|
import NetworkNotification from './components/NetworkNotification.vue';
|
||||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||||
|
import StatusBanner from './components/app/StatusBanner.vue';
|
||||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||||
import vueActionCable from './helper/actionCable';
|
import vueActionCable from './helper/actionCable';
|
||||||
@@ -27,6 +28,7 @@ export default {
|
|||||||
LoadingState,
|
LoadingState,
|
||||||
NetworkNotification,
|
NetworkNotification,
|
||||||
UpdateBanner,
|
UpdateBanner,
|
||||||
|
StatusBanner,
|
||||||
PaymentPendingBanner,
|
PaymentPendingBanner,
|
||||||
WootSnackbarBox,
|
WootSnackbarBox,
|
||||||
PendingEmailVerificationBanner,
|
PendingEmailVerificationBanner,
|
||||||
@@ -137,6 +139,7 @@ export default {
|
|||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
|
<StatusBanner />
|
||||||
<template v-if="currentAccountId">
|
<template v-if="currentAccountId">
|
||||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
|
|||||||
this.inboxId = null;
|
this.inboxId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinClientCall({ to, conversationId }) {
|
async joinClientCall({ to, conversationId, callSid }) {
|
||||||
if (!this.device || !this.initialized || !to) return null;
|
if (!this.device || !this.initialized || !to) return null;
|
||||||
if (this.activeConnection) return this.activeConnection;
|
if (this.activeConnection) return this.activeConnection;
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
|
|||||||
To: to,
|
To: to,
|
||||||
is_agent: 'true',
|
is_agent: 'true',
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
call_sid: callSid,
|
||||||
};
|
};
|
||||||
|
|
||||||
const connection = await this.device.connect({ params });
|
const connection = await this.device.connect({ params });
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
|
|||||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
leaveConference(inboxId, conversationId) {
|
leaveConference({ inboxId, conversationId, callSid }) {
|
||||||
return axios
|
return axios
|
||||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||||
params: { conversation_id: conversationId },
|
params: { conversation_id: conversationId, call_sid: callSid },
|
||||||
})
|
})
|
||||||
.then(r => r.data);
|
.then(r => r.data);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -35,10 +35,16 @@ const emit = defineEmits([
|
|||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||||
|
|
||||||
const voiceCallData = computed(() => ({
|
const voiceCallData = computed(() => {
|
||||||
status: props.chat.additional_attributes?.call_status,
|
const last = lastMessageInChat.value;
|
||||||
direction: props.chat.additional_attributes?.call_direction,
|
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);
|
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>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
|
|||||||
|
|
||||||
const bannerClass = computed(() => {
|
const bannerClass = computed(() => {
|
||||||
const classMap = {
|
const classMap = {
|
||||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
slate:
|
||||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
amber:
|
||||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-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];
|
return classMap[props.color];
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const props = defineProps({
|
|||||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||||
},
|
},
|
||||||
attachments: { type: Array, default: () => [] },
|
attachments: { type: Array, default: () => [] },
|
||||||
|
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||||
content: { type: String, default: null },
|
content: { type: String, default: null },
|
||||||
contentAttributes: { type: Object, default: () => ({}) },
|
contentAttributes: { type: Object, default: () => ({}) },
|
||||||
contentType: {
|
contentType: {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
import { useMessageContext } from '../provider.js';
|
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 Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.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',
|
[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 = {
|
const ICON_MAP = {
|
||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
[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',
|
[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 { t } = useI18n();
|
||||||
const status = computed(() => data.value?.status?.toString());
|
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(() =>
|
const isFailed = computed(() =>
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
[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(() => {
|
const labelKey = computed(() => {
|
||||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
@@ -52,16 +86,28 @@ const labelKey = computed(() => {
|
|||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtextKey = computed(() => {
|
const subtext = computed(() => {
|
||||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
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) {
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
return isOutbound.value
|
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
if (didCurrentUserAnswer.value) {
|
||||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
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
|
return isFailed.value
|
||||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconName = computed(() => {
|
const iconName = computed(() => {
|
||||||
@@ -70,11 +116,83 @@ const iconName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<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 gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
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) }}
|
{{ $t(labelKey) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs text-n-slate-11">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
</template>
|
</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>
|
<script>
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
|
|||||||
await endCallSession({
|
await endCallSession({
|
||||||
conversationId: call.conversationId,
|
conversationId: call.conversationId,
|
||||||
inboxId,
|
inboxId,
|
||||||
|
callSid: call.callSid,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
|
|||||||
const hasUnread = computed(() => unreadCount.value > 0);
|
const hasUnread = computed(() => unreadCount.value > 0);
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
const voiceCallData = computed(() => ({
|
const voiceCallData = computed(() => {
|
||||||
status: props.chat.additional_attributes?.call_status,
|
const last = lastMessageInChat.value;
|
||||||
direction: props.chat.additional_attributes?.call_direction,
|
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(() => {
|
const showMetaSection = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
import Timer from 'dashboard/helper/Timer';
|
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 callsStore = useCallsStore();
|
||||||
|
const { t } = useI18n();
|
||||||
const isJoining = ref(false);
|
const isJoining = ref(false);
|
||||||
const callDuration = ref(0);
|
const callDuration = ref(0);
|
||||||
const durationTimer = new Timer(elapsed => {
|
const durationTimer = new Timer(elapsed => {
|
||||||
@@ -16,36 +23,38 @@ export function useCallSession() {
|
|||||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||||
|
|
||||||
watch(
|
if (manageSessionState) {
|
||||||
hasActiveCall,
|
watch(
|
||||||
active => {
|
hasActiveCall,
|
||||||
if (active) {
|
active => {
|
||||||
durationTimer.start();
|
if (active) {
|
||||||
} else {
|
durationTimer.start();
|
||||||
durationTimer.stop();
|
} else {
|
||||||
callDuration.value = 0;
|
durationTimer.stop();
|
||||||
}
|
callDuration.value = 0;
|
||||||
},
|
}
|
||||||
{ immediate: true }
|
},
|
||||||
);
|
{ immediate: true }
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
|
||||||
callsStore.clearActiveCall()
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onMounted(() => {
|
||||||
durationTimer.stop();
|
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
callsStore.clearActiveCall()
|
||||||
callsStore.clearActiveCall()
|
);
|
||||||
);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const endCall = async ({ conversationId, inboxId }) => {
|
onUnmounted(() => {
|
||||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
durationTimer.stop();
|
||||||
|
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||||
|
callsStore.clearActiveCall()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||||
|
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||||
TwilioVoiceClient.endClientCall();
|
TwilioVoiceClient.endClientCall();
|
||||||
durationTimer.stop();
|
if (manageSessionState) durationTimer.stop();
|
||||||
callsStore.clearActiveCall();
|
callsStore.clearActiveCall();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,13 +75,19 @@ export function useCallSession() {
|
|||||||
await TwilioVoiceClient.joinClientCall({
|
await TwilioVoiceClient.joinClientCall({
|
||||||
to: joinResponse?.conference_sid,
|
to: joinResponse?.conference_sid,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
callSid,
|
||||||
});
|
});
|
||||||
|
|
||||||
callsStore.setCallActive(callSid);
|
callsStore.setCallActive(callSid);
|
||||||
durationTimer.start();
|
if (manageSessionState) durationTimer.start();
|
||||||
|
|
||||||
return { conferenceSid: joinResponse?.conference_sid };
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
} catch (error) {
|
} 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
|
// eslint-disable-next-line no-console
|
||||||
console.error('Failed to join call:', error);
|
console.error('Failed to join call:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const LOCAL_STORAGE_KEYS = {
|
export const LOCAL_STORAGE_KEYS = {
|
||||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||||
|
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||||
WIDGET_BUILDER: 'widgetBubble_',
|
WIDGET_BUILDER: 'widgetBubble_',
|
||||||
DRAFT_MESSAGES: 'draftMessages',
|
DRAFT_MESSAGES: 'draftMessages',
|
||||||
COLOR_SCHEME: 'color_scheme',
|
COLOR_SCHEME: 'color_scheme',
|
||||||
|
|||||||
@@ -22,13 +22,55 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
|||||||
return callDirection === 'outbound' && 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) {
|
function extractCallData(message) {
|
||||||
const contentData = message?.content_attributes?.data || {};
|
const call = message?.call || {};
|
||||||
return {
|
return {
|
||||||
callSid: contentData.call_sid,
|
callSid: call.provider_call_id,
|
||||||
status: contentData.status,
|
status: call.status,
|
||||||
callDirection: contentData.call_direction,
|
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||||
conversationId: message?.conversation_id,
|
conversationId: message?.conversation_id,
|
||||||
|
assigneeId: extractAssigneeId(message?.conversation),
|
||||||
senderId: message?.sender?.id,
|
senderId: message?.sender?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -36,10 +78,19 @@ function extractCallData(message) {
|
|||||||
export function handleVoiceCallCreated(message, currentUserId) {
|
export function handleVoiceCallCreated(message, currentUserId) {
|
||||||
if (!isVoiceCallMessage(message)) return;
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
const { callSid, callDirection, conversationId, senderId } =
|
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||||
extractCallData(message);
|
extractCallData(message);
|
||||||
|
|
||||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
if (
|
||||||
|
!shouldShowCall({
|
||||||
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
assigneeId,
|
||||||
|
currentUserId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
callsStore.addCall({
|
callsStore.addCall({
|
||||||
@@ -53,22 +104,38 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
|||||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||||
if (!isVoiceCallMessage(message)) return;
|
if (!isVoiceCallMessage(message)) return;
|
||||||
|
|
||||||
const { callSid, status, callDirection, conversationId, senderId } =
|
const {
|
||||||
extractCallData(message);
|
callSid,
|
||||||
|
status,
|
||||||
|
callDirection,
|
||||||
|
conversationId,
|
||||||
|
assigneeId,
|
||||||
|
senderId,
|
||||||
|
} = extractCallData(message);
|
||||||
|
|
||||||
const callsStore = useCallsStore();
|
const callsStore = useCallsStore();
|
||||||
|
|
||||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||||
|
|
||||||
const callInfo = { conversationId, callStatus: status };
|
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
conversationId,
|
||||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
callStatus: status,
|
||||||
|
callSid,
|
||||||
|
});
|
||||||
|
|
||||||
const isNewCall =
|
if (
|
||||||
status === 'ringing' &&
|
!shouldShowCall({
|
||||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
callDirection,
|
||||||
|
senderId,
|
||||||
|
assigneeId,
|
||||||
|
currentUserId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
callsStore.removeCall(callSid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewCall) {
|
if (status === 'ringing') {
|
||||||
callsStore.addCall({
|
callsStore.addCall({
|
||||||
callSid,
|
callSid,
|
||||||
conversationId,
|
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",
|
"CALL_ENDED": "Call ended",
|
||||||
"NOT_ANSWERED_YET": "Not answered yet",
|
"NOT_ANSWERED_YET": "Not answered yet",
|
||||||
"THEY_ANSWERED": "They answered",
|
"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": {
|
"HEADER": {
|
||||||
"RESOLVE_ACTION": "Resolve",
|
"RESOLVE_ACTION": "Resolve",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
|
|||||||
import {
|
import {
|
||||||
handleVoiceCallCreated,
|
handleVoiceCallCreated,
|
||||||
handleVoiceCallUpdated,
|
handleVoiceCallUpdated,
|
||||||
|
syncConversationCallVisibility,
|
||||||
} from 'dashboard/helper/voice';
|
} from 'dashboard/helper/voice';
|
||||||
|
|
||||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||||
@@ -393,19 +394,18 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateConversation({ commit, dispatch }, conversation) {
|
updateConversation({ commit, dispatch, rootGetters }, conversation) {
|
||||||
const {
|
const sender = conversation.meta?.sender;
|
||||||
meta: { sender },
|
|
||||||
} = conversation;
|
|
||||||
|
|
||||||
commit(types.UPDATE_CONVERSATION, conversation);
|
commit(types.UPDATE_CONVERSATION, conversation);
|
||||||
|
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
|
||||||
|
|
||||||
dispatch('conversationLabels/setConversationLabel', {
|
dispatch('conversationLabels/setConversationLabel', {
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
data: conversation.labels,
|
data: conversation.labels,
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch('contacts/setContact', sender);
|
if (sender) dispatch('contacts/setContact', sender);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateConversationLastActivity(
|
updateConversationLastActivity(
|
||||||
|
|||||||
@@ -307,34 +307,21 @@ export const mutations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
[types.UPDATE_MESSAGE_CALL_STATUS](
|
||||||
_state,
|
_state,
|
||||||
{ conversationId, callStatus }
|
{ conversationId, callStatus, callSid }
|
||||||
) {
|
) {
|
||||||
const chat = getConversationById(_state)(conversationId);
|
const chat = getConversationById(_state)(conversationId);
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
chat.additional_attributes = {
|
const message = (chat.messages || []).find(
|
||||||
...chat.additional_attributes,
|
m =>
|
||||||
call_status: callStatus,
|
m.content_type === CONTENT_TYPES.VOICE_CALL &&
|
||||||
};
|
m.call?.provider_call_id === callSid
|
||||||
},
|
|
||||||
|
|
||||||
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
|
||||||
const chat = getConversationById(_state)(conversationId);
|
|
||||||
if (!chat) return;
|
|
||||||
|
|
||||||
const lastCall = (chat.messages || []).findLast(
|
|
||||||
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
|
||||||
);
|
);
|
||||||
|
if (!message?.call) return;
|
||||||
|
|
||||||
if (!lastCall) return;
|
message.call = { ...message.call, status: callStatus };
|
||||||
|
|
||||||
lastCall.content_attributes ??= {};
|
|
||||||
lastCall.content_attributes.data = {
|
|
||||||
...lastCall.content_attributes.data,
|
|
||||||
status: callStatus,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||||
|
|||||||
@@ -2,55 +2,18 @@ import { mutations } from '../index';
|
|||||||
import types from '../../../mutation-types';
|
import types from '../../../mutation-types';
|
||||||
|
|
||||||
describe('#mutations', () => {
|
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', () => {
|
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||||
it('does nothing if conversation is not found', () => {
|
it('does nothing if conversation is not found', () => {
|
||||||
const state = { allConversations: [] };
|
const state = { allConversations: [] };
|
||||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
callStatus: 'ringing',
|
callStatus: 'ringing',
|
||||||
|
callSid: 'CA123',
|
||||||
});
|
});
|
||||||
expect(state.allConversations).toEqual([]);
|
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 = {
|
const state = {
|
||||||
allConversations: [
|
allConversations: [
|
||||||
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||||
@@ -59,6 +22,7 @@ describe('#mutations', () => {
|
|||||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
callStatus: 'ringing',
|
callStatus: 'ringing',
|
||||||
|
callSid: 'CA123',
|
||||||
});
|
});
|
||||||
expect(state.allConversations[0].messages[0]).toEqual({
|
expect(state.allConversations[0].messages[0]).toEqual({
|
||||||
id: 1,
|
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 = {
|
const state = {
|
||||||
allConversations: [
|
allConversations: [
|
||||||
{
|
{
|
||||||
@@ -75,12 +39,12 @@ describe('#mutations', () => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
content_type: 'voice_call',
|
content_type: 'voice_call',
|
||||||
content_attributes: { data: { status: 'ringing' } },
|
call: { provider_call_id: 'CA111', status: 'ringing' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
content_type: 'voice_call',
|
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, {
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
callStatus: 'in-progress',
|
callStatus: 'in-progress',
|
||||||
|
callSid: 'CA111',
|
||||||
});
|
});
|
||||||
expect(
|
expect(state.allConversations[0].messages[0].call.status).toBe(
|
||||||
state.allConversations[0].messages[0].content_attributes.data.status
|
'in-progress'
|
||||||
).toBe('ringing');
|
);
|
||||||
expect(
|
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
|
||||||
state.allConversations[0].messages[1].content_attributes.data.status
|
|
||||||
).toBe('in-progress');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates content_attributes.data if it does not exist', () => {
|
it('preserves existing call fields when updating status', () => {
|
||||||
const state = {
|
|
||||||
allConversations: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
messages: [{ id: 1, content_type: 'voice_call' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
|
||||||
conversationId: 1,
|
|
||||||
callStatus: 'completed',
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
state.allConversations[0].messages[0].content_attributes.data.status
|
|
||||||
).toBe('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves existing data in content_attributes.data', () => {
|
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [
|
allConversations: [
|
||||||
{
|
{
|
||||||
@@ -125,8 +70,11 @@ describe('#mutations', () => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
content_type: 'voice_call',
|
content_type: 'voice_call',
|
||||||
content_attributes: {
|
call: {
|
||||||
data: { call_sid: 'CA123', status: 'ringing' },
|
provider_call_id: 'CA123',
|
||||||
|
status: 'ringing',
|
||||||
|
direction: 'incoming',
|
||||||
|
duration_seconds: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -136,12 +84,13 @@ describe('#mutations', () => {
|
|||||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
callStatus: 'in-progress',
|
callStatus: 'in-progress',
|
||||||
|
callSid: 'CA123',
|
||||||
});
|
});
|
||||||
expect(
|
expect(state.allConversations[0].messages[0].call).toEqual({
|
||||||
state.allConversations[0].messages[0].content_attributes.data
|
provider_call_id: 'CA123',
|
||||||
).toEqual({
|
|
||||||
call_sid: 'CA123',
|
|
||||||
status: 'in-progress',
|
status: 'in-progress',
|
||||||
|
direction: 'incoming',
|
||||||
|
duration_seconds: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,8 +101,34 @@ describe('#mutations', () => {
|
|||||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
callStatus: 'ringing',
|
callStatus: 'ringing',
|
||||||
|
callSid: 'CA123',
|
||||||
});
|
});
|
||||||
expect(state.allConversations[0].messages).toEqual([]);
|
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_CUSTOM_ATTRIBUTES',
|
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
|
||||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||||
|
|
||||||
|
|||||||
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
|
|||||||
dismissCall(callSid) {
|
dismissCall(callSid) {
|
||||||
this.calls = this.calls.filter(call => call.callSid !== 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,
|
WIDGET_BRAND_URL: widgetBrandURL,
|
||||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||||
DEPLOYMENT_ENV: deploymentEnv,
|
DEPLOYMENT_ENV: deploymentEnv,
|
||||||
|
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
|
||||||
} = window.globalConfig || {};
|
} = window.globalConfig || {};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
@@ -49,6 +50,7 @@ const state = {
|
|||||||
termsURL,
|
termsURL,
|
||||||
widgetBrandURL,
|
widgetBrandURL,
|
||||||
isEnterprise: parseBoolean(isEnterprise),
|
isEnterprise: parseBoolean(isEnterprise),
|
||||||
|
activePlatformBanners: activePlatformBanners || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getters = {
|
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)
|
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||||
validated_mentioned_ids.each do |user_id|
|
validated_mentioned_ids.each do |user_id|
|
||||||
|
next if self_mention?(user_id)
|
||||||
|
|
||||||
NotificationBuilder.new(
|
NotificationBuilder.new(
|
||||||
notification_type: 'conversation_mention',
|
notification_type: 'conversation_mention',
|
||||||
user: User.find(user_id),
|
user: User.find(user_id),
|
||||||
@@ -60,6 +62,10 @@ class Messages::MentionService
|
|||||||
end
|
end
|
||||||
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)
|
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||||
validated_mentioned_ids.each do |user_id|
|
validated_mentioned_ids.each do |user_id|
|
||||||
message.conversation.conversation_participants.find_or_create_by(user_id: 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.source_id message.source_id
|
||||||
json.sender message.sender.push_event_data if message.sender
|
json.sender message.sender.push_event_data if message.sender
|
||||||
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
|
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"/>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<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"/>
|
<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>
|
</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',
|
users: 'icon-user-follow-line',
|
||||||
platform_apps: 'icon-apps-2-line',
|
platform_apps: 'icon-apps-2-line',
|
||||||
agent_bots: 'icon-robot-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' } %>
|
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
|
||||||
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
|
<% 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 ["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: {
|
<%= render partial: "nav_item", locals: {
|
||||||
icon: sidebar_icons[resource.resource.to_sym],
|
icon: sidebar_icons[resource.resource.to_sym],
|
||||||
url: resource_index_route(resource),
|
url: resource_index_route(resource),
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ en:
|
|||||||
email_already_exists: 'You have already signed up for an account with %{email}'
|
email_already_exists: 'You have already signed up for an account with %{email}'
|
||||||
invalid_params: 'Invalid, please check the signup paramters and try again'
|
invalid_params: 'Invalid, please check the signup paramters and try again'
|
||||||
failed: Signup failed
|
failed: Signup failed
|
||||||
|
voice:
|
||||||
|
call_already_accepted: '%{agent_name} is already handling the call.'
|
||||||
assignment_policy:
|
assignment_policy:
|
||||||
not_found: Assignment policy not found
|
not_found: Assignment policy not found
|
||||||
attachments:
|
attachments:
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ Rails.application.routes.draw do
|
|||||||
delete :avatar, on: :member, action: :destroy_avatar
|
delete :avatar, on: :member, action: :destroy_avatar
|
||||||
end
|
end
|
||||||
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
||||||
|
resources :platform_banners
|
||||||
resource :instance_status, only: [:show]
|
resource :instance_status, only: [:show]
|
||||||
|
|
||||||
resource :settings, only: [:show] do
|
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
|
t.datetime "updated_at", null: false
|
||||||
end
|
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|
|
create_table "portals", force: :cascade do |t|
|
||||||
t.integer "account_id", null: false
|
t.integer "account_id", null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
||||||
before_action :set_voice_inbox_for_conference
|
before_action :set_voice_inbox_for_conference
|
||||||
|
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
|
||||||
|
|
||||||
def token
|
def token
|
||||||
render json: Voice::Provider::Twilio::TokenService.new(
|
render json: Voice::Provider::Twilio::TokenService.new(
|
||||||
@@ -10,36 +11,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
conversation = fetch_conversation_by_display_id
|
call = resolve_call!
|
||||||
ensure_call_sid!(conversation)
|
|
||||||
|
|
||||||
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_sid = conference_service.ensure_conference_sid
|
||||||
conference_service.mark_agent_joined(user: current_user)
|
conference_service.mark_agent_joined(user: current_user)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
id: conversation.display_id,
|
id: call.conversation.display_id,
|
||||||
conference_sid: conference_sid,
|
conference_sid: conference_sid,
|
||||||
using_webrtc: true
|
using_webrtc: true
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
conversation = fetch_conversation_by_display_id
|
call = resolve_call!
|
||||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
|
||||||
render json: { status: 'success', id: conversation.display_id }
|
render json: { status: 'success', id: call.conversation.display_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_call_sid!(conversation)
|
def resolve_call!
|
||||||
return conversation.identifier if conversation.identifier.present?
|
sid = params[:call_sid].presence
|
||||||
|
raise ActionController::ParameterMissing, :call_sid if sid.blank?
|
||||||
|
|
||||||
incoming_sid = params.require(:call_sid)
|
conversation = fetch_conversation_by_display_id
|
||||||
|
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
|
||||||
conversation.update!(identifier: incoming_sid)
|
.find_by!(provider_call_id: sid)
|
||||||
incoming_sid
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_voice_inbox_for_conference
|
def set_voice_inbox_for_conference
|
||||||
@@ -55,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
|||||||
authorize conversation, :show?
|
authorize conversation, :show?
|
||||||
conversation
|
conversation
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_call_already_accepted(error)
|
||||||
|
render json: { error: error.message }, status: :conflict
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
|||||||
authorize contact, :show?
|
authorize contact, :show?
|
||||||
authorize voice_inbox, :show?
|
authorize voice_inbox, :show?
|
||||||
|
|
||||||
result = Voice::OutboundCallBuilder.perform!(
|
call = Voice::OutboundCallBuilder.perform!(
|
||||||
account: Current.account,
|
account: Current.account,
|
||||||
inbox: voice_inbox,
|
inbox: voice_inbox,
|
||||||
user: Current.user,
|
user: Current.user,
|
||||||
contact: contact
|
contact: contact
|
||||||
)
|
)
|
||||||
|
|
||||||
conversation = result[:conversation]
|
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
conversation_id: conversation.display_id,
|
conversation_id: call.conversation.display_id,
|
||||||
inbox_id: voice_inbox.id,
|
inbox_id: voice_inbox.id,
|
||||||
call_sid: result[:call_sid],
|
call_sid: call.provider_call_id,
|
||||||
conference_sid: conversation.additional_attributes['conference_sid']
|
conference_sid: call.conference_sid
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call_twiml
|
def call_twiml
|
||||||
account = current_account
|
|
||||||
Rails.logger.info(
|
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
|
call = resolve_call
|
||||||
conference_sid = ensure_conference_sid!(conversation)
|
render xml: conference_twiml(call)
|
||||||
|
|
||||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def conference_status
|
def conference_status
|
||||||
event = mapped_conference_event
|
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!(
|
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
|
||||||
friendly_name: params[:FriendlyName],
|
|
||||||
call_sid: twilio_call_sid
|
|
||||||
)
|
|
||||||
|
|
||||||
Voice::Conference::Manager.new(
|
Voice::Conference::Manager.new(
|
||||||
conversation: conversation,
|
call: call,
|
||||||
event: event,
|
event: event,
|
||||||
call_sid: twilio_call_sid,
|
|
||||||
participant_label: participant_label
|
participant_label: participant_label
|
||||||
).process
|
).process
|
||||||
|
|
||||||
@@ -80,8 +78,8 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
from_number.start_with?('client:')
|
from_number.start_with?('client:')
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_conversation
|
def resolve_call
|
||||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
return find_call_for_agent if agent_leg?(twilio_from)
|
||||||
|
|
||||||
case twilio_direction
|
case twilio_direction
|
||||||
when 'inbound'
|
when 'inbound'
|
||||||
@@ -92,79 +90,72 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
call_sid: twilio_call_sid
|
call_sid: twilio_call_sid
|
||||||
)
|
)
|
||||||
when 'outbound-api', 'outbound-dial'
|
when 'outbound-api', 'outbound-dial'
|
||||||
sync_outbound_leg(
|
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
|
||||||
call_sid: twilio_call_sid,
|
|
||||||
from_number: twilio_from,
|
|
||||||
direction: twilio_direction
|
|
||||||
)
|
|
||||||
else
|
else
|
||||||
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation_for_agent
|
def find_call_for_agent
|
||||||
if params[:conversation_id].present?
|
sid = params[:call_sid].presence
|
||||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
|
||||||
else
|
|
||||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
inbox_calls.find_by!(provider_call_id: sid)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
def sync_outbound_leg(call_sid:, direction:)
|
||||||
parent_sid = params['ParentCallSid'].presence
|
parent_sid = params['ParentCallSid'].presence
|
||||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
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(
|
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
|
||||||
conversation: conversation,
|
call
|
||||||
call_sid: call_sid,
|
|
||||||
message_call_sid: conversation.identifier,
|
|
||||||
leg: {
|
|
||||||
from_number: from_number,
|
|
||||||
to_number: twilio_to,
|
|
||||||
direction: 'outbound'
|
|
||||||
}
|
|
||||||
).perform
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_conference_sid!(conversation)
|
def inbox_calls
|
||||||
attrs = conversation.additional_attributes || {}
|
Call.where(inbox_id: inbox.id, provider: :twilio)
|
||||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
|
||||||
conversation.update!(additional_attributes: attrs)
|
|
||||||
attrs['conference_sid']
|
|
||||||
end
|
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|
|
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||||
response.dial do |dial|
|
response.dial do |dial|
|
||||||
dial.conference(
|
dial.conference(
|
||||||
conference_sid,
|
conference_sid,
|
||||||
start_conference_on_enter: agent_leg,
|
start_conference_on_enter: agent_leg?(twilio_from),
|
||||||
end_conference_on_exit: false,
|
end_conference_on_exit: false,
|
||||||
status_callback: conference_status_callback_url,
|
status_callback: conference_status_callback_url,
|
||||||
status_callback_event: 'start end join leave',
|
status_callback_event: 'start end join leave',
|
||||||
status_callback_method: 'POST',
|
status_callback_method: 'POST',
|
||||||
participant_label: agent_leg ? 'agent' : 'contact'
|
participant_label: participant_label_for(twilio_from)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end.to_s
|
end.to_s
|
||||||
end
|
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
|
def conference_status_callback_url
|
||||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
def find_call_for_conference!(friendly_name, call_sid)
|
||||||
name = friendly_name.to_s
|
name = friendly_name.to_s
|
||||||
scope = current_account.conversations
|
call = inbox_calls.by_conference_sid(name).first if name.present?
|
||||||
|
call || inbox_calls.find_by!(provider_call_id: call_sid)
|
||||||
if name.present?
|
|
||||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
|
||||||
return conversation if conversation
|
|
||||||
end
|
|
||||||
|
|
||||||
scope.find_by!(identifier: call_sid)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_inbox!
|
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
|
# Statuses where the call is finished and won't change again
|
||||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
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 :provider, { twilio: 0, whatsapp: 1 }
|
||||||
enum :direction, { incoming: 0, outgoing: 1 }
|
enum :direction, { incoming: 0, outgoing: 1 }
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ class Call < ApplicationRecord
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
belongs_to :conversation
|
belongs_to :conversation
|
||||||
belongs_to :contact
|
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
|
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||||
|
|
||||||
has_one_attached :recording
|
has_one_attached :recording
|
||||||
@@ -52,4 +54,51 @@ class Call < ApplicationRecord
|
|||||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||||
|
|
||||||
scope :active, -> { where.not(status: TERMINAL_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
|
end
|
||||||
|
|||||||
@@ -25,17 +25,6 @@ module Enterprise::Conversation
|
|||||||
self.captain_activity_reason_type = previous_reason_type
|
self.captain_activity_reason_type = previous_reason_type
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def dispatch_captain_inference_event(event_name)
|
def dispatch_captain_inference_event(event_name)
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
module Enterprise::Message
|
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
|
private
|
||||||
|
|
||||||
def mark_pending_conversation_as_open_for_human_response
|
def mark_pending_conversation_as_open_for_human_response
|
||||||
|
|||||||
@@ -1,90 +1,26 @@
|
|||||||
class Voice::CallMessageBuilder
|
class Voice::CallMessageBuilder
|
||||||
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
def initialize(call)
|
||||||
new(
|
@call = call
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
validate_sender!
|
call.message || create_message!
|
||||||
message = latest_message
|
|
||||||
message ? update_message!(message) : create_message!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
attr_reader :call
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def create_message!
|
def create_message!
|
||||||
params = {
|
params = {
|
||||||
content: 'Voice Call',
|
content: 'Voice Call',
|
||||||
message_type: message_type,
|
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||||
content_type: 'voice_call',
|
content_type: 'voice_call'
|
||||||
content_attributes: { 'data' => base_payload }
|
|
||||||
}
|
}
|
||||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
Messages::MessageBuilder.new(sender, call.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'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def sender
|
def sender
|
||||||
return user if direction == 'outbound'
|
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||||
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
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
|
class Voice::CallStatus::Manager
|
||||||
pattr_initialize [:conversation!, :call_sid]
|
pattr_initialize [:call!]
|
||||||
|
|
||||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
|
||||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
|
||||||
|
|
||||||
def process_status_update(status, duration: nil, timestamp: nil)
|
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')
|
apply_call_updates!(status, duration: duration, timestamp: timestamp)
|
||||||
return if current_status == status
|
call.conversation.update!(last_activity_at: Time.zone.now)
|
||||||
|
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
|
||||||
apply_status(status, duration: duration, timestamp: timestamp)
|
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
|
||||||
update_message(status)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def apply_status(status, duration:, timestamp:)
|
def apply_call_updates!(status, duration:, timestamp:)
|
||||||
attrs = (conversation.additional_attributes || {}).dup
|
attrs = { status: status }
|
||||||
attrs['call_status'] = status
|
ts = timestamp || now_seconds
|
||||||
|
|
||||||
if status == 'in-progress'
|
if status == 'in_progress'
|
||||||
attrs['call_started_at'] ||= timestamp || now_seconds
|
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
|
||||||
elsif TERMINAL_STATUSES.include?(status)
|
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
|
||||||
attrs['call_ended_at'] = timestamp || now_seconds
|
started_at = Time.zone.at(ts)
|
||||||
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
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
|
end
|
||||||
|
|
||||||
conversation.update!(
|
call.update!(attrs)
|
||||||
additional_attributes: attrs,
|
|
||||||
last_activity_at: current_time
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolved_duration(attrs, provided_duration, timestamp)
|
def resolved_duration(provided_duration, timestamp)
|
||||||
return provided_duration if provided_duration
|
return provided_duration if provided_duration
|
||||||
|
return unless call.started_at
|
||||||
|
|
||||||
started_at = attrs['call_started_at']
|
[timestamp - call.started_at.to_i, 0].max
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def now_seconds
|
def now_seconds
|
||||||
current_time.to_i
|
Time.zone.now.to_i
|
||||||
end
|
|
||||||
|
|
||||||
def current_time
|
|
||||||
@current_time ||= Time.zone.now
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,71 +1,93 @@
|
|||||||
class Voice::Conference::Manager
|
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
|
def process
|
||||||
case event
|
case event
|
||||||
when 'start'
|
when 'start'
|
||||||
ensure_conference_sid!
|
|
||||||
mark_ringing!
|
mark_ringing!
|
||||||
when 'join'
|
when 'join'
|
||||||
mark_in_progress! if agent_participant?
|
join_agent! if agent_participant?
|
||||||
when 'leave'
|
when 'leave'
|
||||||
handle_leave!
|
handle_leave!
|
||||||
when 'end'
|
when 'end'
|
||||||
finalize_conference!
|
finalize!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def status_manager
|
def status_manager
|
||||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_ringing!
|
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')
|
status_manager.process_status_update('ringing')
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_in_progress!
|
def join_agent!
|
||||||
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
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
|
end
|
||||||
|
|
||||||
def handle_leave!
|
def handle_leave!
|
||||||
case current_status
|
case call.status
|
||||||
when 'ringing'
|
when 'ringing'
|
||||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
status_manager.process_status_update('no_answer', timestamp: now)
|
||||||
when 'in-progress'
|
when 'in_progress'
|
||||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
status_manager.process_status_update('completed', timestamp: now)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def finalize_conference!
|
def finalize!
|
||||||
return if %w[completed no-answer failed].include?(current_status)
|
return if Call::TERMINAL_STATUSES.include?(call.status)
|
||||||
|
|
||||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
status_manager.process_status_update('completed', timestamp: now)
|
||||||
end
|
|
||||||
|
|
||||||
def current_status
|
|
||||||
conversation.additional_attributes&.dig('call_status')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def agent_participant?
|
def agent_participant?
|
||||||
participant_label.to_s.start_with?('agent')
|
participant_label.to_s.start_with?('agent-')
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_timestamp
|
def now
|
||||||
Time.zone.now.to_i
|
Time.zone.now.to_i
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
timestamp = current_timestamp
|
existing = find_existing_call
|
||||||
|
return existing if existing
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
contact = ensure_contact!
|
contact = ensure_contact!
|
||||||
contact_inbox = ensure_contact_inbox!(contact)
|
contact_inbox = ensure_contact_inbox!(contact)
|
||||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
conversation = resolve_conversation!(contact, contact_inbox)
|
||||||
conversation.reload
|
call = create_call!(contact, conversation)
|
||||||
update_conversation!(conversation, timestamp)
|
message = Voice::CallMessageBuilder.new(call).perform!
|
||||||
build_voice_message!(conversation, timestamp)
|
call.update!(message_id: message.id)
|
||||||
conversation
|
call
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
# A concurrent Twilio retry won the create race; return what now exists.
|
||||||
|
find_existing_call || raise
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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!
|
def ensure_contact!
|
||||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||||
record.name = from_number if record.name.blank?
|
record.name = from_number if record.name.blank?
|
||||||
@@ -43,57 +52,37 @@ class Voice::InboundCallBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation
|
def resolve_conversation!(contact, contact_inbox)
|
||||||
return if call_sid.blank?
|
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!(
|
account.conversations.create!(
|
||||||
contact_inbox_id: contact_inbox.id,
|
contact_inbox_id: contact_inbox.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
status: :open,
|
status: :open
|
||||||
identifier: call_sid
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_conversation!(conversation, timestamp)
|
def create_call!(contact, conversation)
|
||||||
attrs = {
|
call = Call.create!(
|
||||||
'call_direction' => 'inbound',
|
account: account,
|
||||||
'call_status' => 'ringing',
|
inbox: inbox,
|
||||||
'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!(
|
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
direction: 'inbound',
|
contact: contact,
|
||||||
payload: {
|
provider: :twilio,
|
||||||
call_sid: call_sid,
|
direction: :incoming,
|
||||||
status: 'ringing',
|
status: 'ringing',
|
||||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
provider_call_id: call_sid,
|
||||||
from_number: from_number,
|
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||||
to_number: inbox.channel&.phone_number
|
|
||||||
},
|
|
||||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
|
||||||
)
|
)
|
||||||
end
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
|
call
|
||||||
def current_timestamp
|
|
||||||
@current_timestamp ||= current_time.to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_time
|
|
||||||
@current_time ||= Time.zone.now
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
|
|||||||
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||||
raise ArgumentError, 'Agent required' if user.blank?
|
raise ArgumentError, 'Agent required' if user.blank?
|
||||||
|
|
||||||
timestamp = current_timestamp
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
contact_inbox = ensure_contact_inbox!
|
contact_inbox = ensure_contact_inbox!
|
||||||
conversation = create_conversation!(contact_inbox)
|
conversation = create_conversation!(contact_inbox)
|
||||||
conversation.reload
|
|
||||||
conference_sid = Voice::Conference::Name.for(conversation)
|
|
||||||
call_sid = initiate_call!
|
call_sid = initiate_call!
|
||||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
call = create_call!(conversation, call_sid)
|
||||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
message = Voice::CallMessageBuilder.new(call).perform!
|
||||||
{ conversation: conversation, call_sid: call_sid }
|
call.update!(message_id: message.id)
|
||||||
|
call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def initiate_call!
|
def initiate_call!
|
||||||
inbox.channel.initiate_call(
|
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
|
||||||
to: contact.phone_number
|
|
||||||
)[:call_sid]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
def create_call!(conversation, call_sid)
|
||||||
attrs = {
|
call = Call.create!(
|
||||||
'call_direction' => 'outbound',
|
account: account,
|
||||||
'call_status' => 'ringing',
|
inbox: inbox,
|
||||||
'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!(
|
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
direction: 'outbound',
|
contact: contact,
|
||||||
payload: {
|
accepted_by_agent: user,
|
||||||
call_sid: call_sid,
|
provider: :twilio,
|
||||||
status: 'ringing',
|
direction: :outgoing,
|
||||||
conference_sid: conference_sid,
|
status: 'ringing',
|
||||||
from_number: inbox.channel&.phone_number,
|
provider_call_id: call_sid,
|
||||||
to_number: contact.phone_number
|
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||||
},
|
|
||||||
user: user,
|
|
||||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
|
||||||
)
|
)
|
||||||
end
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
|
call
|
||||||
def current_timestamp
|
|
||||||
@current_timestamp ||= current_time.to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_time
|
|
||||||
@current_time ||= Time.zone.now
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
class Voice::Provider::Twilio::ConferenceService
|
class Voice::Provider::Twilio::ConferenceService
|
||||||
pattr_initialize [:conversation!]
|
pattr_initialize [:call!]
|
||||||
|
|
||||||
def ensure_conference_sid
|
def ensure_conference_sid
|
||||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
return call.conference_sid if call.conference_sid.present?
|
||||||
return existing if existing.present?
|
|
||||||
|
|
||||||
sid = Voice::Conference::Name.for(conversation)
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
merge_attributes('conference_sid' => sid)
|
call.conference_sid
|
||||||
sid
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_agent_joined(user:)
|
def mark_agent_joined(user:)
|
||||||
merge_attributes(
|
claim_call!(user)
|
||||||
'agent_joined' => true,
|
assign_conversation!(user)
|
||||||
'joined_at' => Time.current.to_i,
|
|
||||||
'joined_by' => { id: user.id, name: user.name }
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def end_conference
|
def end_conference
|
||||||
client = conversation.inbox.channel.client
|
return if call.conference_sid.blank?
|
||||||
|
|
||||||
|
client = call.inbox.channel.client
|
||||||
client
|
client
|
||||||
.conferences
|
.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') }
|
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def merge_attributes(attrs)
|
def claim_call!(user)
|
||||||
current = conversation.additional_attributes || {}
|
call.with_lock do
|
||||||
conversation.update!(additional_attributes: current.merge(attrs))
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
|
|||||||
'queued' => 'ringing',
|
'queued' => 'ringing',
|
||||||
'initiated' => 'ringing',
|
'initiated' => 'ringing',
|
||||||
'ringing' => 'ringing',
|
'ringing' => 'ringing',
|
||||||
'in-progress' => 'in-progress',
|
'in-progress' => 'in_progress',
|
||||||
'inprogress' => 'in-progress',
|
'inprogress' => 'in_progress',
|
||||||
'answered' => 'in-progress',
|
'answered' => 'in_progress',
|
||||||
'completed' => 'completed',
|
'completed' => 'completed',
|
||||||
'busy' => 'no-answer',
|
'busy' => 'no_answer',
|
||||||
'no-answer' => 'no-answer',
|
'no-answer' => 'no_answer',
|
||||||
'failed' => 'failed',
|
'failed' => 'failed',
|
||||||
'canceled' => 'failed'
|
'canceled' => 'failed'
|
||||||
}.freeze
|
}.freeze
|
||||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
|||||||
normalized_status = normalize_status(call_status)
|
normalized_status = normalize_status(call_status)
|
||||||
return if normalized_status.blank?
|
return if normalized_status.blank?
|
||||||
|
|
||||||
conversation = account.conversations.find_by(identifier: call_sid)
|
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
|
||||||
return unless conversation
|
return unless call
|
||||||
|
|
||||||
Voice::CallStatus::Manager.new(
|
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||||
conversation: conversation,
|
|
||||||
call_sid: call_sid
|
|
||||||
).process_status_update(
|
|
||||||
normalized_status,
|
normalized_status,
|
||||||
duration: payload_duration,
|
duration: payload_duration,
|
||||||
timestamp: payload_timestamp
|
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,
|
message_type: :outgoing,
|
||||||
account_id: conversation.account_id,
|
account_id: conversation.account_id,
|
||||||
inbox_id: conversation.inbox_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],
|
external_source_id_slack: params[:event][:ts],
|
||||||
private: private_note?,
|
private: private_note?,
|
||||||
sender: resolved_sender,
|
sender: resolved_sender,
|
||||||
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
|
|||||||
[nil, nil, nil]
|
[nil, nil, nil]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted_message_content
|
||||||
|
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
|
||||||
|
Integrations::Slack::EmojiFormatter.format(text)
|
||||||
|
end
|
||||||
|
|
||||||
def private_note?
|
def private_note?
|
||||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
|||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||||
let(:voice_inbox) { voice_channel.inbox }
|
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(:admin) { create(:user, :administrator, account: account) }
|
||||||
let(:agent) { create(:user, account: account, role: :agent) }
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when authenticated agent with inbox access' do
|
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",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
body = response.parsed_body
|
body = response.parsed_body
|
||||||
expect(body['conference_sid']).to be_present
|
expect(body['conference_sid']).to eq('CF123')
|
||||||
conversation.reload
|
expect(body['id']).to eq(conversation.display_id)
|
||||||
expect(conversation.identifier).to eq('CALL123')
|
|
||||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||||
expect(conference_service).to have_received(:mark_agent_joined)
|
expect(conference_service).to have_received(:mark_agent_joined)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not allow accessing conversations from inboxes without access' do
|
it 'rejects the request when call_sid is missing' 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
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
params: { conversation_id: conversation.display_id }
|
params: { conversation_id: conversation.display_id }
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_content)
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when authenticated agent with inbox access' do
|
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",
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
headers: agent.create_new_auth_token,
|
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).to have_http_status(:ok)
|
||||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||||
expect(conference_service).to have_received(:end_conference)
|
expect(conference_service).to have_received(:end_conference)
|
||||||
end
|
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_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",
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||||
headers: agent.create_new_auth_token,
|
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)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,15 +19,24 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
|||||||
let(:to_number) { channel.phone_number }
|
let(:to_number) { channel.phone_number }
|
||||||
|
|
||||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||||
instance_double(Voice::InboundCallBuilder)
|
|
||||||
conversation = create(:conversation, account: account, inbox: inbox)
|
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(
|
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||||
account: account,
|
account: account,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
call_sid: call_sid
|
call_sid: call_sid
|
||||||
).and_return(conversation)
|
).and_return(call)
|
||||||
|
|
||||||
post "/twilio/voice/call/#{digits}", params: {
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
'CallSid' => call_sid,
|
'CallSid' => call_sid,
|
||||||
@@ -39,64 +48,59 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include('<Response>')
|
expect(response.body).to include('<Response>')
|
||||||
expect(response.body).to include('<Dial>')
|
expect(response.body).to include('<Dial>')
|
||||||
|
expect(response.body).to include(call.conference_sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
|
||||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
call = create(
|
||||||
|
:call,
|
||||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
account: account,
|
||||||
hash_including(
|
inbox: inbox,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
call_sid: call_sid,
|
contact: conversation.contact,
|
||||||
message_call_sid: conversation.identifier,
|
direction: :outgoing,
|
||||||
leg: {
|
provider_call_id: call_sid
|
||||||
from_number: from_number,
|
)
|
||||||
to_number: to_number,
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
direction: 'outbound'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).and_return(sync_double)
|
|
||||||
|
|
||||||
post "/twilio/voice/call/#{digits}", params: {
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
'CallSid' => call_sid,
|
'CallSid' => call_sid,
|
||||||
'From' => from_number,
|
'From' => to_number,
|
||||||
'To' => to_number,
|
'To' => from_number,
|
||||||
'Direction' => 'outbound-api'
|
'Direction' => 'outbound-api'
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
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
|
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'
|
parent_sid = 'CA_parent'
|
||||||
child_sid = 'CA_child'
|
child_sid = 'CA_child'
|
||||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
call = create(
|
||||||
|
:call,
|
||||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
account: account,
|
||||||
hash_including(
|
inbox: inbox,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
call_sid: child_sid,
|
contact: conversation.contact,
|
||||||
message_call_sid: parent_sid,
|
direction: :outgoing,
|
||||||
leg: {
|
provider_call_id: parent_sid
|
||||||
from_number: from_number,
|
)
|
||||||
to_number: to_number,
|
call.update!(conference_sid: call.default_conference_sid)
|
||||||
direction: 'outbound'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).and_return(sync_double)
|
|
||||||
|
|
||||||
post "/twilio/voice/call/#{digits}", params: {
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
'CallSid' => child_sid,
|
'CallSid' => child_sid,
|
||||||
'ParentCallSid' => parent_sid,
|
'ParentCallSid' => parent_sid,
|
||||||
'From' => from_number,
|
'From' => to_number,
|
||||||
'To' => to_number,
|
'To' => from_number,
|
||||||
'Direction' => 'outbound-dial'
|
'Direction' => 'outbound-dial'
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(call.reload.parent_call_sid).to eq(parent_sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises not found when inbox is not present' do
|
it 'raises not found when inbox is not present' do
|
||||||
|
|||||||
@@ -62,17 +62,17 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'marks the due document as syncing before queueing' do
|
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
|
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
|
described_class.new.perform
|
||||||
|
|
||||||
expect(document.reload).to have_attributes(
|
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(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
|
||||||
let(:inbox) { channel.inbox }
|
let(:inbox) { channel.inbox }
|
||||||
let(:from_number) { '+15550001111' }
|
let(:from_number) { '+15550001111' }
|
||||||
let(:to_number) { channel.phone_number }
|
|
||||||
let(:call_sid) { 'CA1234567890abcdef' }
|
let(:call_sid) { 'CA1234567890abcdef' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
@@ -24,98 +23,88 @@ RSpec.describe Voice::InboundCallBuilder do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when no existing conversation matches call_sid' do
|
context 'when no existing call matches call_sid' do
|
||||||
it 'creates a new inbound conversation with ringing status' do
|
it 'creates a new conversation and Call with ringing status' do
|
||||||
conversation = nil
|
call = nil
|
||||||
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
|
||||||
|
|
||||||
attrs = conversation.additional_attributes
|
aggregate_failures do
|
||||||
expect(conversation.identifier).to eq(call_sid)
|
expect(call).to be_a(Call)
|
||||||
expect(attrs['call_direction']).to eq('inbound')
|
expect(call.provider_call_id).to eq(call_sid)
|
||||||
expect(attrs['call_status']).to eq('ringing')
|
expect(call.provider).to eq('twilio')
|
||||||
expect(attrs['conference_sid']).to be_present
|
expect(call.direction).to eq('incoming')
|
||||||
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
expect(call.status).to eq('ringing')
|
||||||
expect(conversation.contact.phone_number).to eq(from_number)
|
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
|
end
|
||||||
|
|
||||||
it 'creates a single voice_call message marked as incoming' do
|
it 'creates a voice_call message matched to the call and linked via message_id' do
|
||||||
conversation = perform_builder
|
call = perform_builder
|
||||||
voice_message = conversation.messages.voice_calls.last
|
voice_message = call.conversation.messages.voice_calls.last
|
||||||
|
|
||||||
expect(voice_message).to be_present
|
aggregate_failures do
|
||||||
expect(voice_message.message_type).to eq('incoming')
|
expect(voice_message).to be_present
|
||||||
data = voice_message.content_attributes['data']
|
expect(voice_message.message_type).to eq('incoming')
|
||||||
expect(data).to include(
|
expect(call.message_id).to eq(voice_message.id)
|
||||||
'call_sid' => call_sid,
|
expect(voice_message.call).to eq(call)
|
||||||
'status' => 'ringing',
|
end
|
||||||
'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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets the contact name to the phone number for new callers' do
|
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
|
end
|
||||||
|
|
||||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
it 'does not set conversation.identifier or write call state to additional_attributes' do
|
||||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
call = perform_builder
|
||||||
expect(conversation.display_id).to be_present
|
conversation = call.conversation
|
||||||
original.call(conversation)
|
|
||||||
end
|
|
||||||
|
|
||||||
perform_builder
|
expect(conversation.identifier).to be_nil
|
||||||
|
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when a conversation already exists for the call_sid' do
|
context 'when a Call already exists for the call_sid' do
|
||||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
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!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||||
let!(:existing_conversation) do
|
let!(:existing_open_conversation) do
|
||||||
create(
|
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
|
||||||
: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' } }
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reuses the conversation without creating a duplicate' do
|
before { inbox.update!(lock_to_single_conversation: true) }
|
||||||
existing_message
|
|
||||||
expect { perform_builder }.not_to change(account.conversations, :count)
|
it 'reuses the most recent non-resolved conversation' do
|
||||||
existing_conversation.reload
|
call = nil
|
||||||
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
expect { call = perform_builder }.not_to change(account.conversations, :count)
|
||||||
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
expect(call.conversation).to eq(existing_open_conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the existing voice call message instead of creating a new one' do
|
it 'still creates a new Call and voice_call message on the reused conversation' do
|
||||||
existing_message
|
expect { perform_builder }.to change(Call, :count).by(1)
|
||||||
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
|
||||||
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')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
|
|||||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||||
allow(inbox).to receive(:channel).and_return(channel)
|
allow(inbox).to receive(:channel).and_return(channel)
|
||||||
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||||
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.perform!' do
|
describe '.perform!' do
|
||||||
it 'creates a conversation and voice call message' do
|
it 'creates a conversation, Call, and voice_call message' do
|
||||||
conversation_count = account.conversations.count
|
call = nil
|
||||||
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
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,
|
account: account,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
user: user,
|
user: user,
|
||||||
contact: contact
|
contact: contact
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(account.conversations.count).to eq(conversation_count + 1)
|
expect(call.conversation.identifier).to be_nil
|
||||||
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
|
||||||
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises an error when contact is missing a phone number' do
|
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.to raise_error(ArgumentError, 'Agent required')
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ describe Voice::Provider::Twilio::ConferenceService do
|
|||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||||
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
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(: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) }
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
@@ -14,42 +23,37 @@ describe Voice::Provider::Twilio::ConferenceService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#ensure_conference_sid' do
|
describe '#ensure_conference_sid' do
|
||||||
it 'returns existing sid if present' do
|
it 'returns existing sid if present on the Call' do
|
||||||
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
|
call.update!(conference_sid: 'CF_EXISTING')
|
||||||
|
|
||||||
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
|
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets and returns generated sid when missing' do
|
it 'sets and returns generated sid when missing' do
|
||||||
allow(Voice::Conference::Name).to receive(:for).and_return('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}")
|
||||||
sid = service.ensure_conference_sid
|
|
||||||
|
|
||||||
expect(sid).to eq('CF_GEN')
|
|
||||||
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#mark_agent_joined' do
|
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)
|
agent = create(:user, account: account)
|
||||||
|
|
||||||
service.mark_agent_joined(user: agent)
|
service.mark_agent_joined(user: agent)
|
||||||
|
|
||||||
attrs = conversation.reload.additional_attributes
|
expect(call.reload.accepted_by_agent_id).to eq(agent.id)
|
||||||
expect(attrs['agent_joined']).to be true
|
|
||||||
expect(attrs['joined_by']['id']).to eq(agent.id)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#end_conference' do
|
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)
|
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
|
||||||
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
|
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
|
||||||
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
|
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
|
||||||
|
|
||||||
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
|
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(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
|
||||||
allow(conf_context).to receive(:update).with(status: 'completed')
|
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')
|
expect(conf_context).to have_received(:update).with(status: 'completed')
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,43 +4,47 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Voice::StatusUpdateService do
|
RSpec.describe Voice::StatusUpdateService do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||||
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
|
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
|
let(:conversation) do
|
||||||
Conversation.create!(
|
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||||
account_id: account.id,
|
end
|
||||||
inbox_id: inbox.id,
|
let!(:call) do
|
||||||
contact_id: contact.id,
|
create(
|
||||||
contact_inbox_id: contact_inbox.id,
|
:call,
|
||||||
identifier: call_sid,
|
account: account,
|
||||||
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
|
inbox: inbox,
|
||||||
|
conversation: conversation,
|
||||||
|
contact: contact,
|
||||||
|
provider_call_id: call_sid
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
let(:message) do
|
let!(:message) do
|
||||||
conversation.messages.create!(
|
msg = conversation.messages.create!(
|
||||||
account_id: account.id,
|
account_id: account.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
message_type: :incoming,
|
message_type: :incoming,
|
||||||
sender: contact,
|
sender: contact,
|
||||||
content: 'Voice Call',
|
content: 'Voice Call',
|
||||||
content_type: '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
|
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
|
before do
|
||||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates conversation and last voice message with call status' do
|
it 'updates the Call and touches the linked message on status transition' do
|
||||||
# Ensure records are created after stub setup
|
previous_updated_at = message.updated_at
|
||||||
conversation
|
travel 1.second
|
||||||
message
|
|
||||||
|
|
||||||
described_class.new(
|
described_class.new(
|
||||||
account: account,
|
account: account,
|
||||||
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
|
|||||||
call_status: 'completed'
|
call_status: 'completed'
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
conversation.reload
|
call.reload
|
||||||
message.reload
|
message.reload
|
||||||
|
|
||||||
expect(conversation.additional_attributes['call_status']).to eq('completed')
|
expect(call.status).to eq('completed')
|
||||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
expect(message.updated_at).to be > previous_updated_at
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'normalizes busy to no-answer' do
|
it 'normalizes busy to no_answer on the Call' do
|
||||||
conversation
|
|
||||||
message
|
|
||||||
|
|
||||||
described_class.new(
|
described_class.new(
|
||||||
account: account,
|
account: account,
|
||||||
call_sid: call_sid,
|
call_sid: call_sid,
|
||||||
call_status: 'busy'
|
call_status: 'busy'
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
conversation.reload
|
expect(call.reload.status).to eq('no_answer')
|
||||||
message.reload
|
|
||||||
|
|
||||||
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
|
||||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'no-ops when conversation not found' do
|
it 'no-ops when no Call matches the provided call_sid' do
|
||||||
expect do
|
expect do
|
||||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||||
end.not_to raise_error
|
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
|
||||||
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
|
context 'when mentioned user is not an inbox member' do
|
||||||
let!(:non_member_user) { create(:user, account: account) }
|
let!(:non_member_user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,6 @@
|
|||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.1",
|
"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
|
specifier: ^8.59.1
|
||||||
version: 8.59.1(eslint@9.39.4)(typescript@6.0.3)
|
version: 8.59.1(eslint@9.39.4)(typescript@6.0.3)
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^13.0.0
|
specifier: ^14.0.0
|
||||||
version: 13.0.0
|
version: 14.0.0
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -645,8 +645,8 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||||
|
|
||||||
uuid@13.0.0:
|
uuid@14.0.0:
|
||||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
@@ -1234,7 +1234,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
uuid@13.0.0: {}
|
uuid@14.0.0: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user