Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce782409c7 | ||
|
|
092d59333c | ||
|
|
1bcc25f95c | ||
|
|
442631102c | ||
|
|
78c615fe42 | ||
|
|
907011e280 | ||
|
|
a766ebf642 | ||
|
|
0e0e0868d7 | ||
|
|
d9077c64d3 | ||
|
|
1abb51992b | ||
|
|
0282156821 | ||
|
|
257a9129d7 | ||
|
|
1ce316efc7 | ||
|
|
ba94d8c2b0 | ||
|
|
6e11bebeae | ||
|
|
069f25f88a | ||
|
|
63c193358c | ||
|
|
7663e2ac30 | ||
|
|
0567655840 | ||
|
|
c25ed094fa | ||
|
|
112a8f9e9e | ||
|
|
723d416ce3 | ||
|
|
f3df486ce6 | ||
|
|
c7b6a87e77 | ||
|
|
19d91e47f1 | ||
|
|
1c945899c7 | ||
|
|
2c025755e2 | ||
|
|
0618e6e229 | ||
|
|
cbef2d4e66 | ||
|
|
cb93aba55c | ||
|
|
beef43f2bf | ||
|
|
cbd3f759b5 | ||
|
|
a7fdc45977 | ||
|
|
41e3ea0d87 | ||
|
|
fd22017292 | ||
|
|
555894793e | ||
|
|
7c87d3d150 | ||
|
|
c37d1b1d6b | ||
|
|
e5a03d08ea | ||
|
|
bc80985f55 | ||
|
|
83cd568ca2 | ||
|
|
f32a7b3f77 | ||
|
|
ba0fcbfc7f | ||
|
|
546193de2a | ||
|
|
67b522c433 | ||
|
|
f5eca9d1d0 | ||
|
|
5c4db3a6bc | ||
|
|
792dc359d4 | ||
|
|
2b0aaa237f | ||
|
|
48022833ea | ||
|
|
e2b3965a08 | ||
|
|
17e25fa3c4 | ||
|
|
37963cc654 | ||
|
|
c7f5277277 | ||
|
|
1124c1b4c2 | ||
|
|
cd9c8e3303 | ||
|
|
80fccbc526 | ||
|
|
bcdb73502e | ||
|
|
d634ced4e9 | ||
|
|
e723c6b6f2 | ||
|
|
5325e05143 | ||
|
|
c09206c22e | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
6aeda0ddf6 | ||
|
|
fed32d5964 | ||
|
|
735bc73c96 | ||
|
|
5e79dd699e | ||
|
|
05dd31389e | ||
|
|
f7bbd40816 | ||
|
|
7820739f3a |
@@ -22,6 +22,7 @@ gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
+5
-3
@@ -108,8 +108,8 @@ GEM
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
@@ -349,6 +349,7 @@ GEM
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
gemoji (4.1.0)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -676,7 +677,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -1075,6 +1076,7 @@ DEPENDENCIES
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
|
||||
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||
}
|
||||
end
|
||||
|
||||
def active_platform_banners
|
||||
return [] unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
|
||||
end
|
||||
|
||||
def allowed_login_methods
|
||||
methods = ['email']
|
||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
|
||||
before_action :ensure_chatwoot_cloud
|
||||
|
||||
private
|
||||
|
||||
def ensure_chatwoot_cloud
|
||||
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require 'administrate/base_dashboard'
|
||||
|
||||
class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
banner_message: Field::Text.with_options(truncate: 200),
|
||||
banner_type: Field::Select.with_options(collection: %w[info warning error]),
|
||||
active: Field::Boolean,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
end
|
||||
@@ -11,9 +11,7 @@ class MessageFinder
|
||||
private
|
||||
|
||||
def conversation_messages
|
||||
scope = @conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
|
||||
scope = scope.includes(call: [:contact, { inbox: :channel }]) if Message.reflect_on_association(:call)
|
||||
scope
|
||||
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def messages
|
||||
@@ -50,3 +48,5 @@ class MessageFinder
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
MessageFinder.prepend_mod_with('MessageFinder')
|
||||
|
||||
@@ -7,7 +7,7 @@ module EmailHelper
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import StatusBanner from './components/app/StatusBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -27,6 +28,7 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
StatusBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
@@ -137,6 +139,7 @@ export default {
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<StatusBanner />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class VoiceCallsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('voice_calls', { accountScoped: true });
|
||||
}
|
||||
|
||||
show(callId) {
|
||||
return axios.get(`${this.url}/${callId}`);
|
||||
}
|
||||
|
||||
// The browser does WebRTC locally and ships the SDP answer up. Rails forwards
|
||||
// it to Meta via pre_accept_call+accept_call.
|
||||
accept(callId, { sdpAnswer } = {}) {
|
||||
return axios.post(`${this.url}/${callId}/accept`, {
|
||||
sdp_answer: sdpAnswer,
|
||||
});
|
||||
}
|
||||
|
||||
reject(callId) {
|
||||
return axios.post(`${this.url}/${callId}/reject`);
|
||||
}
|
||||
|
||||
terminate(callId) {
|
||||
return axios.post(`${this.url}/${callId}/terminate`);
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer first; Rails ships it to Meta and
|
||||
// creates the Call record. Meta delivers its SDP answer later via the
|
||||
// connect webhook (broadcast over ActionCable as voice_call.outbound_connected).
|
||||
initiate(conversationId, provider, { sdpOffer } = {}) {
|
||||
return axios.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
provider,
|
||||
sdp_offer: sdpOffer,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the current agent's active call (if any). Used on page load to detect
|
||||
// a stale active session so we can terminate it cleanly.
|
||||
active() {
|
||||
return axios.get(`${this.url}/active`);
|
||||
}
|
||||
|
||||
// Multipart upload of the in-browser MediaRecorder Blob to Rails. The
|
||||
// controller attaches it to the call's voice_call message; the after-create
|
||||
// hook on Attachment fires Messages::AudioTranscriptionJob automatically.
|
||||
uploadRecording(callId, blob, filename) {
|
||||
const fd = new FormData();
|
||||
fd.append('recording', blob, filename);
|
||||
return axios.post(`${this.url}/${callId}/upload_recording`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for navigator.sendBeacon (which can't carry custom headers and
|
||||
// needs the absolute account-scoped path).
|
||||
uploadRecordingUrl(callId) {
|
||||
return `${this.url}/${callId}/upload_recording`;
|
||||
}
|
||||
|
||||
terminateUrl(callId) {
|
||||
return `${this.url}/${callId}/terminate`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceCallsAPI();
|
||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -3,10 +3,10 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -34,12 +34,11 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const isInitiatingCall = computed(() => {
|
||||
@@ -67,16 +66,15 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: response.call_id,
|
||||
callId: response.call_sid,
|
||||
provider: response.provider || 'twilio',
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: response.conversation_id,
|
||||
caller: null,
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
|
||||
+10
-4
@@ -35,10 +35,16 @@ const emit = defineEmits([
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
|
||||
+2
-6
@@ -81,13 +81,9 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const voiceCallEnabled = computed(() => props.voiceEnabled);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!voiceCallEnabled.value
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||
);
|
||||
});
|
||||
|
||||
@@ -112,7 +108,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !voiceCallEnabled.value) setSignature();
|
||||
if (newValue && !props.voiceEnabled) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
slate:
|
||||
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||
amber:
|
||||
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { acceptVoiceCallById } from 'dashboard/composables/useVoiceCallSession';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -33,65 +30,14 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const { contentAttributes, messageType, attachments } = useMessageContext();
|
||||
const { call } = useMessageContext();
|
||||
|
||||
// NOTE: contentAttributes.data keys are camelCase because MessageList.vue
|
||||
// applies useCamelCase(messages, { deep: true }) before rendering.
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
// Call source and metadata — all camelCase due to deep transform
|
||||
const callSource = computed(() => data.value?.callSource);
|
||||
const isVoiceCall = computed(() =>
|
||||
['whatsapp', 'twilio'].includes(callSource.value)
|
||||
);
|
||||
const callId = computed(() => data.value?.callId);
|
||||
const acceptedBy = computed(() => data.value?.acceptedBy);
|
||||
const durationSeconds = computed(() => data.value?.durationSeconds);
|
||||
|
||||
// Recording and transcript live on the first audio attachment. After the
|
||||
// call ends, CallRecordingFetchJob creates an Attachment on this message;
|
||||
// Messages::AudioTranscriptionService fills in `transcribedText` when ready.
|
||||
const audioAttachment = computed(
|
||||
() => attachments.value?.find(a => a.fileType === 'audio') || null
|
||||
);
|
||||
const recordingUrl = computed(() => audioAttachment.value?.dataUrl);
|
||||
const transcript = computed(() => audioAttachment.value?.transcribedText);
|
||||
const isJoining = ref(false);
|
||||
const isRejecting = ref(false);
|
||||
const showTranscript = ref(false);
|
||||
|
||||
// Reject is shown only for ringing inbound calls — outbound calls and
|
||||
// in-progress calls don't need a reject button (initiator hangs up via the
|
||||
// floating widget).
|
||||
const showRejectButton = computed(
|
||||
() =>
|
||||
isVoiceCall.value &&
|
||||
!isOutbound.value &&
|
||||
status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const seconds = durationSeconds.value;
|
||||
if (!seconds || seconds <= 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||
});
|
||||
|
||||
// Show join/accept button logic
|
||||
// Direct browser↔Meta WebRTC has no rejoin path (Meta caches the prior
|
||||
// DTLS fingerprint), so only ringing calls show the Accept button.
|
||||
const showJoinButton = computed(
|
||||
() => isVoiceCall.value && status.value === VOICE_CALL_STATUS.RINGING
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
@@ -105,19 +51,6 @@ const labelKey = computed(() => {
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
// acceptedBy on outbound calls is the initiator, not the contact — so keep
|
||||
// "They answered" instead of "Answered by <agent>". Only inbound bubbles
|
||||
// should suppress the subtext in favor of the acceptedBy line.
|
||||
if (
|
||||
!isOutbound.value &&
|
||||
acceptedBy.value?.name &&
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS, VOICE_CALL_STATUS.COMPLETED].includes(
|
||||
status.value
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
@@ -129,50 +62,12 @@ const subtextKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const answeredByText = computed(() => {
|
||||
if (isOutbound.value) return '';
|
||||
if (!acceptedBy.value?.name) return '';
|
||||
return acceptedBy.value.name;
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (isJoining.value || !isVoiceCall.value) return;
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const result = await acceptVoiceCallById(callId.value);
|
||||
if (result?.success && result.call) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: result.call.conversationId },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Accept from bubble failed:', err);
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectCall = async () => {
|
||||
if (isRejecting.value || !callId.value) return;
|
||||
isRejecting.value = true;
|
||||
try {
|
||||
await VoiceCallsAPI.reject(callId.value);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Reject from bubble failed:', err);
|
||||
} finally {
|
||||
isRejecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -193,90 +88,14 @@ const handleRejectCall = async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow gap-0.5">
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span v-if="answeredByText" class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('CONVERSATION.VOICE_CALL.ANSWERED_BY', {
|
||||
name: answeredByText,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else-if="subtextKey" class="text-xs text-n-slate-11">
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formattedDuration && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showRejectButton"
|
||||
:disabled="isRejecting"
|
||||
:title="$t('WHATSAPP_CALL.REJECT')"
|
||||
class="flex justify-center items-center w-8 h-8 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isRejecting }"
|
||||
@click="handleRejectCall"
|
||||
>
|
||||
<i
|
||||
v-if="isRejecting"
|
||||
class="i-ph-circle-notch-bold text-sm text-white animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-x-bold text-sm text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showJoinButton"
|
||||
:disabled="isJoining"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-n-teal-9 hover:bg-n-teal-10 rounded-lg transition-colors shrink-0"
|
||||
:class="{ 'opacity-75 cursor-wait': isJoining }"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<i
|
||||
v-if="isJoining"
|
||||
class="i-ph-circle-notch-bold text-sm animate-spin"
|
||||
/>
|
||||
<i v-else class="i-ph-phone-bold text-sm" />
|
||||
{{ $t('CONVERSATION.VOICE_CALL.ACCEPT_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recordingUrl && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-2"
|
||||
>
|
||||
<audio controls class="w-full h-8" :src="recordingUrl">
|
||||
{{ $t('CONVERSATION.VOICE_CALL.AUDIO_NOT_SUPPORTED') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transcript && status === VOICE_CALL_STATUS.COMPLETED"
|
||||
class="px-3 pb-3"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 text-xs text-n-slate-11 hover:text-n-slate-12 transition-colors"
|
||||
@click="showTranscript = !showTranscript"
|
||||
>
|
||||
<i
|
||||
class="text-sm"
|
||||
:class="
|
||||
showTranscript ? 'i-ph-caret-up-bold' : 'i-ph-caret-down-bold'
|
||||
"
|
||||
/>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.TRANSCRIPT') }}
|
||||
</button>
|
||||
<p
|
||||
v-if="showTranscript"
|
||||
class="mt-1 text-xs leading-relaxed text-n-slate-11 whitespace-pre-wrap"
|
||||
>
|
||||
{{ transcript }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const BANNER_COLOR_MAP = {
|
||||
info: 'blue',
|
||||
warning: 'amber',
|
||||
error: 'ruby',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const dismissedBannerIds = ref(
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
|
||||
);
|
||||
|
||||
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
|
||||
|
||||
const visibleBanners = computed(() => {
|
||||
const banners = globalConfig.value?.activePlatformBanners || [];
|
||||
return banners.filter(
|
||||
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
|
||||
);
|
||||
});
|
||||
|
||||
const formattedMessage = message =>
|
||||
new MessageFormatter(message).formattedMessage;
|
||||
|
||||
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
|
||||
|
||||
const dismissBanner = banner => {
|
||||
const key = dismissKey(banner);
|
||||
if (dismissedBannerIds.value.includes(key)) return;
|
||||
|
||||
dismissedBannerIds.value.push(key);
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
|
||||
dismissedBannerIds.value
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-for="banner in visibleBanners"
|
||||
:key="banner.id"
|
||||
:color="bannerColor(banner.banner_type)"
|
||||
:action-label="t('GENERAL_SETTINGS.DISMISS')"
|
||||
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
|
||||
@action="dismissBanner(banner)"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="formattedMessage(banner.banner_message)"
|
||||
class="text-xs"
|
||||
/>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useVoiceCallSession } from 'dashboard/composables/useVoiceCallSession';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
} = useVoiceCallSession();
|
||||
|
||||
// In server-relay mode, the timer starts when the Peer B WebRTC handshake
|
||||
// completes (not when the agent clicks accept). Listen for this event.
|
||||
const onAgentWebRTCConnected = () => {
|
||||
startDurationTimer();
|
||||
};
|
||||
|
||||
const onPermissionGranted = ({ contactName }) => {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.PERMISSION_GRANTED', { contactName }),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.on('voice_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
|
||||
// Auto-dismiss ringing calls after 30 seconds
|
||||
const autoRejectTimers = new Map();
|
||||
|
||||
const startAutoRejectTimer = call => {
|
||||
if (autoRejectTimers.has(call.callId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
dismissIncomingCall(call);
|
||||
autoRejectTimers.delete(call.callId);
|
||||
}, 30000);
|
||||
autoRejectTimers.set(call.callId, timer);
|
||||
};
|
||||
|
||||
const clearAutoRejectTimer = callId => {
|
||||
const timer = autoRejectTimers.get(callId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
autoRejectTimers.delete(callId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async call => {
|
||||
clearAutoRejectTimer(call.callId);
|
||||
await acceptCall(call);
|
||||
if (activeCall.value) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async call => {
|
||||
clearAutoRejectTimer(call.callId);
|
||||
await rejectCall(call);
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
await endActiveCall();
|
||||
};
|
||||
|
||||
// Start auto-reject timers for each newly added incoming call
|
||||
watch(
|
||||
incomingCalls,
|
||||
calls => {
|
||||
calls.forEach(call => startAutoRejectTimer(call));
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
autoRejectTimers.forEach(timer => clearTimeout(timer));
|
||||
autoRejectTimers.clear();
|
||||
emitter.off('voice_call:agent_webrtc_connected', onAgentWebRTCConnected);
|
||||
emitter.off('voice_call:permission_granted', onPermissionGranted);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasIncomingCall || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-20 z-50 flex flex-col gap-2 w-72"
|
||||
>
|
||||
<!-- Error banner -->
|
||||
<div
|
||||
v-if="callError"
|
||||
class="px-3 py-2 bg-n-ruby-3 border border-n-ruby-6 rounded-lg text-xs text-n-ruby-11"
|
||||
>
|
||||
{{ callError }}
|
||||
</div>
|
||||
|
||||
<!-- Incoming calls (shown when there's no active call yet) -->
|
||||
<template v-if="!hasActiveCall">
|
||||
<div
|
||||
v-for="call in incomingCalls"
|
||||
:key="call.callId"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="call.caller?.avatar"
|
||||
:name="call.caller?.name || call.caller?.phone"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{
|
||||
call.caller?.name ||
|
||||
call.caller?.phone ||
|
||||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{
|
||||
call.provider === 'twilio'
|
||||
? 'Incoming voice call'
|
||||
: t('WHATSAPP_CALL.INCOMING_WHATSAPP_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
:title="t('WHATSAPP_CALL.REJECT')"
|
||||
@click="handleReject(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
:disabled="isAccepting"
|
||||
:title="t('WHATSAPP_CALL.ACCEPT')"
|
||||
@click="handleAccept(call)"
|
||||
>
|
||||
<i
|
||||
v-if="isAccepting"
|
||||
class="text-lg text-white i-ph-circle-notch animate-spin"
|
||||
/>
|
||||
<i v-else class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Active call widget -->
|
||||
<div
|
||||
v-if="hasActiveCall"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': isOutboundRinging }"
|
||||
>
|
||||
<Avatar
|
||||
:src="activeCall.caller?.avatar"
|
||||
:name="activeCall.caller?.name || activeCall.caller?.phone"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{
|
||||
activeCall.caller?.name ||
|
||||
activeCall.caller?.phone ||
|
||||
t('WHATSAPP_CALL.UNKNOWN_CALLER')
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm"
|
||||
:class="
|
||||
isOutboundRinging ? 'text-n-slate-11' : 'font-mono text-n-teal-9'
|
||||
"
|
||||
>
|
||||
<template v-if="isOutboundRinging">
|
||||
{{ t('WHATSAPP_CALL.RINGING') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formattedCallDuration }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<!-- Mute toggle -->
|
||||
<button
|
||||
class="flex justify-center items-center w-9 h-9 rounded-full transition-colors"
|
||||
:class="
|
||||
isMuted
|
||||
? 'bg-n-amber-9 hover:bg-n-amber-10'
|
||||
: 'bg-n-slate-4 hover:bg-n-slate-5'
|
||||
"
|
||||
:title="isMuted ? t('WHATSAPP_CALL.UNMUTE') : t('WHATSAPP_CALL.MUTE')"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
class="text-base text-white"
|
||||
:class="
|
||||
isMuted ? 'i-ph-microphone-slash-bold' : 'i-ph-microphone-bold'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
<!-- Hang up -->
|
||||
<button
|
||||
class="flex justify-center items-center w-9 h-9 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
:title="t('WHATSAPP_CALL.HANG_UP')"
|
||||
@click="handleEndCall"
|
||||
>
|
||||
<i class="text-base text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
collapseSelection,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
insertAtCursor,
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
@@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) {
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
@@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (isEscape(event)) {
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
if (isEnterToSendEnabled()) {
|
||||
handleLineBreakWhenEnterToSendEnabled(event);
|
||||
}
|
||||
if (isCmdPlusEnterToSendEnabled()) {
|
||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
@@ -744,6 +753,9 @@ function createEditorView() {
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
// PM keeps its selection on blur — clear the menu flags manually.
|
||||
isTextSelected.value = false;
|
||||
editorRoot.value?.classList.remove('has-selection');
|
||||
emit('blur');
|
||||
},
|
||||
paste: (view, event) => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
import { collapseSelection } from 'dashboard/helper/editorHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
@@ -362,19 +364,33 @@ export default {
|
||||
onKeyup() {
|
||||
this.$emit('keyup');
|
||||
},
|
||||
onKeydown() {
|
||||
onKeydown(view, event) {
|
||||
this.$emit('keydown');
|
||||
if (isEscape(event)) {
|
||||
if (this.showSlashMenu) {
|
||||
this.showSlashMenu = false;
|
||||
this.slashSearchTerm = '';
|
||||
this.slashMenuPosition = null;
|
||||
return true;
|
||||
}
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onBlur() {
|
||||
// ProseMirror keeps its selection on blur — clear the menu flag manually.
|
||||
this.isTextSelected = false;
|
||||
this.$refs.editor?.classList.remove('has-selection');
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
|
||||
@@ -13,14 +13,6 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import {
|
||||
prepareOutboundOffer,
|
||||
cleanupInboundWebRTC,
|
||||
} from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
@@ -38,10 +30,7 @@ const store = useStore();
|
||||
const route = useRoute();
|
||||
const conversationHeader = ref(null);
|
||||
const { width } = useElementSize(conversationHeader);
|
||||
const { isAWebWidgetInbox, isAWhatsAppCloudChannel, voiceCallEnabled } =
|
||||
useInbox();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const isInitiatingCall = ref(false);
|
||||
const { isAWebWidgetInbox } = useInbox();
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
@@ -102,99 +91,6 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
// Returns the provider to use for an outbound call, based on the inbox
|
||||
// channel type. WhatsApp Cloud inboxes use :whatsapp; Twilio Voice inboxes
|
||||
// use :twilio. Returns null if outbound calling isn't supported on this
|
||||
// channel.
|
||||
const callProvider = computed(() => {
|
||||
if (isAWhatsAppCloudChannel.value && inbox.value?.calling_enabled) {
|
||||
return 'whatsapp';
|
||||
}
|
||||
if (voiceCallEnabled.value) {
|
||||
return 'twilio';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const canInitiateVoiceCall = computed(() => {
|
||||
if (!callProvider.value) return false;
|
||||
if (voiceCallsStore.hasVoiceCall) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Outbound call initiation. WhatsApp now runs direct browser ↔ Meta WebRTC:
|
||||
// the browser builds the SDP offer locally before any network call, so Meta
|
||||
// gets the agent's real fingerprint and the eventual answer applies cleanly
|
||||
// in the cable handler. Twilio still POSTs first and waits for the
|
||||
// synchronous agent_offer in the response (its OutboundCallBuilder produces
|
||||
// the SDP server-side). On 138006 (no call permission) we tear down the local
|
||||
// PC immediately so the mic light goes off while the contact's phone shows
|
||||
// the consent prompt.
|
||||
const initiateWhatsappCall = async () => {
|
||||
if (isInitiatingCall.value || !currentChat.value?.id || !callProvider.value)
|
||||
return;
|
||||
isInitiatingCall.value = true;
|
||||
const isWhatsApp = callProvider.value === 'whatsapp';
|
||||
|
||||
try {
|
||||
let sdpOffer = null;
|
||||
if (isWhatsApp) {
|
||||
sdpOffer = await prepareOutboundOffer({});
|
||||
}
|
||||
|
||||
const response = await VoiceCallsAPI.initiate(
|
||||
currentChat.value.id,
|
||||
callProvider.value,
|
||||
isWhatsApp ? { sdpOffer } : {}
|
||||
);
|
||||
|
||||
const callStatus = response.data?.status;
|
||||
if (
|
||||
callStatus === 'permission_requested' ||
|
||||
callStatus === 'permission_pending'
|
||||
) {
|
||||
// Tear down the local PC; we'll rebuild from scratch on retry.
|
||||
cleanupInboundWebRTC();
|
||||
const message =
|
||||
callStatus === 'permission_requested'
|
||||
? t('WHATSAPP_CALL.PERMISSION_REQUESTED')
|
||||
: t('WHATSAPP_CALL.PERMISSION_PENDING');
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, { message, type: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: t('WHATSAPP_CALL.CALLING'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
const callData = response.data || {};
|
||||
voiceCallsStore.setActiveCall({
|
||||
id: callData.id,
|
||||
callId: callData.call_id,
|
||||
provider: callData.provider || callProvider.value,
|
||||
direction: 'outbound',
|
||||
status: 'ringing',
|
||||
conversationId: currentChat.value.id,
|
||||
caller: {
|
||||
name: currentContact.value?.name,
|
||||
phone: currentContact.value?.phone_number,
|
||||
avatar: currentContact.value?.thumbnail,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
const errorMessage =
|
||||
err.response?.data?.error || t('WHATSAPP_CALL.CALL_FAILED');
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: errorMessage,
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
isInitiatingCall.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -256,19 +152,6 @@ const initiateWhatsappCall = async () => {
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<button
|
||||
v-if="canInitiateVoiceCall"
|
||||
v-tooltip="$t('WHATSAPP_CALL.INITIATE_CALL')"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg text-n-slate-11 hover:text-n-slate-12 hover:bg-n-slate-3 transition-colors"
|
||||
:disabled="isInitiatingCall"
|
||||
@click="initiateWhatsappCall"
|
||||
>
|
||||
<i
|
||||
v-if="isInitiatingCall"
|
||||
class="text-base i-ph-circle-notch animate-spin"
|
||||
/>
|
||||
<i v-else class="text-base i-ph-phone-bold" />
|
||||
</button>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
/**
|
||||
* Detects a stale active call on page load and cleans it up. Direct
|
||||
* browser ↔ Meta WebRTC has no rejoin path — Meta caches the prior DTLS
|
||||
* fingerprint, so a fresh PC can never re-bind to the existing call leg.
|
||||
* Best we can do is terminate the stranded Call so the agent isn't shown
|
||||
* "in a call" forever.
|
||||
*/
|
||||
export function useCallReconnection() {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(async () => {
|
||||
if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return;
|
||||
|
||||
try {
|
||||
const { data } = await VoiceCallsAPI.active();
|
||||
if (!data?.call && !data?.id) return;
|
||||
|
||||
const activeCallData = data.call || data;
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(activeCallData.id);
|
||||
} catch {
|
||||
// best-effort — Rails-side cleanup
|
||||
}
|
||||
useAlert(t('WHATSAPP_CALL.RELOAD_ENDED_CALL'));
|
||||
} catch {
|
||||
callsStore.clearActiveCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import VoiceCallsAPI from 'dashboard/api/voiceCalls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Module-level WebRTC + recorder state. One active peer per browser tab.
|
||||
// `endActiveCall` and `pagehide` race for finalize duty; `intentionallyClosing`
|
||||
// disambiguates so beforeunload doesn't warn during a clean hangup.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
let inboundPc = null;
|
||||
let inboundStream = null;
|
||||
let inboundAudio = null;
|
||||
|
||||
let audioContext = null;
|
||||
let mediaRecorder = null;
|
||||
let recordingMime = null;
|
||||
let recordingChunks = [];
|
||||
let recordingFilename = null;
|
||||
let intentionallyClosing = false;
|
||||
|
||||
const RECORDER_MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
];
|
||||
|
||||
const DEFAULT_ICE = [{ urls: 'stun:stun.l.google.com:19302' }];
|
||||
|
||||
function pickRecorderMime() {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
return (
|
||||
RECORDER_MIME_CANDIDATES.find(t => MediaRecorder.isTypeSupported(t)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function teardownRecorder() {
|
||||
mediaRecorder = null;
|
||||
recordingChunks = [];
|
||||
recordingMime = null;
|
||||
recordingFilename = null;
|
||||
if (audioContext) {
|
||||
audioContext.close().catch(() => {});
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFromMime(mime) {
|
||||
if (!mime) return 'webm';
|
||||
if (mime.startsWith('audio/webm')) return 'webm';
|
||||
if (mime.startsWith('audio/ogg')) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
// Mix the agent's mic and Meta's remote audio into a single stream so the
|
||||
// resulting Blob is a real conversation rather than two interleaved channels.
|
||||
// Web Audio handles the mix; we never apply gain (raw levels matter for
|
||||
// Whisper's speech-detection floor).
|
||||
function setupRecorder(localStream, remoteStream, callId, providerCallId) {
|
||||
if (mediaRecorder || !localStream || !remoteStream) return;
|
||||
const mime = pickRecorderMime();
|
||||
if (!mime) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[Voice Call] No supported MediaRecorder MIME — skipping recording'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 48000,
|
||||
});
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
const localSource = audioContext.createMediaStreamSource(localStream);
|
||||
const remoteSource = audioContext.createMediaStreamSource(remoteStream);
|
||||
const mixDest = audioContext.createMediaStreamDestination();
|
||||
localSource.connect(mixDest);
|
||||
remoteSource.connect(mixDest);
|
||||
|
||||
recordingMime = mime;
|
||||
recordingChunks = [];
|
||||
recordingFilename = `call_${callId}_${providerCallId || callId}.${extensionFromMime(mime)}`;
|
||||
|
||||
mediaRecorder = new MediaRecorder(mixDest.stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = event => {
|
||||
if (event.data && event.data.size) recordingChunks.push(event.data);
|
||||
};
|
||||
// 5s timeslice keeps the in-memory blob fresh enough for the pagehide
|
||||
// beacon to capture most of the call if the user closes the tab.
|
||||
mediaRecorder.start(5000);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recorder setup failed:', err);
|
||||
teardownRecorder();
|
||||
}
|
||||
}
|
||||
|
||||
// stop() returns nothing useful; the final dataavailable fires on the next
|
||||
// task and onstop fires after that. Wait for onstop, then assemble the blob.
|
||||
async function stopRecorderAndGetBlob() {
|
||||
if (!mediaRecorder) return null;
|
||||
const mr = mediaRecorder;
|
||||
if (mr.state === 'inactive') {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
teardownRecorder();
|
||||
return { blob, filename: recordingFilename };
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
const cleanup = () => {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const filename = recordingFilename;
|
||||
teardownRecorder();
|
||||
resolve({ blob, filename });
|
||||
};
|
||||
mr.onstop = cleanup;
|
||||
try {
|
||||
mr.stop();
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupInboundWebRTC() {
|
||||
// Stop recorder first so the source nodes still see live tracks during
|
||||
// flush. Any awaited blob is the caller's problem (endActiveCall does it).
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
teardownRecorder();
|
||||
|
||||
if (inboundStream) {
|
||||
inboundStream.getTracks().forEach(track => track.stop());
|
||||
inboundStream = null;
|
||||
}
|
||||
if (inboundPc) {
|
||||
inboundPc.close();
|
||||
inboundPc = null;
|
||||
}
|
||||
if (inboundAudio) {
|
||||
inboundAudio.srcObject = null;
|
||||
if (inboundAudio.parentNode) {
|
||||
inboundAudio.parentNode.removeChild(inboundAudio);
|
||||
}
|
||||
inboundAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(pc) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = null;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
pc.onicegatheringstatechange = null;
|
||||
pc.oniceconnectionstatechange = null;
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] ICE gathering timed out, sending partial SDP');
|
||||
resolve();
|
||||
}, 10000);
|
||||
|
||||
pc.onicegatheringstatechange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'failed') {
|
||||
cleanup();
|
||||
reject(new Error('ICE connection failed'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachRemoteTrackHandler(pc, callId, providerCallId) {
|
||||
pc.ontrack = event => {
|
||||
let remoteStream = event.streams && event.streams[0];
|
||||
if (!remoteStream) {
|
||||
if (!event.track) return;
|
||||
remoteStream = new MediaStream([event.track]);
|
||||
}
|
||||
if (!inboundAudio) {
|
||||
const audio = document.createElement('audio');
|
||||
audio.autoplay = true;
|
||||
document.body.appendChild(audio);
|
||||
inboundAudio = audio;
|
||||
}
|
||||
inboundAudio.srcObject = remoteStream;
|
||||
inboundAudio.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] audio.play() rejected:', err);
|
||||
});
|
||||
if (inboundStream) {
|
||||
setupRecorder(inboundStream, remoteStream, callId, providerCallId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Inbound: browser receives Meta's SDP offer via the voice_call.incoming
|
||||
// cable event, opens its mic, builds the answer, and ships it back so Rails
|
||||
// can hand it to Meta. After this returns, the agent and Meta are mid-DTLS.
|
||||
async function prepareInboundAnswer({
|
||||
callId,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Outbound: browser builds the offer locally, before any network call. Rails
|
||||
// hands the offer to Meta via initiate_call; we receive Meta's SDP answer
|
||||
// later via the voice_call.outbound_connected cable event.
|
||||
async function prepareOutboundOffer({
|
||||
callId,
|
||||
providerCallId,
|
||||
iceServers,
|
||||
} = {}) {
|
||||
cleanupInboundWebRTC();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
inboundStream = stream;
|
||||
|
||||
const servers = iceServers?.length ? iceServers : DEFAULT_ICE;
|
||||
const pc = new RTCPeerConnection({ iceServers: servers });
|
||||
inboundPc = pc;
|
||||
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
attachRemoteTrackHandler(pc, callId, providerCallId);
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: true });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc);
|
||||
|
||||
return pc.localDescription.sdp;
|
||||
}
|
||||
|
||||
// Apply Meta's SDP answer to the existing outbound RTCPeerConnection. Called
|
||||
// from the actionCable handler when voice_call.outbound_connected lands.
|
||||
async function applyOutboundAnswer(sdpAnswer) {
|
||||
if (!inboundPc) throw new Error('No active outbound RTCPeerConnection');
|
||||
if (inboundPc.signalingState !== 'have-local-offer') {
|
||||
// Already applied — duplicate cable delivery. No-op rather than detonate.
|
||||
return;
|
||||
}
|
||||
await inboundPc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
|
||||
// Some browsers fire ontrack synchronously inside setRemoteDescription;
|
||||
// others delay it. If we already have a remote stream, kick the recorder.
|
||||
if (inboundAudio?.srcObject && inboundStream) {
|
||||
setupRecorder(inboundStream, inboundAudio.srcObject, undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export { prepareOutboundOffer, applyOutboundAnswer, cleanupInboundWebRTC };
|
||||
|
||||
// Standalone for the message bubble's Accept button. Branches on whether the
|
||||
// store has an SDP offer (WhatsApp direct mode = always yes).
|
||||
export async function acceptVoiceCallById(callId) {
|
||||
const callsStore = useVoiceCallsStore();
|
||||
|
||||
if (callsStore.hasActiveCall) {
|
||||
return { success: false, error: 'active_call_exists' };
|
||||
}
|
||||
|
||||
let call = callsStore.incomingCalls.find(
|
||||
c => c.id === callId || c.callId === String(callId)
|
||||
);
|
||||
|
||||
let sdpOffer = call?.sdpOffer;
|
||||
let iceServers = call?.iceServers;
|
||||
let providerCallId = call?.callId;
|
||||
|
||||
if (!sdpOffer) {
|
||||
const { data } = await VoiceCallsAPI.show(callId);
|
||||
if (data.status !== 'ringing') {
|
||||
return { success: false, error: 'not_ringing' };
|
||||
}
|
||||
sdpOffer = data.sdp_offer;
|
||||
iceServers = data.ice_servers;
|
||||
providerCallId = data.call_id;
|
||||
call = {
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
};
|
||||
callsStore.addIncomingCall(call);
|
||||
}
|
||||
|
||||
if (!sdpOffer) return { success: false, error: 'missing_sdp_offer' };
|
||||
|
||||
// ORDER-SENSITIVE: setActiveCall must happen synchronously before the
|
||||
// network call so the cable handler doesn't drop a frame on a null guard.
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
return { success: true, call: activeCallData };
|
||||
} catch (err) {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall?.();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort upload. Errors are swallowed because the call has already
|
||||
// completed by the time we get here; surfacing failures isn't actionable.
|
||||
async function uploadRecordingBlob(callId) {
|
||||
if (!mediaRecorder && recordingChunks.length === 0) return;
|
||||
try {
|
||||
const result = await stopRecorderAndGetBlob();
|
||||
if (!result?.blob || result.blob.size === 0) return;
|
||||
await VoiceCallsAPI.uploadRecording(callId, result.blob, result.filename);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[Voice Call] Recording upload failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composable (used by VoiceCallWidget for floating UI + timer) ──
|
||||
export function useVoiceCallSession() {
|
||||
const { t } = useI18n();
|
||||
const callsStore = useVoiceCallsStore();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
const isAccepting = ref(false);
|
||||
const isMuted = ref(false);
|
||||
const callError = ref(null);
|
||||
const callDuration = ref(0);
|
||||
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
|
||||
const firstIncomingCall = computed(() => callsStore.firstIncomingCall);
|
||||
|
||||
const isOutboundRinging = computed(
|
||||
() =>
|
||||
activeCall.value?.direction === 'outbound' &&
|
||||
activeCall.value?.status === 'ringing'
|
||||
);
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
// Triggered when handleCallEnded fires — i.e. the OTHER side hung up
|
||||
// (Meta terminate webhook → voice_call.ended cable). Upload whatever we
|
||||
// recorded BEFORE tearing down the PC, otherwise the agent-hangup path is
|
||||
// the only one that ships audio to the server. uploadRecordingBlob
|
||||
// gathers the blob via the recorder's onstop listener and then frees the
|
||||
// recorder — only AFTER that completes do we close the PC and stop tracks.
|
||||
callsStore.registerCleanupCallback(async callId => {
|
||||
const idForUpload = callId || activeCall.value?.id;
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
try {
|
||||
if (idForUpload) await uploadRecordingBlob(idForUpload);
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
}
|
||||
});
|
||||
|
||||
// Warn the user if they try to navigate away mid-call. The actual cleanup
|
||||
// happens in the pagehide handler — beforeunload is purely the warning.
|
||||
const handleBeforeUnload = e => {
|
||||
if (!callsStore.activeCall || intentionallyClosing) return undefined;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
// Some browsers (older Safari/Firefox) only honour a returned string.
|
||||
// eslint-disable-next-line consistent-return
|
||||
return '';
|
||||
};
|
||||
|
||||
// Fires after the user confirms close (or on tab switch). We use fetch
|
||||
// with `keepalive: true` rather than navigator.sendBeacon because Chatwoot
|
||||
// authenticates via devise-token-auth headers (`access-token`, `client`,
|
||||
// `uid`) which sendBeacon cannot attach — beacon would 401 silently.
|
||||
// fetch+keepalive sends both cookies AND custom headers and the request
|
||||
// survives page unload (capped at ~64KB body, fine for terminate).
|
||||
const handlePageHide = () => {
|
||||
const call = callsStore.activeCall;
|
||||
if (!call || intentionallyClosing) return;
|
||||
|
||||
// Stop the recorder synchronously; final dataavailable already fired via
|
||||
// the 5s timeslice, so chunks[] is approximately current.
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
try {
|
||||
mediaRecorder.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const sessionHeaders = (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
decodeURIComponent(
|
||||
(document.cookie.match(/(^|;\s*)cw_d_session_info=([^;]+)/) ||
|
||||
[])[2] || '%7B%7D'
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const authHeaders = {
|
||||
'access-token': sessionHeaders['access-token'] || '',
|
||||
client: sessionHeaders.client || '',
|
||||
uid: sessionHeaders.uid || '',
|
||||
expiry: sessionHeaders.expiry || '',
|
||||
'token-type': sessionHeaders['token-type'] || 'Bearer',
|
||||
};
|
||||
|
||||
if (recordingChunks.length > 0 && accountId.value) {
|
||||
try {
|
||||
const blob = new Blob(recordingChunks, {
|
||||
type: recordingMime || 'audio/webm',
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append(
|
||||
'recording',
|
||||
blob,
|
||||
recordingFilename || `call_${call.id}.webm`
|
||||
);
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/upload_recording`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (accountId.value) {
|
||||
try {
|
||||
fetch(
|
||||
`/api/v1/accounts/${accountId.value}/voice_calls/${call.id}/terminate`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: authHeaders,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
cleanupInboundWebRTC();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
|
||||
// Start timer when an outbound call transitions to connected.
|
||||
watch(activeCall, call => {
|
||||
if (
|
||||
call?.direction === 'outbound' &&
|
||||
call?.status === 'connected' &&
|
||||
!durationTimer.intervalId
|
||||
) {
|
||||
durationTimer.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Floating-widget Accept button. Same WhatsApp-direct flow as
|
||||
// acceptVoiceCallById — see comments there.
|
||||
const acceptCall = async call => {
|
||||
if (isAccepting.value) return;
|
||||
isAccepting.value = true;
|
||||
callError.value = null;
|
||||
|
||||
const sdpOffer = call.sdpOffer;
|
||||
const iceServers = call.iceServers;
|
||||
if (!sdpOffer) {
|
||||
callError.value = t('WHATSAPP_CALL.CALL_FAILED');
|
||||
isAccepting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCallData = { ...call };
|
||||
callsStore.setActiveCall(activeCallData);
|
||||
|
||||
try {
|
||||
const sdpAnswer = await prepareInboundAnswer({
|
||||
callId: call.id,
|
||||
providerCallId: call.callId,
|
||||
sdpOffer,
|
||||
iceServers,
|
||||
});
|
||||
await VoiceCallsAPI.accept(call.id, { sdpAnswer });
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.markActiveCallConnected();
|
||||
durationTimer.start();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
} catch (err) {
|
||||
callError.value =
|
||||
err?.name === 'NotAllowedError'
|
||||
? t('WHATSAPP_CALL.MIC_DENIED')
|
||||
: t('WHATSAPP_CALL.CALL_FAILED');
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] acceptCall error:', err);
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
callsStore.clearActiveCall();
|
||||
} finally {
|
||||
isAccepting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCall = async call => {
|
||||
try {
|
||||
await VoiceCallsAPI.reject(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
}
|
||||
};
|
||||
|
||||
const endActiveCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
intentionallyClosing = true;
|
||||
|
||||
try {
|
||||
await uploadRecordingBlob(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
try {
|
||||
await VoiceCallsAPI.terminate(call.id);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
cleanupInboundWebRTC();
|
||||
callsStore.clearActiveCall();
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
intentionallyClosing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!inboundStream) return;
|
||||
const audioTrack = inboundStream.getAudioTracks()[0];
|
||||
if (!audioTrack) return;
|
||||
audioTrack.enabled = !audioTrack.enabled;
|
||||
isMuted.value = !audioTrack.enabled;
|
||||
};
|
||||
|
||||
const dismissIncomingCall = call => {
|
||||
callsStore.removeIncomingCall(call.callId);
|
||||
};
|
||||
|
||||
const startDurationTimer = () => {
|
||||
durationTimer.start();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
durationTimer.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
hasIncomingCall,
|
||||
firstIncomingCall,
|
||||
isAccepting,
|
||||
isMuted,
|
||||
isOutboundRinging,
|
||||
callError,
|
||||
formattedCallDuration,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endActiveCall,
|
||||
toggleMute,
|
||||
dismissIncomingCall,
|
||||
startDurationTimer,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
|
||||
@@ -4,8 +4,6 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { applyOutboundAnswer } from 'dashboard/composables/useVoiceCallSession';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
@@ -35,12 +33,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
'voice_call.incoming': this.onVoiceCallIncoming,
|
||||
'voice_call.accepted': this.onVoiceCallAccepted,
|
||||
'voice_call.ended': this.onVoiceCallEnded,
|
||||
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
|
||||
'voice_call.permission_granted': this.onVoiceCallPermissionGranted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,75 +195,16 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
|
||||
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallIncoming = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.addIncomingCall({
|
||||
id: data.id,
|
||||
callId: data.call_id,
|
||||
provider: data.provider,
|
||||
direction: data.direction,
|
||||
inboxId: data.inbox_id,
|
||||
conversationId: data.conversation_id,
|
||||
caller: data.caller,
|
||||
// Stash Meta's SDP offer + ICE servers on the incoming-call entry so
|
||||
// the bubble's Accept button can build the WebRTC answer locally
|
||||
// without re-fetching from /show.
|
||||
sdpOffer: data.sdp_offer,
|
||||
iceServers: data.ice_servers,
|
||||
});
|
||||
};
|
||||
|
||||
onVoiceCallAccepted = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const currentUserId = this.app.$store.getters.getCurrentUserID;
|
||||
if (data.accepted_by_agent_id !== currentUserId) {
|
||||
voiceCallsStore.handleCallAcceptedByOther(data.call_id);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallEnded = data => {
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
voiceCallsStore.handleCallEnded(data.call_id);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallOutboundConnected = data => {
|
||||
// Outbound WhatsApp direct mode: Meta delivers its SDP answer when the
|
||||
// contact picks up. Apply it to the existing local RTCPeerConnection so
|
||||
// DTLS can complete browser ↔ Meta directly. The PC was created earlier
|
||||
// by prepareOutboundOffer when the agent clicked the call button.
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
const activeCall = voiceCallsStore.activeCall;
|
||||
if (!activeCall || activeCall.callId !== data.call_id) return;
|
||||
|
||||
if (!data.sdp_answer) return;
|
||||
|
||||
applyOutboundAnswer(data.sdp_answer)
|
||||
.then(() => {
|
||||
voiceCallsStore.markActiveCallConnected();
|
||||
emitter.emit('voice_call:agent_webrtc_connected');
|
||||
})
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Voice Call] Failed to apply outbound SDP answer:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onVoiceCallPermissionGranted = data => {
|
||||
emitter.emit('voice_call:permission_granted', {
|
||||
contactName: data.contact_name,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse the current selection to a cursor near its head. Used to override
|
||||
* the default Escape -> selectParentNode behavior which would otherwise keep
|
||||
* the text highlight visible.
|
||||
*
|
||||
* @param {EditorView} view - The ProseMirror EditorView
|
||||
*/
|
||||
export const collapseSelection = view => {
|
||||
const { tr, selection } = view.state;
|
||||
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a transaction that inserts a node into editor at the given position
|
||||
* Has an optional param 'content' to check if the
|
||||
|
||||
@@ -21,6 +21,7 @@ export const VOICE_CALL_PROVIDERS = {
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
if (!inbox) return null;
|
||||
|
||||
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
collapseSelection,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseSelection', () => {
|
||||
it('collapses a text range to a cursor at its head', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hello world'),
|
||||
});
|
||||
|
||||
// Build a TextSelection via the initial selection's constructor (avoids
|
||||
// importing prosemirror-state, which isn't a direct dep).
|
||||
const { doc, selection } = editorView.state;
|
||||
editorView.dispatch(
|
||||
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
|
||||
);
|
||||
expect(editorView.state.selection.empty).toBe(false);
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
expect(editorView.state.selection.head).toBe(6);
|
||||
});
|
||||
|
||||
it('leaves an already-collapsed selection as a cursor', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hi'),
|
||||
});
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -83,11 +83,7 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"ANSWERED_BY": "Answered by {name}",
|
||||
"ACCEPT_CALL": "Accept",
|
||||
"AUDIO_NOT_SUPPORTED": "Your browser does not support audio playback.",
|
||||
"TRANSCRIPT": "Transcript"
|
||||
"YOU_ANSWERED": "You answered"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -645,7 +645,7 @@
|
||||
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent WebRTC connections."
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
|
||||
@@ -36,7 +36,6 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappCall from './whatsappCall.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
@@ -82,7 +81,6 @@ export default {
|
||||
...sla,
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappCall,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"WHATSAPP_CALL": {
|
||||
"INCOMING_WHATSAPP_CALL": "Incoming WhatsApp Call",
|
||||
"ACCEPT": "Accept",
|
||||
"REJECT": "Reject",
|
||||
"HANG_UP": "Hang Up",
|
||||
"MUTE": "Mute",
|
||||
"UNMUTE": "Unmute",
|
||||
"INITIATE_CALL": "Call via WhatsApp",
|
||||
"CALLING": "Calling…",
|
||||
"RINGING": "Ringing…",
|
||||
"CALL_FAILED": "Call failed. Please try again.",
|
||||
"PERMISSION_REQUESTED": "Call permission request sent to the contact. You can call once they approve.",
|
||||
"PERMISSION_PENDING": "Waiting for the contact to approve the call permission request. Please try again shortly.",
|
||||
"UNKNOWN_CALLER": "Unknown caller",
|
||||
"MIC_DENIED": "Microphone access denied. Please allow mic access and try again.",
|
||||
"PERMISSION_GRANTED": "{contactName} approved the call permission request. You can now call them.",
|
||||
"RELOAD_ENDED_CALL": "The call ended because the page was refreshed."
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,6 @@ const navigateTo = computed(() => {
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -20,17 +20,11 @@ const FloatingCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
|
||||
);
|
||||
|
||||
const WhatsappCallWidget = defineAsyncComponent(
|
||||
() => import('dashboard/components/widgets/WhatsappCallWidget.vue')
|
||||
);
|
||||
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
|
||||
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls';
|
||||
import { useCallReconnection } from 'dashboard/composables/useCallReconnection';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -42,7 +36,6 @@ export default {
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
FloatingCallWidget,
|
||||
WhatsappCallWidget,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
@@ -51,13 +44,6 @@ export default {
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const callsStore = useCallsStore();
|
||||
const voiceCallsStore = useVoiceCallsStore();
|
||||
|
||||
// Detect a stale active WhatsApp call left over from a previous tab /
|
||||
// refresh and terminate it. Direct browser↔Meta WebRTC has no rejoin
|
||||
// path because Meta caches our DTLS fingerprint; the only safe action
|
||||
// is to clean up server-side state and tell the agent the call dropped.
|
||||
useCallReconnection();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -65,15 +51,8 @@ export default {
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
windowWidth,
|
||||
// Twilio voice still flows through useCallSession + the calls store.
|
||||
hasActiveCall: computed(() => callsStore.hasActiveCall),
|
||||
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
|
||||
// WhatsApp direct-browser calls flow through useVoiceCallSession + the
|
||||
// voiceCalls store. Both widgets can mount simultaneously; only one
|
||||
// ever has state at a time because they listen to disjoint cable
|
||||
// events.
|
||||
hasActiveVoiceCall: computed(() => voiceCallsStore.hasActiveCall),
|
||||
hasIncomingVoiceCall: computed(() => voiceCallsStore.hasIncomingCall),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -184,7 +163,6 @@ export default {
|
||||
/>
|
||||
<CopilotContainer />
|
||||
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
|
||||
<WhatsappCallWidget v-if="hasActiveVoiceCall || hasIncomingVoiceCall" />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
|
||||
@@ -132,8 +132,10 @@ const headers = computed(() => [
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
<div
|
||||
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
|
||||
>
|
||||
<p class="text-body-main text-n-slate-11 mb-0">
|
||||
{{
|
||||
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
|
||||
selected: selectedAgents.length,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
|
||||
* 3. Nested properties in custom_attributes (conversation_type, etc.)
|
||||
*/
|
||||
import jsonLogic from 'json-logic-js';
|
||||
import { coerceToDate } from '@chatwoot/utils';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
|
||||
/**
|
||||
* Gets a value from a conversation based on the attribute key
|
||||
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
|
||||
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
|
||||
*
|
||||
* This function handles various equality scenarios:
|
||||
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
|
||||
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
|
||||
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
|
||||
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
|
||||
* 3. Otherwise: performs strict equality comparison
|
||||
*/
|
||||
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
|
||||
if (filterValue === 'all') return true;
|
||||
|
||||
if (Array.isArray(conversationValue)) {
|
||||
// For array values like labels, check if any of the filter values exist in the array
|
||||
return filterValue.every(val => conversationValue.includes(val));
|
||||
// For array values like labels, match if any filter value is present.
|
||||
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
|
||||
return filterValue.some(val => conversationValue.includes(val));
|
||||
}
|
||||
|
||||
if (!Array.isArray(conversationValue)) {
|
||||
|
||||
+34
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
|
||||
// a conversation matches if ANY of the filter labels is on it.
|
||||
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['support'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['new'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with is_present operator for labels', () => {
|
||||
const conversation = { labels: ['support', 'urgent', 'new'] };
|
||||
const filters = [
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useVoiceCallsStore = defineStore('voiceCalls', {
|
||||
state: () => ({
|
||||
// Incoming ringing calls waiting for agent action (per account)
|
||||
incomingCalls: [],
|
||||
// The single active call (accepted + audio connected)
|
||||
activeCall: null,
|
||||
// Cleanup callback registered by the composable — called when a call ends externally
|
||||
cleanupCallback: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
hasIncomingCall: state => state.incomingCalls.length > 0,
|
||||
hasActiveCall: state => state.activeCall !== null,
|
||||
hasVoiceCall: state =>
|
||||
state.incomingCalls.length > 0 || state.activeCall !== null,
|
||||
firstIncomingCall: state => state.incomingCalls[0] || null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
addIncomingCall(callData) {
|
||||
const exists = this.incomingCalls.some(c => c.callId === callData.callId);
|
||||
if (exists) return;
|
||||
this.incomingCalls.push(callData);
|
||||
},
|
||||
|
||||
removeIncomingCall(callId) {
|
||||
this.incomingCalls = this.incomingCalls.filter(c => c.callId !== callId);
|
||||
},
|
||||
|
||||
setActiveCall(callData) {
|
||||
this.activeCall = callData;
|
||||
},
|
||||
|
||||
clearActiveCall() {
|
||||
this.activeCall = null;
|
||||
},
|
||||
|
||||
markActiveCallConnected() {
|
||||
if (this.activeCall) {
|
||||
this.activeCall = { ...this.activeCall, status: 'connected' };
|
||||
}
|
||||
},
|
||||
|
||||
registerCleanupCallback(callback) {
|
||||
this.cleanupCallback = callback;
|
||||
},
|
||||
|
||||
handleCallAcceptedByOther(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
},
|
||||
|
||||
handleCallEnded(callId) {
|
||||
this.removeIncomingCall(callId);
|
||||
if (this.activeCall?.callId === callId) {
|
||||
// Snapshot the DB id before nulling activeCall so the composable can
|
||||
// POST upload_recording with the right path even after we clear state.
|
||||
const dbCallId = this.activeCall?.id;
|
||||
if (this.cleanupCallback) this.cleanupCallback(dbCallId);
|
||||
this.activeCall = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
const state = {
|
||||
@@ -49,6 +50,7 @@ const state = {
|
||||
termsURL,
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
activePlatformBanners: activePlatformBanners || [],
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
+17
-6
@@ -23,13 +23,24 @@ class HookJob < MutexApplicationJob
|
||||
private
|
||||
|
||||
def process_slack_integration(hook, event_name, event_data)
|
||||
return unless ['message.created'].include?(event_name)
|
||||
|
||||
message = event_data[:message]
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
|
||||
case event_name
|
||||
when 'message.created'
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
end
|
||||
when 'message.updated'
|
||||
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
|
||||
# Skip other content types to avoid unnecessary job enqueues on every message update.
|
||||
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
|
||||
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
|
||||
# while submitted_values is already present on the message.
|
||||
return unless event_data[:previous_changes]&.key?('content_attributes')
|
||||
|
||||
::UpdateSlackMessageJob.perform_later(message, hook)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class UpdateSlackMessageJob < MutexApplicationJob
|
||||
queue_as :medium
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message, hook)
|
||||
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
|
||||
with_lock(key) do
|
||||
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,9 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
# Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise
|
||||
# a webhook that arrives just after the lock is acquired can exhaust retries before the
|
||||
# holder finishes and silently drop its message.
|
||||
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,6 +13,20 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
sender_id = contact_sender_id(params)
|
||||
return process_events(channel, params) if sender_id.blank?
|
||||
|
||||
# Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact)
|
||||
# so the first webhook creates the conversation and the rest append to it.
|
||||
# 30s TTL covers the attachment download + transaction — the default 1s expires
|
||||
# mid-processing and lets a concurrent webhook re-acquire before the first commit.
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
else
|
||||
@@ -69,6 +87,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
# Echo payloads reverse the fields — `from` is the business number and `to` is the contact.
|
||||
# Returns nil for status-only webhooks so they bypass the lock.
|
||||
def contact_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || params
|
||||
message = (value[:messages] || value[:message_echoes])&.first
|
||||
return if message.blank?
|
||||
|
||||
message[:to] || message[:from]
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
|
||||
@@ -43,7 +43,7 @@ class HookListener < BaseListener
|
||||
next if hook.inbox.present? && hook.inbox != message.inbox
|
||||
next unless supported_hook_event?(hook, event.name)
|
||||
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +59,7 @@ class HookListener < BaseListener
|
||||
return false if hook.disabled?
|
||||
|
||||
supported_events_map = {
|
||||
'slack' => ['message.created'],
|
||||
'slack' => ['message.created', 'message.updated'],
|
||||
'dialogflow' => ['message.created', 'message.updated'],
|
||||
'google_translate' => ['message.created'],
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
|
||||
@@ -104,11 +104,23 @@ class Attachment < ApplicationRecord
|
||||
audio_file_data = base_data.merge(file_metadata)
|
||||
audio_file_data.merge(
|
||||
{
|
||||
# ActiveStorage's redirect endpoint defaults to Content-Disposition: attachment,
|
||||
# which makes <audio> elements download instead of play. Force inline so the
|
||||
# call-recording chip (and any other audio bubble) can stream directly.
|
||||
data_url: inline_audio_url,
|
||||
transcribed_text: meta&.[]('transcribed_text') || ''
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def inline_audio_url
|
||||
return '' unless file.attached?
|
||||
|
||||
# Proxy endpoint streams through Rails and honours `disposition: 'inline'`,
|
||||
# unlike the redirect endpoint which always sends Content-Disposition: attachment.
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(file, disposition: 'inline')
|
||||
end
|
||||
|
||||
def file_metadata
|
||||
metadata = {
|
||||
extension: extension,
|
||||
|
||||
@@ -61,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
client.messages.create(**params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
if api_key_sid.present?
|
||||
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
|
||||
@@ -71,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_message_from
|
||||
if messaging_service_sid?
|
||||
{ messaging_service_sid: messaging_service_sid }
|
||||
|
||||
@@ -40,6 +40,15 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
'Whatsapp'
|
||||
end
|
||||
|
||||
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
|
||||
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
|
||||
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
|
||||
def voice_enabled?
|
||||
provider == 'whatsapp_cloud' &&
|
||||
provider_config['source'] == 'embedded_signup' &&
|
||||
provider_config['calling_enabled'].present?
|
||||
end
|
||||
|
||||
def provider_service
|
||||
if provider == 'whatsapp_cloud'
|
||||
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
|
||||
|
||||
@@ -19,7 +19,7 @@ module SortHandler
|
||||
end
|
||||
|
||||
def sort_on_waiting_since(sort_direction = :asc)
|
||||
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
|
||||
order(generate_sql_query("(waiting_since IS NULL), waiting_since #{sort_direction.to_s.upcase}, created_at ASC"))
|
||||
end
|
||||
|
||||
def last_messaged_conversations
|
||||
|
||||
@@ -152,14 +152,9 @@ class Message < ApplicationRecord
|
||||
)
|
||||
data[:echo_id] = echo_id if echo_id.present?
|
||||
data[:attachments] = attachments.map(&:push_event_data) if attachments.present?
|
||||
data[:call] = call.push_event_data if voice_call? && respond_to?(:call) && call.present?
|
||||
merge_sender_attributes(data)
|
||||
end
|
||||
|
||||
def voice_call?
|
||||
content_type == 'voice_call'
|
||||
end
|
||||
|
||||
def conversation_push_event_data
|
||||
{
|
||||
assignee_id: conversation.assignee_id,
|
||||
|
||||
@@ -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
|
||||
@@ -46,9 +46,7 @@ class Base::SendOnChannelService
|
||||
def invalid_message?
|
||||
# private notes aren't send to the channels
|
||||
# we should also avoid the case of message loops, when outgoing messages are created from channel
|
||||
# voice_call bubbles are in-app system events for the call lifecycle —
|
||||
# dispatching them to the channel would deliver "WhatsApp Call" as a
|
||||
# text message to the contact every time the agent placed a call.
|
||||
# voice_call bubbles are call status indicators, not deliverable messages
|
||||
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
|
||||
end
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ class Messages::MentionService
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
next if self_mention?(user_id)
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: User.find(user_id),
|
||||
@@ -60,6 +62,10 @@ class Messages::MentionService
|
||||
end
|
||||
end
|
||||
|
||||
def self_mention?(user_id)
|
||||
message.sender_type == 'User' && user_id.to_i == message.sender_id
|
||||
end
|
||||
|
||||
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
class Whatsapp::LiquidTemplateProcessorService
|
||||
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
|
||||
|
||||
module JsonEscapeFilter
|
||||
def json_escape(input)
|
||||
input.to_s.to_json[1..-2]
|
||||
end
|
||||
end
|
||||
|
||||
pattr_initialize [:campaign!, :contact!]
|
||||
|
||||
def process_template_params(template_params)
|
||||
return template_params if template_params.blank?
|
||||
|
||||
template_params_copy = template_params.deep_dup
|
||||
processed_params = template_params_copy['processed_params']
|
||||
|
||||
return template_params_copy if processed_params.blank?
|
||||
|
||||
rendered_params = render_liquid(processed_params)
|
||||
return nil if blank_render?(processed_params, rendered_params)
|
||||
|
||||
template_params_copy.merge('processed_params' => rendered_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_liquid(processed_params)
|
||||
raw = processed_params.to_json
|
||||
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
|
||||
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
|
||||
JSON.parse(rendered)
|
||||
rescue Liquid::Error, JSON::ParserError
|
||||
processed_params
|
||||
end
|
||||
|
||||
def drops
|
||||
{
|
||||
'contact' => ContactDrop.new(contact),
|
||||
'agent' => UserDrop.new(campaign.sender),
|
||||
'inbox' => InboxDrop.new(campaign.inbox),
|
||||
'account' => AccountDrop.new(campaign.account)
|
||||
}
|
||||
end
|
||||
|
||||
def blank_render?(original, rendered)
|
||||
case original
|
||||
when Hash then blank_render_in_hash?(original, rendered)
|
||||
when Array then blank_render_in_array?(original, rendered)
|
||||
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def blank_render_in_hash?(original, rendered)
|
||||
return false unless rendered.is_a?(Hash)
|
||||
|
||||
original.any? { |key, value| blank_render?(value, rendered[key]) }
|
||||
end
|
||||
|
||||
def blank_render_in_array?(original, rendered)
|
||||
return false unless rendered.is_a?(Array)
|
||||
|
||||
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
processed_template_params = process_liquid_template_params(contact)
|
||||
return if processed_template_params.nil?
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
@@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
def process_liquid_template_params(contact)
|
||||
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
|
||||
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
|
||||
|
||||
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
|
||||
|
||||
processed_template_params
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:, template_params:)
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
template_params: template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseService
|
||||
prepend Whatsapp::Providers::WhatsappCloudCallMethods if defined?(Whatsapp::Providers::WhatsappCloudCallMethods)
|
||||
|
||||
def send_message(phone_number, message)
|
||||
@message = message
|
||||
|
||||
@@ -207,3 +205,5 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
process_response(response, message)
|
||||
end
|
||||
end
|
||||
|
||||
Whatsapp::Providers::WhatsappCloudService.prepend_mod_with('Whatsapp::Providers::WhatsappCloudService')
|
||||
|
||||
@@ -130,9 +130,6 @@ if resource.whatsapp?
|
||||
json.message_templates resource.channel.try(:message_templates)
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
# `calling_enabled` is needed by every agent (not just admins) so the
|
||||
# ConversationHeader can decide whether to render the outbound call button.
|
||||
json.calling_enabled resource.channel.try(:provider_config)&.fetch('calling_enabled', false) || false
|
||||
end
|
||||
|
||||
## Voice attributes for TwilioSms
|
||||
@@ -145,3 +142,6 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
end
|
||||
end
|
||||
|
||||
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
|
||||
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
|
||||
|
||||
@@ -13,6 +13,4 @@ json.source_id message.source_id
|
||||
json.sender message.sender.push_event_data if message.sender
|
||||
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
|
||||
|
||||
if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
|
||||
json.set! :call, message.call.push_event_data
|
||||
end
|
||||
json.set! :call, message.call.push_event_data if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-megaphone-line" viewBox="0 0 20 20">
|
||||
<path d="M15.8333 3.33334C16.0543 3.33334 16.2663 3.42114 16.4226 3.57742C16.5789 3.7337 16.6667 3.94566 16.6667 4.16668V15.8333C16.6667 16.0544 16.5789 16.2663 16.4226 16.4226C16.2663 16.5789 16.0543 16.6667 15.8333 16.6667C14.3967 16.6667 12.9408 16.2917 11.6575 15.5267C10.5833 14.8842 9.6675 14.0317 8.9575 13.0217L8.33333 14.8933C8.26541 15.0989 8.12416 15.2722 7.93666 15.3803C7.74917 15.4884 7.52804 15.5239 7.31583 15.48L5.67083 15.0392C5.45946 14.9842 5.27514 14.8558 5.15098 14.677C5.02683 14.4982 4.97074 14.2812 4.99249 14.065L5.5375 10.8333H4.16667C3.94565 10.8333 3.72369 10.7455 3.56741 10.5893C3.41113 10.433 3.32333 10.221 3.32333 10V7.50001C3.32333 7.27899 3.41113 7.06703 3.56741 6.91075C3.72369 6.75447 3.94565 6.66668 4.16667 6.66668H8.0625C9.43167 4.56834 12.2483 3.33334 15.8333 3.33334ZM7.60667 8.33334H5V9.16668H7.29167L7.60667 8.33334ZM15 5.07501C12.4167 5.24168 10.3483 6.23168 9.2675 7.82168L7.97833 10.8975L6.63417 14.0508L7.12 14.1817L7.78917 12.1817L8.335 11.3683C8.335 11.3683 8.37167 11.4092 8.42667 11.4783C9.265 12.5483 10.3342 13.4267 11.565 14.0617C12.5833 14.6275 13.7608 14.935 15 14.99V5.07501Z" fill="currentColor"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
@@ -16,6 +16,7 @@ as defined by the routes in the `admin/` namespace
|
||||
users: 'icon-user-follow-line',
|
||||
platform_apps: 'icon-apps-2-line',
|
||||
agent_bots: 'icon-robot-line',
|
||||
platform_banners: 'icon-megaphone-line',
|
||||
}
|
||||
%>
|
||||
|
||||
@@ -33,6 +34,7 @@ as defined by the routes in the `admin/` namespace
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
|
||||
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
|
||||
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
|
||||
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
|
||||
<%= render partial: "nav_item", locals: {
|
||||
icon: sidebar_icons[resource.resource.to_sym],
|
||||
url: resource_index_route(resource),
|
||||
|
||||
+3
-3
@@ -191,10 +191,10 @@
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
- name: captain_document_auto_sync
|
||||
display_name: Captain Document Auto Sync
|
||||
enabled: false
|
||||
deprecated: true
|
||||
premium: true
|
||||
- name: advanced_search
|
||||
display_name: Advanced Search
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Allow audio attachments (call recordings, voice notes) to serve inline so the
|
||||
# in-app <audio> player can stream them. Without this, ActiveStorage's blob model
|
||||
# forces Content-Disposition: attachment for any MIME outside the default allowlist
|
||||
# (images + PDF), which makes the browser download instead of play.
|
||||
Rails.application.config.active_storage.content_types_allowed_inline += %w[
|
||||
audio/webm
|
||||
audio/ogg
|
||||
audio/mpeg
|
||||
audio/mp4
|
||||
audio/x-m4a
|
||||
audio/wav
|
||||
audio/x-wav
|
||||
]
|
||||
@@ -40,6 +40,11 @@ Rails.application.reloader.to_prepare do
|
||||
if File.exist?(schedule_file) && Sidekiq.server?
|
||||
schedule = YAML.load_file(schedule_file)
|
||||
|
||||
# Merge enterprise-only cron entries when running an enterprise build.
|
||||
# Mirrors the conditional-load pattern already used for enterprise initializers.
|
||||
enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
|
||||
schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
|
||||
|
||||
# Cron entries removed from schedule.yml but possibly still in Redis
|
||||
# with source:'dynamic' (predating the source tag). load_from_hash!
|
||||
# only cleans up source:'schedule' entries, so these need explicit removal.
|
||||
|
||||
@@ -114,6 +114,13 @@ en:
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
calls:
|
||||
not_enabled: 'Calling is not enabled for this inbox'
|
||||
no_recording: 'No recording file provided'
|
||||
no_message: 'Call has no associated message'
|
||||
sdp_offer_required: 'sdp_offer is required'
|
||||
contact_phone_required: 'Contact phone number is required'
|
||||
permission_request_failed: 'Failed to send call permission request'
|
||||
inboxes:
|
||||
imap:
|
||||
socket_error: Please check the network connection, IMAP address and try again.
|
||||
@@ -243,6 +250,10 @@ en:
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
call_permission_request_body: 'We would like to call you regarding your conversation.'
|
||||
voice_call:
|
||||
twilio: 'Voice Call'
|
||||
whatsapp: 'WhatsApp Call'
|
||||
delivery_status:
|
||||
error_code: 'Error code: %{error_code}'
|
||||
activity:
|
||||
@@ -379,6 +390,9 @@ en:
|
||||
limit_exceeded: 'Document limit exceeded'
|
||||
pdf_format_error: 'must be a PDF file'
|
||||
pdf_size_error: 'must be less than 10MB'
|
||||
sync_not_supported_for_pdf: 'Sync is not supported for PDF documents'
|
||||
sync_only_available_documents: 'Sync is only available for processed documents'
|
||||
sync_already_in_progress: 'Document sync is already in progress'
|
||||
pdf_upload_failed: 'Failed to upload PDF to OpenAI'
|
||||
pdf_upload_success: 'PDF uploaded successfully with file_id: %{file_id}'
|
||||
pdf_processing_failed: 'Failed to process PDF document %{document_id}: %{error}'
|
||||
|
||||
+19
-14
@@ -77,7 +77,9 @@ Rails.application.routes.draw do
|
||||
resources :custom_tools do
|
||||
post :test, on: :collection
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resources :documents, only: [:index, :show, :create, :destroy] do
|
||||
post :sync, on: :member
|
||||
end
|
||||
resource :tasks, only: [], controller: 'tasks' do
|
||||
post :rewrite
|
||||
post :summarize
|
||||
@@ -213,6 +215,21 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
||||
|
||||
if ChatwootApp.enterprise?
|
||||
resources :whatsapp_calls, only: [:show] do
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :upload_recording
|
||||
end
|
||||
collection do
|
||||
post :initiate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||
@@ -303,19 +320,6 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :voice_calls, only: [:show] do
|
||||
collection do
|
||||
get :active
|
||||
post :initiate
|
||||
end
|
||||
member do
|
||||
post :accept
|
||||
post :reject
|
||||
post :terminate
|
||||
post :upload_recording
|
||||
end
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
@@ -664,6 +668,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member, action: :destroy_avatar
|
||||
end
|
||||
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
||||
resources :platform_banners
|
||||
resource :instance_status, only: [:show]
|
||||
|
||||
resource :settings, only: [:show] do
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class CreatePlatformBanners < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :platform_banners do |t|
|
||||
t.text :banner_message, null: false
|
||||
t.integer :banner_type, null: false, default: 0
|
||||
t.boolean :active, default: true, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
class RepurposeTwilioContentTemplatesFlagForCaptainDocumentAutoSync < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# The twilio_content_templates flag (deprecated) has been renamed to captain_document_auto_sync.
|
||||
# Disable it on any accounts that had twilio_content_templates enabled so the repurposed
|
||||
# flag starts in its intended default-off state.
|
||||
Account.feature_captain_document_auto_sync.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:captain_document_auto_sync)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
# Remove the stale twilio_content_templates entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS.
|
||||
# ConfigLoader only adds new flags; it never removes renamed ones.
|
||||
# Leaving it would cause NoMethodError in enable_default_features when
|
||||
# creating new accounts (feature_twilio_content_templates= no longer exists).
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |f| f['name'] == 'twilio_content_templates' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class BackfillCaptainDocumentSyncMetadata < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
return unless ChatwootApp.enterprise?
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Captain::Document
|
||||
.syncable
|
||||
.where(status: :available, sync_status: nil, last_synced_at: nil)
|
||||
.in_batches(of: 1000) do |batch|
|
||||
batch.update_all('sync_status = 1, last_synced_at = updated_at')
|
||||
end
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def down
|
||||
# No-op. This is a one-time baseline for existing available web documents.
|
||||
end
|
||||
end
|
||||
+9
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1087,6 +1087,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "platform_banners", force: :cascade do |t|
|
||||
t.text "banner_message", null: false
|
||||
t.integer "banner_type", default: 0, null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "portals", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.string "name", null: false
|
||||
|
||||
@@ -2,13 +2,12 @@ module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
|
||||
return @message_type if @message_type == 'incoming' && voice_call_inbox? && @params[:content_type] == 'voice_call'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def twilio_voice_inbox?
|
||||
inbox = @conversation.inbox
|
||||
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
||||
def voice_call_inbox?
|
||||
@conversation.inbox.channel.try(:voice_enabled?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,10 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
|
||||
|
||||
def create
|
||||
@responses = process_bulk_action
|
||||
result = process_bulk_action
|
||||
return if performed?
|
||||
|
||||
@responses = result
|
||||
end
|
||||
|
||||
private
|
||||
@@ -15,13 +18,13 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
def validate_params
|
||||
return if params[:type].present? && params[:ids].present? && params[:fields].present?
|
||||
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
render json: { success: false }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def type_matches?
|
||||
return if MODEL_TYPE.include?(params[:type])
|
||||
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
render json: { success: false }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def process_bulk_action
|
||||
@@ -48,13 +51,38 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def handle_documents
|
||||
return [] unless params[:fields][:status] == 'delete'
|
||||
case params[:fields][:status]
|
||||
when 'delete'
|
||||
delete_documents
|
||||
when 'sync'
|
||||
sync_documents
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def delete_documents
|
||||
documents = Current.account.captain_documents.where(id: params[:ids])
|
||||
return [] unless documents.exists?
|
||||
return render json: { count: 0 } unless documents.exists?
|
||||
|
||||
documents.destroy_all
|
||||
[]
|
||||
destroyed_documents = documents.destroy_all
|
||||
render json: { count: destroyed_documents.size }
|
||||
end
|
||||
|
||||
def sync_documents
|
||||
synced_document_ids = []
|
||||
|
||||
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
|
||||
next unless document.syncable?
|
||||
next unless document.available?
|
||||
next if document.sync_in_progress?
|
||||
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
synced_document_ids << document.id
|
||||
end
|
||||
|
||||
render json: { ids: synced_document_ids, count: synced_document_ids.size }
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_documents, except: [:create]
|
||||
before_action :set_document, only: [:show, :destroy]
|
||||
before_action :set_document, only: [:show, :destroy, :sync]
|
||||
before_action :set_assistant, only: [:create]
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
@@ -29,6 +29,16 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
def sync
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
|
||||
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
|
||||
|
||||
@document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
Captain::Documents::PerformSyncJob.perform_later(@document)
|
||||
head :accepted
|
||||
end
|
||||
|
||||
def destroy
|
||||
@document.destroy
|
||||
head :no_content
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
class Api::V1::Accounts::VoiceCallsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
|
||||
def show
|
||||
render json: call_payload(@call)
|
||||
end
|
||||
|
||||
def accept
|
||||
call = Voice::CallService.new(
|
||||
call: @call,
|
||||
agent: current_user,
|
||||
sdp_answer: params[:sdp_answer]
|
||||
).accept
|
||||
render json: call_payload(call)
|
||||
rescue Voice::CallErrors::NotRinging, Voice::CallErrors::AlreadyAccepted, Voice::CallErrors::CallFailed => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] accept failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to accept call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def reject
|
||||
call = Voice::CallService.new(call: @call, agent: current_user).reject
|
||||
render json: { id: call.id, status: call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] reject failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to reject call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def terminate
|
||||
call = Voice::CallService.new(call: @call, agent: current_user).terminate
|
||||
render json: { id: call.id, status: call.status }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] terminate failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to terminate call' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Browser-supplied recording, captured via MediaRecorder during the call.
|
||||
# Idempotent: subsequent uploads after the first audio attachment exists
|
||||
# silently no-op so the hangup-vs-pagehide race can't double-attach.
|
||||
def upload_recording
|
||||
return render json: { error: 'No recording file provided' }, status: :unprocessable_entity if params[:recording].blank?
|
||||
return render json: { id: @call.id, status: 'no_message' }, status: :unprocessable_entity if @call.message.blank?
|
||||
return render json: { id: @call.id, status: 'already_uploaded' } if @call.message.attachments.exists?(file_type: :audio)
|
||||
|
||||
attach_recording!
|
||||
render json: { id: @call.id, status: 'uploaded' }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] upload_recording failed: #{e.class} #{e.message}"
|
||||
render json: { error: 'Failed to upload recording' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def active
|
||||
call = current_account.calls.active_for_agent(current_user.id).last
|
||||
if call
|
||||
elapsed = call.started_at ? (Time.current - call.started_at).to_i : 0
|
||||
render json: {
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
provider: call.provider,
|
||||
conversation_id: call.conversation_id,
|
||||
status: call.status,
|
||||
elapsed_seconds: elapsed
|
||||
}
|
||||
else
|
||||
render json: { call: nil }
|
||||
end
|
||||
end
|
||||
|
||||
def initiate
|
||||
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize conversation, :show?
|
||||
|
||||
initiate_whatsapp(conversation)
|
||||
rescue Voice::CallErrors::NoCallPermission
|
||||
handle_no_call_permission(conversation)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Conversation not found' }, status: :not_found
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[VOICE CALL] initiate failed: #{e.class} #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initiate_whatsapp(conversation)
|
||||
error = validate_whatsapp_calling(conversation)
|
||||
return render json: { error: error }, status: :unprocessable_entity if error
|
||||
return render json: { error: 'sdp_offer is required' }, status: :unprocessable_entity if params[:sdp_offer].blank?
|
||||
|
||||
contact_phone = conversation.contact&.phone_number
|
||||
raise ArgumentError, 'Contact phone number not available' if contact_phone.blank?
|
||||
|
||||
call = create_whatsapp_outbound_call(conversation, contact_phone, params[:sdp_offer])
|
||||
message = Voice::CallMessageBuilder.create!(conversation: conversation, call: call, user: current_user)
|
||||
call.update!(message_id: message.id)
|
||||
render json: { status: 'calling', call_id: call.provider_call_id, id: call.id, message_id: message.id, provider: 'whatsapp' }
|
||||
end
|
||||
|
||||
# Browser → Rails → Meta. The browser already opened its mic, built an
|
||||
# RTCPeerConnection, generated the offer, and waited for ICE gathering.
|
||||
# We just hand that offer to Meta. Meta returns the call_id immediately
|
||||
# (no SDP yet); the contact's phone rings; on pickup the connect webhook
|
||||
# delivers Meta's SDP answer and we relay it back via ActionCable.
|
||||
def create_whatsapp_outbound_call(conversation, contact_phone, sdp_offer)
|
||||
result = conversation.inbox.channel.provider_service.initiate_call(contact_phone.delete('+'), sdp_offer)
|
||||
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||
|
||||
current_account.calls.create!(
|
||||
provider: :whatsapp,
|
||||
inbox: conversation.inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: provider_call_id,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
accepted_by_agent_id: current_user.id,
|
||||
meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
end
|
||||
|
||||
def validate_whatsapp_calling(conversation)
|
||||
channel = conversation.inbox.channel
|
||||
return 'Calling is only supported on WhatsApp Cloud inboxes' unless channel.is_a?(Channel::Whatsapp) && channel.provider == 'whatsapp_cloud'
|
||||
return 'Calling is not enabled for this inbox' unless channel.provider_config['calling_enabled']
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def handle_no_call_permission(conversation)
|
||||
last_requested = conversation.additional_attributes&.dig('call_permission_requested_at')
|
||||
|
||||
return render json: { status: 'permission_pending' } if last_requested.present? && Time.zone.parse(last_requested) > 5.minutes.ago
|
||||
|
||||
contact_phone = conversation.contact.phone_number.delete('+')
|
||||
result = conversation.inbox.channel.provider_service.send_call_permission_request(contact_phone)
|
||||
return render json: { error: 'Failed to send call permission request' }, status: :unprocessable_entity unless result
|
||||
|
||||
attrs = (conversation.additional_attributes || {}).merge('call_permission_requested_at' => Time.current.iso8601)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
render json: { status: 'permission_requested' }
|
||||
end
|
||||
|
||||
def call_payload(call)
|
||||
{
|
||||
id: call.id,
|
||||
call_id: call.provider_call_id,
|
||||
provider: call.provider,
|
||||
status: call.status,
|
||||
direction: call.direction_label,
|
||||
conversation_id: call.conversation_id,
|
||||
inbox_id: call.inbox_id,
|
||||
message_id: call.message_id,
|
||||
accepted_by_agent_id: call.accepted_by_agent_id,
|
||||
elapsed_seconds: call.started_at ? (Time.current - call.started_at).to_i : 0,
|
||||
sdp_offer: call.meta&.dig('sdp_offer'),
|
||||
ice_servers: call.meta&.dig('ice_servers') || Call.default_ice_servers,
|
||||
caller: caller_info(call)
|
||||
}
|
||||
end
|
||||
|
||||
def caller_info(call)
|
||||
contact = call.conversation&.contact
|
||||
return {} unless contact
|
||||
|
||||
{ name: contact.name, phone: contact.phone_number, avatar: contact.avatar_url }
|
||||
end
|
||||
|
||||
def set_call
|
||||
@call = current_account.calls.find(params[:id])
|
||||
authorize @call.conversation, :show?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Call not found' }, status: :not_found
|
||||
end
|
||||
|
||||
def attach_recording!
|
||||
@call.message.attachments.create!(
|
||||
account_id: @call.account_id,
|
||||
file_type: :audio,
|
||||
file: params[:recording]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseController
|
||||
PERMISSION_REQUEST_THROTTLE = 5.minutes
|
||||
|
||||
before_action :set_call, only: %i[show accept reject terminate upload_recording]
|
||||
before_action :set_conversation, only: :initiate
|
||||
before_action :ensure_calling_enabled, only: :initiate
|
||||
before_action :ensure_sdp_offer, only: :initiate
|
||||
before_action :ensure_contact_phone, only: :initiate
|
||||
before_action :ensure_recording_present, only: :upload_recording
|
||||
before_action :ensure_call_message, only: :upload_recording
|
||||
|
||||
rescue_from Voice::CallErrors::NotRinging,
|
||||
Voice::CallErrors::AlreadyAccepted,
|
||||
Voice::CallErrors::CallFailed,
|
||||
with: :render_call_error
|
||||
rescue_from Voice::CallErrors::NoCallPermission, with: :render_permission_request
|
||||
|
||||
def show; end
|
||||
|
||||
def accept
|
||||
call_service.accept
|
||||
end
|
||||
|
||||
def reject
|
||||
call_service.reject
|
||||
end
|
||||
|
||||
def terminate
|
||||
call_service.terminate
|
||||
end
|
||||
|
||||
def upload_recording
|
||||
@upload_status = @call.message.with_lock { attach_recording_idempotently }
|
||||
end
|
||||
|
||||
def initiate
|
||||
@call = create_outbound_call
|
||||
@message = Voice::CallMessageBuilder.new(@call).perform!
|
||||
@call.update!(message_id: @message.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_service
|
||||
@call_service ||= Whatsapp::CallService.new(call: @call, agent: Current.user, sdp_answer: params[:sdp_answer])
|
||||
end
|
||||
|
||||
def provider_service
|
||||
@provider_service ||= @conversation.inbox.channel.provider_service
|
||||
end
|
||||
|
||||
def set_call
|
||||
@call = Current.account.calls.whatsapp.find(params[:id])
|
||||
authorize @call.conversation, :show?
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
@conversation = Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def ensure_calling_enabled
|
||||
channel = @conversation.inbox.channel
|
||||
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||
end
|
||||
|
||||
def ensure_sdp_offer
|
||||
return if params[:sdp_offer].present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.sdp_offer_required'))
|
||||
end
|
||||
|
||||
def ensure_contact_phone
|
||||
return if @conversation.contact&.phone_number.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
def ensure_recording_present
|
||||
return if params[:recording].present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_recording'))
|
||||
end
|
||||
|
||||
def ensure_call_message
|
||||
return if @call.message.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('errors.whatsapp.calls.no_message'))
|
||||
end
|
||||
|
||||
def attach_recording_idempotently
|
||||
return 'already_uploaded' if @call.message.attachments.exists?(file_type: :audio)
|
||||
|
||||
@call.message.attachments.create!(account_id: @call.account_id, file_type: :audio, file: params[:recording])
|
||||
'uploaded'
|
||||
end
|
||||
|
||||
def create_outbound_call
|
||||
contact_phone = @conversation.contact.phone_number.delete('+')
|
||||
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
|
||||
provider_call_id = result.dig('calls', 0, 'id') || result['call_id']
|
||||
|
||||
Current.account.calls.create!(
|
||||
provider: :whatsapp, inbox: @conversation.inbox, conversation: @conversation, contact: @conversation.contact,
|
||||
provider_call_id: provider_call_id, direction: :outgoing, status: 'ringing',
|
||||
accepted_by_agent_id: Current.user.id,
|
||||
meta: { 'sdp_offer' => params[:sdp_offer], 'ice_servers' => Call.default_ice_servers }
|
||||
)
|
||||
end
|
||||
|
||||
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
|
||||
# template (throttled, behind a conversation lock to prevent double-send).
|
||||
def render_permission_request
|
||||
status = nil
|
||||
@conversation.with_lock do
|
||||
if permission_request_throttled?
|
||||
status = 'permission_pending'
|
||||
next
|
||||
end
|
||||
|
||||
sent = send_permission_request_safely
|
||||
if sent
|
||||
record_permission_request_wamid(sent)
|
||||
status = 'permission_requested'
|
||||
else
|
||||
status = 'failed'
|
||||
end
|
||||
end
|
||||
|
||||
return render_could_not_create_error(I18n.t('errors.whatsapp.calls.permission_request_failed')) if status == 'failed'
|
||||
|
||||
render json: { status: status }
|
||||
end
|
||||
|
||||
def permission_request_throttled?
|
||||
last_requested = @conversation.additional_attributes&.dig('call_permission_requested_at')
|
||||
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
|
||||
end
|
||||
|
||||
# Treat transport errors as a falsy return so we render 422 rather than 500.
|
||||
def send_permission_request_safely
|
||||
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[WHATSAPP CALL] permission_request failed: #{e.class} #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Stash the outbound wamid so the reply webhook can match context.id back here.
|
||||
def record_permission_request_wamid(sent)
|
||||
attrs = (@conversation.additional_attributes || {}).merge(
|
||||
'call_permission_requested_at' => Time.current.iso8601,
|
||||
'call_permission_request_message_id' => sent.dig('messages', 0, 'id')
|
||||
)
|
||||
@conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def render_call_error(error)
|
||||
render_could_not_create_error(error.message)
|
||||
end
|
||||
end
|
||||
@@ -30,12 +30,14 @@ class Twilio::VoiceController < ApplicationController
|
||||
|
||||
def conference_status
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
if event.nil?
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
|
||||
)
|
||||
return head :no_content
|
||||
end
|
||||
|
||||
call = find_call_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
call: call,
|
||||
@@ -82,7 +84,6 @@ class Twilio::VoiceController < ApplicationController
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
Voice::InboundCallBuilder.perform!(
|
||||
account: current_account,
|
||||
inbox: inbox,
|
||||
from_number: twilio_from,
|
||||
call_sid: twilio_call_sid
|
||||
@@ -106,7 +107,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(call: call, parent_call_sid: parent_sid).perform
|
||||
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
|
||||
call
|
||||
end
|
||||
|
||||
def inbox_calls
|
||||
@@ -134,7 +136,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
def ensure_conference_sid!(call)
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
call.update!(conference_sid: Voice::Conference::Name.for(call))
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
@@ -149,7 +151,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_call_for_conference!(friendly_name:, call_sid:)
|
||||
def find_call_for_conference!(friendly_name, call_sid)
|
||||
name = friendly_name.to_s
|
||||
call = inbox_calls.by_conference_sid(name).first if name.present?
|
||||
call || inbox_calls.find_by!(provider_call_id: call_sid)
|
||||
@@ -157,9 +159,9 @@ class Twilio::VoiceController < ApplicationController
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
channel = Channel::TwilioSms.find_by!(phone_number: e164)
|
||||
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{e164}" unless channel.voice_enabled?
|
||||
phone_number = "+#{digits}"
|
||||
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
|
||||
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
|
||||
|
||||
@inbox = channel.inbox
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::MessageFinder
|
||||
def conversation_messages
|
||||
super.includes(call: [:contact, { inbox: :channel }])
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
|
||||
# A single page fetch + fingerprint compare should complete in seconds.
|
||||
# 10 minutes is generous headroom — if still "syncing" after that, the worker likely died mid-run.
|
||||
# Shared with ScheduleSyncsJob so stale locks are re-enqueued at the same threshold.
|
||||
LOCK_TIMEOUT = 10.minutes
|
||||
|
||||
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
|
||||
@@ -78,6 +81,7 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
def handle_unexpected_failure(document, error, start_time)
|
||||
document.update!(
|
||||
sync_status: :failed,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: 'sync_error',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
PER_ACCOUNT_HOURLY_CAP = 50
|
||||
GLOBAL_HOURLY_CAP = 1000
|
||||
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
|
||||
|
||||
def perform
|
||||
@remaining_global_capacity = GLOBAL_HOURLY_CAP
|
||||
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
|
||||
|
||||
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
|
||||
break if @remaining_global_capacity <= 0
|
||||
|
||||
stats[:accounts_scanned] += 1
|
||||
next unless account.feature_enabled?('captain_document_auto_sync')
|
||||
|
||||
stats[:accounts_enabled] += 1
|
||||
interval = account.captain_document_sync_interval
|
||||
next unless interval
|
||||
|
||||
stats[:accounts_scheduled] += 1
|
||||
stats[:documents_enqueued] += enqueue_due_documents(account, interval)
|
||||
end
|
||||
|
||||
log_scheduler_summary(stats)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_due_documents(account, interval)
|
||||
syncing = Captain::Document.sync_statuses[:syncing]
|
||||
synced = Captain::Document.sync_statuses[:synced]
|
||||
failed = Captain::Document.sync_statuses[:failed]
|
||||
stale_cutoff = SYNC_STALE_TIMEOUT.ago
|
||||
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
|
||||
enqueued_count = 0
|
||||
|
||||
account.captain_documents.syncable.where(status: :available).where(
|
||||
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
|
||||
'(sync_status = ? AND last_sync_attempted_at < ?)',
|
||||
synced, interval.ago, failed, interval.ago, syncing, stale_cutoff
|
||||
).order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id).limit(per_account_limit).each do |document|
|
||||
next unless document.syncable?
|
||||
|
||||
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
Captain::Documents::PerformSyncJob.perform_later(document)
|
||||
@remaining_global_capacity -= 1
|
||||
enqueued_count += 1
|
||||
end
|
||||
|
||||
enqueued_count
|
||||
end
|
||||
|
||||
def log_scheduler_summary(stats)
|
||||
payload = {
|
||||
event: 'completed',
|
||||
global_cap_hit: @remaining_global_capacity <= 0,
|
||||
remaining_global_capacity: @remaining_global_capacity
|
||||
}.merge(stats)
|
||||
|
||||
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
external_link: canonical_url,
|
||||
content: payload[:markdown],
|
||||
name: metadata['title'],
|
||||
status: :available
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
)
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse FireCrawl data: #{e.message}"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
class PermanentCrawlError < StandardError; end
|
||||
|
||||
queue_as :low
|
||||
|
||||
discard_on PermanentCrawlError
|
||||
|
||||
def perform(assistant_id:, page_link:)
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
account = assistant.account
|
||||
@@ -11,23 +15,71 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
end
|
||||
|
||||
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
|
||||
|
||||
page_title = crawler.page_title || ''
|
||||
content = crawler.body_text_content || ''
|
||||
|
||||
normalized_link = normalize_link(page_link)
|
||||
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
|
||||
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: page_title[0..254], content: content[0..14_999], status: :available
|
||||
)
|
||||
handle_failed_fetch!(document, crawler.status_code, page_link) unless crawler.success?
|
||||
|
||||
persist_document!(document, normalized_link, crawler)
|
||||
rescue PermanentCrawlError
|
||||
raise
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse data: #{page_link} #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_failed_fetch!(document, status_code, page_link)
|
||||
error_code = http_error_code(status_code)
|
||||
mark_failed!(document, error_code) if document.persisted?
|
||||
|
||||
error_message = "Failed to fetch page: #{page_link}"
|
||||
raise PermanentCrawlError, error_message if permanent_failure?(error_code)
|
||||
|
||||
raise error_message
|
||||
end
|
||||
|
||||
def persist_document!(document, normalized_link, crawler)
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: (crawler.page_title || '')[0..254],
|
||||
content: (crawler.body_markdown || '')[0..14_999],
|
||||
status: :available,
|
||||
**synced_attributes
|
||||
)
|
||||
end
|
||||
|
||||
def synced_attributes
|
||||
{
|
||||
sync_status: :synced,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
}
|
||||
end
|
||||
|
||||
def mark_failed!(document, error_code)
|
||||
document.update!(
|
||||
status: :available,
|
||||
sync_status: :failed,
|
||||
last_sync_error_code: error_code,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def http_error_code(status_code)
|
||||
case status_code
|
||||
when 404 then 'not_found'
|
||||
when 401, 403 then 'access_denied'
|
||||
when 408, 504 then 'timeout'
|
||||
else 'fetch_failed'
|
||||
end
|
||||
end
|
||||
|
||||
def permanent_failure?(error_code)
|
||||
Captain::Documents::SyncService::PERMANENT_ERROR_CODES.include?(error_code)
|
||||
end
|
||||
|
||||
def normalize_link(raw_link)
|
||||
raw_link.to_s.delete_suffix('/')
|
||||
end
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
module Enterprise::Webhooks::WhatsappEventsJob
|
||||
def handle_message_events(channel, params)
|
||||
if call_event?(params)
|
||||
handle_call_events(channel, params)
|
||||
return
|
||||
end
|
||||
|
||||
if call_permission_reply?(params)
|
||||
handle_call_permission_reply(channel, params)
|
||||
return
|
||||
end
|
||||
return handle_call_events(channel, params) if call_event?(params)
|
||||
return handle_call_permission_reply(channel, params) if call_permission_reply?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Lock per-call_id inside handle_call_events instead of the parent's per-sender mutex.
|
||||
def contact_sender_id(params)
|
||||
return nil if call_event?(params)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def call_event?(params)
|
||||
params.dig(:entry, 0, :changes, 0, :field) == 'calls'
|
||||
end
|
||||
|
||||
def call_permission_reply?(params)
|
||||
message = params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
|
||||
message&.dig(:type) == 'interactive' && message&.dig(:interactive, :type) == 'call_permission_reply'
|
||||
params.dig(:entry, 0, :changes, 0, :value, :messages, 0, :interactive, :type) == 'call_permission_reply'
|
||||
end
|
||||
|
||||
# Per-call_id mutex so connect/terminate for the same call serialize across batches.
|
||||
def handle_call_events(channel, params)
|
||||
Whatsapp::IncomingCallService.new(
|
||||
inbox: channel.inbox,
|
||||
params: extract_call_params(params)
|
||||
).perform
|
||||
calls = params.dig(:entry, 0, :changes, 0, :value, :calls) || []
|
||||
calls.each do |call_payload|
|
||||
lock_key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX,
|
||||
inbox_id: channel.inbox.id, sender_id: "call:#{call_payload[:id]}")
|
||||
with_lock(lock_key, 30.seconds) do
|
||||
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call_permission_reply(channel, params)
|
||||
Whatsapp::CallPermissionReplyService.new(inbox: channel.inbox, params: params).perform
|
||||
end
|
||||
|
||||
def extract_call_params(params)
|
||||
params.dig(:entry, 0, :changes, 0, :value) || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,29 +18,27 @@
|
||||
# contact_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
# media_session_id :string
|
||||
# message_id :bigint
|
||||
# provider_call_id :string not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_calls_on_accepted_by_agent_id_and_status (accepted_by_agent_id,status)
|
||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||
# index_calls_on_media_session_id (media_session_id) UNIQUE
|
||||
# index_calls_on_message_id (message_id)
|
||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||
# index_calls_on_account_id_and_contact_id (account_id,contact_id)
|
||||
# index_calls_on_account_id_and_conversation_id (account_id,conversation_id)
|
||||
# index_calls_on_message_id (message_id)
|
||||
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
|
||||
#
|
||||
class Call < ApplicationRecord
|
||||
# All valid call statuses
|
||||
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
META_ACCESSORS = %i[conference_sid recording_sid parent_call_sid initiated_at ended_at].freeze
|
||||
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
# Frontend voice bubbles/stores expect inbound/outbound string values
|
||||
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
|
||||
|
||||
DEFAULT_STUN_URL = 'stun:stun.l.google.com:19302'.freeze
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -59,33 +57,26 @@ class Call < ApplicationRecord
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :active_for_agent, ->(agent_id) { active.where(accepted_by_agent_id: agent_id) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
|
||||
META_ACCESSORS.each do |key|
|
||||
define_method(key) { (meta || {})[key.to_s] }
|
||||
define_method("#{key}=") { |value| self.meta = (meta || {}).merge(key.to_s => value) }
|
||||
end
|
||||
|
||||
def self.find_by_provider_call_id(provider, sid)
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
end
|
||||
|
||||
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its
|
||||
# public srflx candidate. Configurable via VOICE_CALL_STUN_URLS (comma-
|
||||
# separated). TURN can be added by appending turn:user@host?credential=...
|
||||
# entries to the same env var.
|
||||
def default_conference_sid
|
||||
"conf_account_#{account_id}_call_#{id}"
|
||||
end
|
||||
|
||||
# Browser ↔ Meta WebRTC needs at least one STUN server to discover its public srflx candidate.
|
||||
def self.default_ice_servers
|
||||
urls = ENV.fetch('VOICE_CALL_STUN_URLS', 'stun:stun.l.google.com:19302').split(',').map(&:strip).reject(&:blank?)
|
||||
urls = ENV.fetch('VOICE_CALL_STUN_URLS', DEFAULT_STUN_URL).split(',').filter_map { |u| u.strip.presence }
|
||||
[{ urls: urls }]
|
||||
end
|
||||
|
||||
def display_direction
|
||||
def direction_label
|
||||
DISPLAY_DIRECTION[direction]
|
||||
end
|
||||
|
||||
alias direction_label display_direction
|
||||
|
||||
def ringing?
|
||||
status == 'ringing'
|
||||
end
|
||||
|
||||
@@ -26,12 +26,14 @@
|
||||
#
|
||||
class Captain::Document < ApplicationRecord
|
||||
class LimitExceededError < StandardError; end
|
||||
SYNC_STALE_TIMEOUT = 2.hours
|
||||
self.table_name = 'captain_documents'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy, as: :documentable
|
||||
belongs_to :account
|
||||
has_one_attached :pdf_file
|
||||
store_accessor :metadata, :content_fingerprint, :last_sync_error_code, :sync_step, :openai_file_id
|
||||
|
||||
validates :external_link, presence: true, unless: -> { pdf_file.attached? }
|
||||
validates :external_link, uniqueness: { scope: :assistant_id }, allow_blank: true
|
||||
@@ -59,6 +61,7 @@ class Captain::Document < ApplicationRecord
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
scope :syncable, -> { where("external_link NOT LIKE 'PDF:%' AND external_link NOT LIKE '%.pdf'") }
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
@@ -75,36 +78,8 @@ class Captain::Document < ApplicationRecord
|
||||
pdf_file.blob.byte_size if pdf_file.attached?
|
||||
end
|
||||
|
||||
def content_fingerprint
|
||||
metadata&.dig('content_fingerprint')
|
||||
end
|
||||
|
||||
def content_fingerprint=(value)
|
||||
self.metadata = (metadata || {}).merge('content_fingerprint' => value)
|
||||
end
|
||||
|
||||
def last_sync_error_code
|
||||
metadata&.dig('last_sync_error_code')
|
||||
end
|
||||
|
||||
def last_sync_error_code=(value)
|
||||
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
|
||||
end
|
||||
|
||||
def sync_step
|
||||
metadata&.dig('sync_step')
|
||||
end
|
||||
|
||||
def store_sync_step(step)
|
||||
update!(metadata: (metadata || {}).merge('sync_step' => step))
|
||||
end
|
||||
|
||||
def openai_file_id
|
||||
metadata&.dig('openai_file_id')
|
||||
end
|
||||
|
||||
def store_openai_file_id(file_id)
|
||||
update!(metadata: (metadata || {}).merge('openai_file_id' => file_id))
|
||||
update!(openai_file_id: file_id)
|
||||
end
|
||||
|
||||
def display_url
|
||||
@@ -121,6 +96,18 @@ class Captain::Document < ApplicationRecord
|
||||
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
|
||||
end
|
||||
|
||||
def syncable?
|
||||
!pdf_document?
|
||||
end
|
||||
|
||||
def sync_stale?
|
||||
sync_syncing? && (last_sync_attempted_at.blank? || last_sync_attempted_at < SYNC_STALE_TIMEOUT.ago)
|
||||
end
|
||||
|
||||
def sync_in_progress?
|
||||
sync_syncing? && !sync_stale?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
|
||||
# the upstream DropChannelVoice migration moved voice fields onto
|
||||
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
|
||||
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
|
||||
# so jbuilder `.try` chains short-circuit instead of raising.
|
||||
class Channel::Voice < ApplicationRecord
|
||||
self.table_name = 'channel_twilio_sms'
|
||||
end
|
||||
@@ -1,4 +1,11 @@
|
||||
module Enterprise::Account
|
||||
CAPTAIN_SYNC_INTERVALS = {
|
||||
'hacker' => nil,
|
||||
'startups' => 7.days,
|
||||
'business' => 1.day,
|
||||
'enterprise' => 6.hours
|
||||
}.freeze
|
||||
|
||||
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||
def manually_managed_features; end
|
||||
@@ -34,6 +41,14 @@ module Enterprise::Account
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def captain_document_sync_interval
|
||||
plan = custom_attributes['plan_name']
|
||||
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
|
||||
return nil if plan.blank?
|
||||
|
||||
CAPTAIN_SYNC_INTERVALS[plan.downcase]
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
@@ -12,10 +12,6 @@ module Enterprise::Channel::TwilioSms
|
||||
end
|
||||
end
|
||||
|
||||
def voice_enabled?
|
||||
voice_enabled
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
|
||||
to: to,
|
||||
@@ -34,11 +30,7 @@ module Enterprise::Channel::TwilioSms
|
||||
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Override: when api_key_secret is stored separately (voice channels),
|
||||
# use it instead of auth_token for API key authentication.
|
||||
# Existing SMS channels store the secret in auth_token — that path is unchanged via super.
|
||||
# Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super.
|
||||
def client
|
||||
if api_key_sid.present? && api_key_secret.present?
|
||||
Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid)
|
||||
@@ -47,6 +39,8 @@ module Enterprise::Channel::TwilioSms
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def voice_requires_phone_number
|
||||
return if phone_number.present?
|
||||
|
||||
|
||||
@@ -3,13 +3,30 @@ module Enterprise::Concerns::Attachment
|
||||
|
||||
included do
|
||||
after_create_commit :enqueue_audio_transcription
|
||||
# Broadcast the message update so the FE bubble picks up the new audio
|
||||
# attachment immediately. Without this, the FE has to wait until Whisper
|
||||
# finishes (or fall back to a page refresh) — and if Whisper returns blank,
|
||||
# the bubble never gets the audio at all.
|
||||
after_create_commit :broadcast_message_update_for_audio
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_audio_transcription
|
||||
return unless file_type.to_sym == :audio
|
||||
return unless file.attached?
|
||||
|
||||
Messages::AudioTranscriptionJob.perform_later(id)
|
||||
end
|
||||
|
||||
def broadcast_message_update_for_audio
|
||||
return unless file_type.to_sym == :audio
|
||||
return unless message
|
||||
# Without an attached file, the message serializer's audio_metadata path
|
||||
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
|
||||
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
|
||||
return unless file.attached?
|
||||
|
||||
message.reload.send_update_event
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,12 @@ module Enterprise::Conversation
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
# Surface call lifecycle changes to the FE: writes to additional_attributes
|
||||
# call_status/call_direction should rebroadcast conversation_updated.
|
||||
def allowed_keys?
|
||||
super || call_attributes_changed?
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
@@ -30,4 +36,13 @@ module Enterprise::Conversation
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
def call_attributes_changed?
|
||||
return false if previous_changes['additional_attributes'].blank?
|
||||
|
||||
# Compare before/after values for call keys — checking key presence alone
|
||||
# rebroadcasts on any unrelated additional_attributes write once the keys exist.
|
||||
before, after = previous_changes['additional_attributes']
|
||||
%w[call_status call_direction].any? { |key| (before || {})[key] != (after || {})[key] }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,12 @@ module Enterprise::Message
|
||||
end
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
data = super
|
||||
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
|
||||
@@ -23,6 +23,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def sync?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def playground?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ class Captain::Documents::SinglePageFetcher
|
||||
private
|
||||
|
||||
def firecrawl_configured?
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
Captain::Tools::FirecrawlService.configured?
|
||||
end
|
||||
|
||||
def fetch_with_firecrawl
|
||||
@@ -52,14 +52,13 @@ class Captain::Documents::SinglePageFetcher
|
||||
end
|
||||
|
||||
def fetch_with_fallback
|
||||
response = HTTParty.get(@url)
|
||||
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
|
||||
crawler = Captain::Tools::SimplePageCrawlService.new(@url)
|
||||
return Result.new(success: false, error_code: http_error_code(crawler.status_code)) unless crawler.success?
|
||||
|
||||
parser = Captain::Tools::HtmlPageParser.new(response.body)
|
||||
Result.new(
|
||||
success: true,
|
||||
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
|
||||
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
|
||||
title: crawler.page_title&.truncate(TITLE_MAX_LENGTH, omission: ''),
|
||||
content: crawler.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class Captain::Documents::SyncService
|
||||
end
|
||||
|
||||
def perform
|
||||
@document.store_sync_step('fetching')
|
||||
@document.update!(sync_step: 'fetching')
|
||||
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
|
||||
|
||||
unless result.success
|
||||
@@ -20,17 +20,21 @@ class Captain::Documents::SyncService
|
||||
raise_for_error_code(result.error_code)
|
||||
end
|
||||
|
||||
@document.store_sync_step('comparing')
|
||||
fingerprint = compute_fingerprint(result.content)
|
||||
@document.update!(sync_step: 'comparing')
|
||||
new_fingerprint = compute_fingerprint(result.content)
|
||||
previous_fingerprint = @document.content_fingerprint
|
||||
|
||||
if fingerprint == @document.content_fingerprint
|
||||
if new_fingerprint == previous_fingerprint
|
||||
mark_synced
|
||||
return :unchanged
|
||||
end
|
||||
|
||||
@document.store_sync_step('updating')
|
||||
update_content(result, fingerprint)
|
||||
:updated
|
||||
@document.update!(sync_step: 'updating')
|
||||
update_content(result, new_fingerprint)
|
||||
|
||||
# Without a prior fingerprint we cannot tell a first-ever sync apart from a real
|
||||
# change, so treat it as unchanged to keep downstream signals quiet on baseline.
|
||||
previous_fingerprint.present? ? :updated : :unchanged
|
||||
end
|
||||
|
||||
private
|
||||
@@ -42,6 +46,7 @@ class Captain::Documents::SyncService
|
||||
def mark_failed(error_code)
|
||||
@document.update!(
|
||||
sync_status: :failed,
|
||||
sync_step: nil,
|
||||
last_sync_error_code: error_code,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
@@ -50,6 +55,7 @@ class Captain::Documents::SyncService
|
||||
def mark_synced
|
||||
@document.update!(
|
||||
sync_status: :synced,
|
||||
sync_step: nil,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
@@ -62,6 +68,7 @@ class Captain::Documents::SyncService
|
||||
name: result.title.presence || @document.name,
|
||||
content_fingerprint: fingerprint,
|
||||
sync_status: :synced,
|
||||
sync_step: nil,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
MAX_CONTENT_LENGTH = 8000
|
||||
|
||||
def initialize(website_url)
|
||||
@@ -30,7 +31,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
def fetch_website_content
|
||||
crawler = Captain::Tools::SimplePageCrawlService.new(@website_url)
|
||||
|
||||
text_content = crawler.body_text_content
|
||||
text_content = crawler.body_markdown
|
||||
page_title = crawler.page_title
|
||||
meta_description = crawler.meta_description
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def self.configured?
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
|
||||
.present?
|
||||
end
|
||||
|
||||
def initialize
|
||||
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
|
||||
raise 'Missing API key' if @api_key.empty?
|
||||
raise 'Missing API key' if @api_key.blank?
|
||||
end
|
||||
|
||||
def perform(url, webhook_url, crawl_limit = 10)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
class Captain::Tools::SimplePageCrawlService
|
||||
attr_reader :external_link
|
||||
attr_reader :external_link, :status_code
|
||||
|
||||
def initialize(external_link)
|
||||
@external_link = external_link
|
||||
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
|
||||
@parser = Captain::Tools::HtmlPageParser.new(fetch_body)
|
||||
@doc = @parser.doc
|
||||
end
|
||||
|
||||
def success?
|
||||
status_code.to_i.between?(200, 299)
|
||||
end
|
||||
|
||||
def page_links
|
||||
sitemap? ? extract_links_from_sitemap : extract_links_from_html
|
||||
end
|
||||
@@ -15,7 +19,7 @@ class Captain::Tools::SimplePageCrawlService
|
||||
@parser.title
|
||||
end
|
||||
|
||||
def body_text_content
|
||||
def body_markdown
|
||||
@parser.body_markdown
|
||||
end
|
||||
|
||||
@@ -35,6 +39,21 @@ class Captain::Tools::SimplePageCrawlService
|
||||
|
||||
private
|
||||
|
||||
def fetch_body
|
||||
body = ''
|
||||
SafeFetch.fetch(external_link, validate_content_type: false) do |result|
|
||||
body = result.tempfile.read
|
||||
end
|
||||
@status_code = 200
|
||||
body
|
||||
rescue SafeFetch::HttpError => e
|
||||
@status_code = e.message.to_i
|
||||
''
|
||||
rescue SafeFetch::Error
|
||||
@status_code = nil
|
||||
''
|
||||
end
|
||||
|
||||
def sitemap?
|
||||
@external_link.end_with?('.xml')
|
||||
end
|
||||
|
||||
+25
-14
@@ -1,4 +1,9 @@
|
||||
module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||
module Enterprise::Whatsapp::Providers::WhatsappCloudService
|
||||
# Calls API + the call_permission_request interactive message both require Graph
|
||||
# API v17+; OSS phone_id_path is locked at v13.0 for legacy /messages compatibility.
|
||||
# Use the configured global version (defaulting to v22.0) for call-flow endpoints.
|
||||
WHATSAPP_CALLING_API_VERSION_FALLBACK = 'v22.0'.freeze
|
||||
|
||||
def pre_accept_call(call_id, sdp_answer)
|
||||
call_api('pre_accept_call', call_action_body(call_id, 'pre_accept', sdp_answer))
|
||||
end
|
||||
@@ -8,16 +13,16 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||
end
|
||||
|
||||
def reject_call(call_id)
|
||||
call_api('reject_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'reject' })
|
||||
call_api('reject_call', call_action_body(call_id, 'reject'))
|
||||
end
|
||||
|
||||
def terminate_call(call_id)
|
||||
call_api('terminate_call', { messaging_product: 'whatsapp', call_id: call_id, action: 'terminate' })
|
||||
call_api('terminate_call', call_action_body(call_id, 'terminate'))
|
||||
end
|
||||
|
||||
def send_call_permission_request(to_phone_number, body_text = 'We would like to call you regarding your conversation.')
|
||||
def send_call_permission_request(to_phone_number, body_text = I18n.t('conversations.messages.whatsapp.call_permission_request_body'))
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text).to_json
|
||||
"#{calls_phone_id_path}/messages", headers: api_headers, body: permission_request_body(to_phone_number, body_text)
|
||||
)
|
||||
|
||||
unless response.success?
|
||||
@@ -30,13 +35,19 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||
|
||||
def initiate_call(to_phone_number, sdp_offer)
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer).to_json
|
||||
"#{calls_phone_id_path}/calls", headers: api_headers, body: initiate_call_body(to_phone_number, sdp_offer)
|
||||
)
|
||||
process_initiate_call_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calls_phone_id_path
|
||||
base = ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
version = GlobalConfigService.load('WHATSAPP_API_VERSION', WHATSAPP_CALLING_API_VERSION_FALLBACK)
|
||||
"#{base}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||
end
|
||||
|
||||
def call_action_body(call_id, action, sdp_answer = nil)
|
||||
body = { messaging_product: 'whatsapp', call_id: call_id, action: action }
|
||||
body[:session] = { sdp: sdp_answer, sdp_type: 'answer' } if sdp_answer
|
||||
@@ -44,7 +55,7 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||
end
|
||||
|
||||
def call_api(action_name, body)
|
||||
url = "#{phone_id_path}/calls"
|
||||
url = "#{calls_phone_id_path}/calls"
|
||||
Rails.logger.info "[WHATSAPP CALL] #{action_name} POST #{url} body=#{body.except(:session).to_json}"
|
||||
response = HTTParty.post(url, headers: api_headers, body: body.to_json)
|
||||
Rails.logger.error "[WHATSAPP CALL] #{action_name} failed: status=#{response.code} body=#{response.body}" unless response.success?
|
||||
@@ -60,26 +71,26 @@ module Whatsapp::Providers::WhatsappCloudCallMethods
|
||||
action: { name: 'call_permission_request' },
|
||||
body: { text: body_text }
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def initiate_call_body(to_phone_number, sdp_offer)
|
||||
{
|
||||
messaging_product: 'whatsapp', to: to_phone_number, type: 'audio',
|
||||
session: { sdp: sdp_offer, sdp_type: 'offer' }
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def process_initiate_call_response(response)
|
||||
return response.parsed_response if response.success?
|
||||
|
||||
parsed = response.parsed_response
|
||||
error_code = parsed&.dig('error', 'code')
|
||||
error_msg = parsed&.dig('error', 'error_user_msg') || 'Failed to initiate call'
|
||||
Rails.logger.error "[WHATSAPP CALL] initiate_call failed: status=#{response.code} body=#{response.body}"
|
||||
parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
|
||||
error_code = parsed.dig('error', 'code')
|
||||
error_msg = parsed.dig('error', 'error_user_msg') || 'Failed to initiate call'
|
||||
|
||||
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == 138_006
|
||||
raise Voice::CallErrors::NoCallPermission, error_msg if error_code == Voice::CallErrors::NO_CALL_PERMISSION_CODE
|
||||
|
||||
raise StandardError, error_msg
|
||||
raise Voice::CallErrors::CallFailed, error_msg
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
# Whisper API rejects files larger than 25 MB. At Opus 48 kbps that is ~70
|
||||
# minutes of audio — longer recordings keep the audio attachment but skip
|
||||
# transcription rather than failing with an OpenAI 413.
|
||||
WHISPER_BYTE_LIMIT = 25.megabytes
|
||||
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
@@ -19,7 +15,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
def perform
|
||||
return { error: 'Transcription limit exceeded' } unless can_transcribe?
|
||||
return { error: 'Message not found' } if message.blank?
|
||||
return { error: 'Audio too large for Whisper' } if audio_too_large?
|
||||
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
@@ -38,13 +33,6 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def audio_too_large?
|
||||
blob = attachment.file&.blob
|
||||
return false unless blob
|
||||
|
||||
blob.byte_size > WHISPER_BYTE_LIMIT
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
blob = attachment.file.blob
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
@@ -75,16 +63,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
transcribed_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
# temperature: 0.0 minimises Whisper's hallucinations on ambiguous or
|
||||
# low-amplitude segments. The previous value (0.4) triggered spiraling
|
||||
# repetitions like "Oh, dear. Oh, dear. Oh, dear." on silences and
|
||||
# "No. No. No." / "Hello. Hello. Hello." on near-silent audio —
|
||||
# well-documented Whisper behaviour at non-zero temperatures. 0.0
|
||||
# matches OpenAI's own default recommendation.
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: WHISPER_MODEL,
|
||||
file: file,
|
||||
# 0.0 minimises Whisper's hallucinations on silence / sub-vocal segments —
|
||||
# higher temperatures produce phantom phrases that look like real transcripts.
|
||||
temperature: 0.0
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,18 +11,18 @@ class Twilio::VoiceTeardownService
|
||||
private
|
||||
|
||||
def delete_twiml_app
|
||||
twilio_client.applications(channel.twiml_app_sid).delete
|
||||
channel.client.applications(channel.twiml_app_sid).delete
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
|
||||
end
|
||||
|
||||
def clear_number_webhooks
|
||||
numbers = twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
return if numbers.empty?
|
||||
|
||||
twilio_client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(voice_url: '', status_callback: '')
|
||||
channel.client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(voice_url: '', status_callback: '')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}")
|
||||
end
|
||||
@@ -30,12 +30,4 @@ class Twilio::VoiceTeardownService
|
||||
def clear_voice_credentials
|
||||
channel.update(twiml_app_sid: nil)
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
|
||||
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
else
|
||||
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService
|
||||
private
|
||||
|
||||
def validate_token_credentials!
|
||||
validation_client = channel.api_key_sid.present? ? api_key_client : token_client
|
||||
validation_client.incoming_phone_numbers.list(limit: 1)
|
||||
channel.client.incoming_phone_numbers.list(limit: 1)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
||||
raise
|
||||
@@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService
|
||||
|
||||
def create_twiml_app!
|
||||
friendly_name = "Chatwoot Voice #{channel.phone_number}"
|
||||
app = api_key_client.applications.create(
|
||||
app = channel.client.applications.create(
|
||||
friendly_name: friendly_name,
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD
|
||||
@@ -38,33 +37,25 @@ class Twilio::VoiceWebhookSetupService
|
||||
end
|
||||
|
||||
def configure_number_webhooks!
|
||||
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
if numbers.empty?
|
||||
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
||||
return
|
||||
end
|
||||
|
||||
api_key_client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD,
|
||||
status_callback: channel.voice_status_webhook_url,
|
||||
status_callback_method: HTTP_METHOD
|
||||
)
|
||||
channel.client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD,
|
||||
status_callback: channel.voice_status_webhook_url,
|
||||
status_callback_method: HTTP_METHOD
|
||||
)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def api_key_client
|
||||
@api_key_client ||= ::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
end
|
||||
|
||||
def token_client
|
||||
@token_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
details = build_error_details(context, error)
|
||||
add_twilio_specific_details(details, error)
|
||||
|
||||
@@ -1,119 +1,52 @@
|
||||
class Voice::CallMessageBuilder
|
||||
# Maps Call model statuses to voice_call display statuses (hyphenated for frontend)
|
||||
CALL_TO_VOICE_STATUS = {
|
||||
'ringing' => 'ringing',
|
||||
'in_progress' => 'in-progress',
|
||||
'failed' => 'failed',
|
||||
'no_answer' => 'no-answer',
|
||||
'completed' => 'completed'
|
||||
}.freeze
|
||||
|
||||
def self.create!(conversation:, call:, user: nil)
|
||||
new(conversation: conversation, call: call, user: user).create!
|
||||
end
|
||||
|
||||
# Backwards-compatible entry used by Voice::OutboundCallBuilder (Twilio).
|
||||
def self.perform!(call:)
|
||||
create!(conversation: call.conversation, call: call, user: call.accepted_by_agent)
|
||||
end
|
||||
|
||||
def self.update_status!(call:, status: nil, agent: nil, duration_seconds: nil)
|
||||
new(conversation: call.conversation, call: call).update_status!(
|
||||
status: status, agent: agent, duration_seconds: duration_seconds
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(conversation:, call:, user: nil)
|
||||
@conversation = conversation
|
||||
def initialize(call)
|
||||
@call = call
|
||||
@user = user
|
||||
end
|
||||
|
||||
def create!
|
||||
# Direct create rather than Messages::MessageBuilder because the latter
|
||||
# rejects `incoming` messages on non-Api inboxes — that gate is meant for
|
||||
# human messages flowing through chatbot-style integrations, but a voice
|
||||
# call bubble is a system event that needs to live on the WhatsApp inbox.
|
||||
Message.create!(
|
||||
conversation: conversation,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: message_content,
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => build_data_payload },
|
||||
sender: sender
|
||||
)
|
||||
def perform!
|
||||
call.message || create_message!
|
||||
end
|
||||
|
||||
def update_status!(status:, agent: nil, duration_seconds: nil)
|
||||
message = call.message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = map_status(status) if status
|
||||
data['data']['accepted_by'] = { 'id' => agent.id, 'name' => agent.name } if agent
|
||||
data['data']['duration_seconds'] = duration_seconds if duration_seconds
|
||||
patch = {
|
||||
'status' => status&.to_s&.tr('_', '-'),
|
||||
'accepted_by' => agent && { 'id' => agent.id, 'name' => agent.name },
|
||||
'duration_seconds' => duration_seconds
|
||||
}.compact
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
message.update!(content_attributes: (message.content_attributes || {}).deep_merge('data' => patch))
|
||||
message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :call, :user
|
||||
attr_reader :call
|
||||
|
||||
# `call_source` lets the FE disambiguate WhatsApp vs Twilio for UI copy
|
||||
# and event routing without loading the whole Call record client-side.
|
||||
# `media_server_enabled` is hard-coded to false because WhatsApp now runs
|
||||
# browser↔Meta WebRTC directly (no media server) and Twilio's bubble
|
||||
# never gates on this flag. Rejoin is unsupported across the board.
|
||||
def build_data_payload
|
||||
{
|
||||
'call_sid' => call.provider_call_id,
|
||||
'status' => map_status(call.status),
|
||||
'call_direction' => call.direction_label,
|
||||
'call_source' => call.provider,
|
||||
'call_id' => call.id,
|
||||
'from_number' => from_number,
|
||||
'to_number' => to_number,
|
||||
'media_server_enabled' => false,
|
||||
'meta' => { 'created_at' => Time.zone.now.to_i }
|
||||
def create_message!
|
||||
params = {
|
||||
content: I18n.t("conversations.messages.voice_call.#{call.provider}"),
|
||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => build_data_payload }
|
||||
}
|
||||
end
|
||||
|
||||
def message_content
|
||||
call.twilio? ? 'Voice Call' : 'WhatsApp Call'
|
||||
end
|
||||
|
||||
def message_type
|
||||
call.outgoing? ? 'outgoing' : 'incoming'
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if call.outgoing? && user
|
||||
|
||||
conversation.contact
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
end
|
||||
|
||||
def from_number
|
||||
if call.incoming?
|
||||
conversation.contact&.phone_number
|
||||
else
|
||||
conversation.inbox.channel&.phone_number
|
||||
end
|
||||
end
|
||||
|
||||
def to_number
|
||||
if call.incoming?
|
||||
conversation.inbox.channel&.phone_number
|
||||
else
|
||||
conversation.contact&.phone_number
|
||||
end
|
||||
end
|
||||
|
||||
def map_status(status)
|
||||
CALL_TO_VOICE_STATUS[status] || status
|
||||
# call_source lets the FE disambiguate WhatsApp vs Twilio without re-fetching the Call.
|
||||
def build_data_payload
|
||||
{
|
||||
'call_id' => call.id,
|
||||
'call_sid' => call.provider_call_id,
|
||||
'call_source' => call.provider,
|
||||
'call_direction' => call.direction_label,
|
||||
'status' => call.display_status
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user