Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2921db5bd4 | ||
|
|
3d7567bf23 | ||
|
|
cd9c8e3303 | ||
|
|
80fccbc526 | ||
|
|
bcdb73502e | ||
|
|
d634ced4e9 | ||
|
|
e723c6b6f2 | ||
|
|
5325e05143 | ||
|
|
c09206c22e | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 |
@@ -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
|
||||
@@ -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" />
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { useCallsStore } from 'dashboard/stores/calls';
|
||||
@@ -34,9 +34,7 @@ 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);
|
||||
|
||||
|
||||
+3
-6
@@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
@@ -30,6 +28,7 @@ const props = defineProps({
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
voiceEnabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,7 +108,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
if (newValue && !props.voiceEnabled) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
+4
-1
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
@@ -442,6 +444,7 @@ useKeyboardEvents({
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:voice-enabled="voiceCallEnabled"
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
@@ -40,6 +41,16 @@ const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
const isHomeDashboardEnabled = computed(
|
||||
() => !!uiSettings.value?.home_dashboard_enabled
|
||||
);
|
||||
const unreadInboxCount = useMapGetter('notifications/getUnreadCount');
|
||||
const unreadInboxCountLabel = computed(() => {
|
||||
const count = unreadInboxCount.value;
|
||||
if (!count) return '';
|
||||
return count < 100 ? String(count) : '99+';
|
||||
});
|
||||
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
@@ -212,16 +223,28 @@ const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
...(isHomeDashboardEnabled.value
|
||||
? [
|
||||
{
|
||||
name: 'Home',
|
||||
label: t('SIDEBAR.HOME'),
|
||||
icon: 'i-lucide-gauge',
|
||||
to: accountScopedRoute('home_dashboard'),
|
||||
activeOn: ['home_dashboard', 'home_dashboard_admin'],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: 'Conversation',
|
||||
label: t('SIDEBAR.CONVERSATIONS'),
|
||||
@@ -858,6 +881,20 @@ const menuItems = computed(() => {
|
||||
:is-collapsed="isEffectivelyCollapsed"
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
<RouterLink
|
||||
v-if="isHomeDashboardEnabled"
|
||||
:to="accountScopedRoute('inbox_view')"
|
||||
:title="t('SIDEBAR.INBOX')"
|
||||
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative text-n-slate-11"
|
||||
>
|
||||
<span class="i-lucide-bell size-4" />
|
||||
<span
|
||||
v-if="unreadInboxCountLabel"
|
||||
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
|
||||
>
|
||||
{{ unreadInboxCountLabel }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Resize Handle (desktop only) -->
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,11 @@ const mockStore = createStore({
|
||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||
14: {
|
||||
id: 14,
|
||||
channel_type: INBOX_TYPES.TWILIO,
|
||||
voice_enabled: true,
|
||||
},
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
@@ -211,11 +215,11 @@ describe('useInbox', () => {
|
||||
});
|
||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||
|
||||
// Test Voice
|
||||
// Test Voice (Twilio with voice_enabled)
|
||||
wrapper = mount(createTestComponent(14), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
||||
|
||||
// Test Tiktok
|
||||
wrapper = mount(createTestComponent(15), {
|
||||
@@ -274,7 +278,8 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'isAVoiceChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
];
|
||||
|
||||
expectedProperties.forEach(prop => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
|
||||
return {
|
||||
inbox,
|
||||
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAVoiceChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,11 +109,6 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Tiktok': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CHANNEL_VOICE: 'channel_voice',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,9 +11,29 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
if (!inbox) return null;
|
||||
|
||||
// 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;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
@@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
@@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TIKTOK:
|
||||
return 'brand-tiktok';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"INBOX": {
|
||||
"LIST": {
|
||||
"TITLE": "My Inbox",
|
||||
"TITLE": "Notifications",
|
||||
"DISPLAY_DROPDOWN": "Display",
|
||||
"LOADING": "Fetching notifications",
|
||||
"404": "There are no active notifications in this group.",
|
||||
|
||||
@@ -636,7 +636,17 @@
|
||||
"WIDGET_BUILDER": "Widget Builder",
|
||||
"BOT_CONFIGURATION": "Bot Configuration",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Voice"
|
||||
},
|
||||
"VOICE_CONFIGURATION": {
|
||||
"ENABLE_VOICE": {
|
||||
"LABEL": "Enable Voice Calling",
|
||||
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
|
||||
},
|
||||
"CREDENTIALS": {
|
||||
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
|
||||
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
|
||||
"USE_ACCOUNT_DEFAULT": "Use account default"
|
||||
},
|
||||
"HOME_DASHBOARD": {
|
||||
"TITLE": "Home dashboard",
|
||||
"NOTE": "Show a Home dashboard with team workload, KPIs, and a Captain prompt as your landing page. Hides the My Inbox sidebar item."
|
||||
}
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
@@ -228,6 +232,128 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HOME_DASHBOARD_PAGE": {
|
||||
"ROLE_SWITCH": {
|
||||
"AGENT": "Agent",
|
||||
"ADMIN": "Admin"
|
||||
},
|
||||
"GREETING": {
|
||||
"MORNING": "Good morning",
|
||||
"AFTERNOON": "Good afternoon",
|
||||
"EVENING": "Good evening",
|
||||
"LATE": "Working late",
|
||||
"FALLBACK_NAME": "there",
|
||||
"GREETING_LINE": "{greeting}, ",
|
||||
"NAME_LINE": "{name}."
|
||||
},
|
||||
"TEAM_SUMMARY": {
|
||||
"PREFIX": "Your team has ",
|
||||
"OPEN_CONVERSATIONS": "{count} open conversations",
|
||||
"MID": ", ",
|
||||
"NEARING_SLA": "{count} nearing SLA",
|
||||
"AND": ", and ",
|
||||
"AGENTS_ONLINE": "{count} agents online",
|
||||
"PERIOD": "."
|
||||
},
|
||||
"AGENT_SUMMARY": {
|
||||
"PREFIX": "You have ",
|
||||
"OPEN_CONVERSATIONS": "{count} open conversations",
|
||||
"MID": ", ",
|
||||
"UNATTENDED": "{count} unattended",
|
||||
"AND": ", and ",
|
||||
"NEARING_SLA": "{count} close to SLA breach",
|
||||
"PERIOD": "."
|
||||
},
|
||||
"KPI": {
|
||||
"OPEN_CONVERSATIONS": "Open conversations",
|
||||
"AVG_FIRST_RESPONSE": "Avg first response",
|
||||
"FIRST_RESPONSE": "First response",
|
||||
"RESOLVED_TODAY": "Resolved today",
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
"SLA_BREACHES_TODAY": "SLA breaches today",
|
||||
"TEAM_CSAT_TODAY": "Team CSAT today",
|
||||
"SEE_ALL": "See all"
|
||||
},
|
||||
"AI": {
|
||||
"SHORTCUT": "⌘K"
|
||||
},
|
||||
"TEAM_WORKLOAD": {
|
||||
"TITLE": "Team workload",
|
||||
"SORT_BY": "Sort by",
|
||||
"HEADERS": {
|
||||
"AGENT": "Agent",
|
||||
"WORKLOAD": "Workload",
|
||||
"UNATTENDED_OPEN": "Unattended / Open",
|
||||
"FRT": "FRT",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"OFF_SHIFT": "Off shift",
|
||||
"CAPACITY": "{percent}% capacity",
|
||||
"YOU": "YOU"
|
||||
},
|
||||
"ATTENTION": {
|
||||
"TITLE": "Needs your attention",
|
||||
"SUBTITLE": "3 items, surfaced by Captain"
|
||||
},
|
||||
"SUGGESTIONS_ADMIN": {
|
||||
"OVERLOADED": "Who's overloaded right now?",
|
||||
"HIGH_VOLUME": "What's driving the {count} open conversations today?",
|
||||
"CSAT_OUTLIERS": "Show CSAT outliers this week",
|
||||
"TEAM_PERFORMANCE": "Summarize team performance today",
|
||||
"COACH_AGENT": "Draft a coaching note for {name}",
|
||||
"QUIET_DAY": "What did the team accomplish today?",
|
||||
"FIND_PATTERNS": "Trending customer questions"
|
||||
},
|
||||
"SUGGESTIONS_AGENT": {
|
||||
"SUMMARIZE_QUEUE": "Summarize my queue",
|
||||
"TRIAGE_UNATTENDED": "Help me triage my {count} unattended",
|
||||
"NEAR_SLA": "Which of my conversations are close to SLA breach?",
|
||||
"URGENT": "What's most urgent right now?",
|
||||
"SUMMARIZE_CONVERSATION": "Summarize my conversation with {name}",
|
||||
"CATCH_UP": "Catch me up on what I missed",
|
||||
"SLOW_DAY": "What can I work on?"
|
||||
},
|
||||
"AI_PLACEHOLDERS_ADMIN": {
|
||||
"DEFAULT": "Ask Captain about your team…",
|
||||
"SUMMARIZE": "Summarize the three most urgent tickets…",
|
||||
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
|
||||
"WHY_CSAT": "Why did CSAT slip today?",
|
||||
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
|
||||
},
|
||||
"AI_PLACEHOLDERS_AGENT": {
|
||||
"DEFAULT": "Ask anything about your queue…",
|
||||
"SUMMARIZE": "Summarize the three most urgent tickets…",
|
||||
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
|
||||
"WHY_CSAT": "Why did CSAT slip today?",
|
||||
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
|
||||
},
|
||||
"SORT_OPTIONS_ADMIN": {
|
||||
"CAPACITY": "Capacity",
|
||||
"FIRST_RESPONSE": "First response",
|
||||
"CSAT_TODAY": "CSAT today",
|
||||
"RESOLVED_TODAY": "Resolved today"
|
||||
},
|
||||
"SORT_OPTIONS_AGENT": {
|
||||
"PRIORITY": "Priority",
|
||||
"SLA_BREACH": "SLA breach",
|
||||
"LATEST": "Latest activity",
|
||||
"OLDEST": "Oldest first",
|
||||
"CUSTOMER": "Customer name"
|
||||
},
|
||||
"TOP_CONVERSATIONS": {
|
||||
"TITLE": "Top conversations",
|
||||
"SLA_LEFT": "{time} left",
|
||||
"EMPTY": "Nothing in your queue.",
|
||||
"LOADING": "Loading…"
|
||||
},
|
||||
"COPILOT_THREAD": {
|
||||
"BACK": "Back to home",
|
||||
"SEND": "Send"
|
||||
},
|
||||
"RECENT_THREADS": {
|
||||
"TITLE": "Recent threads"
|
||||
}
|
||||
},
|
||||
"SIDEBAR_ITEMS": {
|
||||
"CHANGE_AVAILABILITY_STATUS": "Change",
|
||||
"CHANGE_ACCOUNTS": "Switch account",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as companyRoutes } from './companies/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { routes as inboxRoutes } from './inbox/routes';
|
||||
import { routes as homeRoutes } from './home/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
import campaignsRoutes from './campaigns/campaigns.routes';
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
component: AppContainer,
|
||||
children: [
|
||||
...captainRoutes,
|
||||
...homeRoutes,
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||
import CopilotAgentMessage from 'dashboard/components-next/copilot/CopilotAgentMessage.vue';
|
||||
import CopilotAssistantMessage from 'dashboard/components-next/copilot/CopilotAssistantMessage.vue';
|
||||
import CopilotThinkingGroup from 'dashboard/components-next/copilot/CopilotThinkingGroup.vue';
|
||||
import CopilotLoader from 'dashboard/components-next/copilot/CopilotLoader.vue';
|
||||
import CopilotInput from 'dashboard/components-next/copilot/CopilotInput.vue';
|
||||
import ToggleCopilotAssistant from 'dashboard/components-next/copilot/ToggleCopilotAssistant.vue';
|
||||
|
||||
const props = defineProps({
|
||||
threadId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountScopedRoute } = useAccount();
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const messages = computed(() =>
|
||||
store.getters['copilotMessages/getMessagesByThreadId'](props.threadId)
|
||||
);
|
||||
|
||||
const activeAssistant = computed(() => {
|
||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||
if (preferredId) {
|
||||
const found = assistants.value.find(a => a.id === preferredId);
|
||||
if (found) return found;
|
||||
}
|
||||
return assistants.value[0];
|
||||
});
|
||||
|
||||
const setAssistant = async assistant => {
|
||||
await updateUISettings({
|
||||
preferred_captain_assistant_id: assistant.id,
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
try {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value?.id,
|
||||
threadId: props.threadId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
router.push(accountScopedRoute('home_dashboard'));
|
||||
};
|
||||
|
||||
const groupedMessages = computed(() => {
|
||||
const result = [];
|
||||
let thinkingGroup = [];
|
||||
messages.value.forEach(message => {
|
||||
if (message.message_type === 'assistant_thinking') {
|
||||
thinkingGroup.push(message);
|
||||
} else {
|
||||
if (thinkingGroup.length) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
thinkingGroup = [];
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
});
|
||||
if (thinkingGroup.length) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const isLastMessageFromAssistant = computed(() => {
|
||||
const last = groupedMessages.value[groupedMessages.value.length - 1];
|
||||
return last?.message_type === 'assistant';
|
||||
});
|
||||
|
||||
const chatContainer = ref(null);
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => messages.value, scrollToBottom, { deep: true });
|
||||
|
||||
onMounted(async () => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
try {
|
||||
const { data } = await CopilotMessagesAPI.get(props.threadId);
|
||||
const payload = data?.payload || [];
|
||||
payload.forEach(message => {
|
||||
store.dispatch('copilotMessages/upsert', message);
|
||||
});
|
||||
scrollToBottom();
|
||||
} catch {
|
||||
// best-effort fetch; rely on websocket if API errors
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full bg-n-surface-1 text-n-slate-12">
|
||||
<div
|
||||
class="flex items-center gap-2 px-6 py-3 border-b border-n-weak flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md text-[13px] font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition-colors bg-transparent border-0 cursor-pointer"
|
||||
@click="goHome"
|
||||
>
|
||||
<span class="i-lucide-chevron-left size-4" />
|
||||
{{ $t('HOME_DASHBOARD_PAGE.COPILOT_THREAD.BACK') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="chatContainer"
|
||||
class="flex-1 min-h-0 w-full overflow-y-auto text-sm leading-6 tracking-tight"
|
||||
>
|
||||
<div class="w-full max-w-[820px] mx-auto px-6 py-6">
|
||||
<div
|
||||
v-if="groupedMessages.length"
|
||||
class="space-y-6 flex flex-col w-full"
|
||||
>
|
||||
<template v-for="(item, index) in groupedMessages" :key="item.id">
|
||||
<CopilotAgentMessage
|
||||
v-if="item.message_type === 'user'"
|
||||
:message="item.message"
|
||||
/>
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="item.message_type === 'assistant'"
|
||||
:message="item.message"
|
||||
:is-last-message="index === groupedMessages.length - 1"
|
||||
conversation-inbox-type=""
|
||||
/>
|
||||
<CopilotThinkingGroup
|
||||
v-else
|
||||
:messages="item.messages"
|
||||
:default-collapsed="isLastMessageFromAssistant"
|
||||
/>
|
||||
</template>
|
||||
<CopilotLoader v-if="!isLastMessageFromAssistant" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('HOME_DASHBOARD_PAGE.TOP_CONVERSATIONS.LOADING') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex-shrink-0">
|
||||
<div class="w-full max-w-[820px] mx-auto px-6 pt-px pb-4">
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<ToggleCopilotAssistant
|
||||
v-if="assistants.length > 1"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant || {}"
|
||||
@set-assistant="setAssistant"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
<CopilotInput
|
||||
v-if="assistants.length"
|
||||
class="w-full"
|
||||
@send="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { ROLES } from 'dashboard/constants/permissions.js';
|
||||
import HomeDashboard from './HomeDashboard.vue';
|
||||
import HomeCopilotThread from './HomeCopilotThread.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home'),
|
||||
name: 'home_dashboard',
|
||||
component: HomeDashboard,
|
||||
meta: {
|
||||
permissions: ROLES,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home/admin'),
|
||||
name: 'home_dashboard_admin',
|
||||
component: HomeDashboard,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home/copilot/:threadId'),
|
||||
name: 'home_copilot_thread',
|
||||
component: HomeCopilotThread,
|
||||
props: true,
|
||||
meta: {
|
||||
permissions: ROLES,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -145,6 +145,7 @@ const openDelete = inbox => {
|
||||
<ChannelName
|
||||
:channel-type="inbox.channel_type"
|
||||
:medium="inbox.medium"
|
||||
:voice-enabled="inbox.voice_enabled"
|
||||
class="text-body-main text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||
import BotConfiguration from './components/BotConfiguration.vue';
|
||||
@@ -46,6 +47,7 @@ export default {
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
VoiceConfigurationPage,
|
||||
CustomerSatisfactionPage,
|
||||
FacebookReauthorize,
|
||||
GreetingsEditor,
|
||||
@@ -169,19 +171,17 @@ export default {
|
||||
},
|
||||
];
|
||||
|
||||
if (!this.isAVoiceChannel) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'business-hours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
}
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'business-hours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
visibleToAllChannelTabs = [
|
||||
@@ -197,7 +197,6 @@ export default {
|
||||
this.isATwilioChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isAVoiceChannel ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
@@ -232,6 +231,24 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
this.isATwilioChannel &&
|
||||
this.inbox.phone_number &&
|
||||
this.inbox.medium === 'sms' &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHANNEL_VOICE
|
||||
)
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'voice-configuration',
|
||||
name: this.$t('INBOX_MGMT.TABS.VOICE'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -812,7 +829,6 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAVoiceChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1240,6 +1256,12 @@ export default {
|
||||
>
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabKey === 'voice-configuration'"
|
||||
class="mx-6 max-w-4xl"
|
||||
>
|
||||
<VoiceConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
<CustomerSatisfactionPage :inbox="inbox" />
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
voiceEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
@@ -30,7 +34,6 @@ const i18nMap = {
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Tiktok': 'TIKTOK',
|
||||
'Channel::Voice': 'VOICE',
|
||||
};
|
||||
|
||||
const twilioChannelName = () => {
|
||||
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
|
||||
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
|
||||
}
|
||||
if (props.channelType === 'Channel::TwilioSms') {
|
||||
if (props.voiceEnabled) {
|
||||
return t('INBOX_MGMT.CHANNELS.VOICE');
|
||||
}
|
||||
return twilioChannelName();
|
||||
}
|
||||
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
|
||||
|
||||
-18
@@ -208,24 +208,6 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel">
|
||||
<SettingsFieldSection
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsFieldSection,
|
||||
SettingsToggleSection,
|
||||
NextInput,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
voiceEnabled: this.inbox.voice_enabled || false,
|
||||
apiKeySid: this.inbox.api_key_sid || '',
|
||||
apiKeySecret: '',
|
||||
isUpdating: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isVoiceConfigured() {
|
||||
return !!this.inbox.voice_configured;
|
||||
},
|
||||
hasApiKeySid() {
|
||||
return !!this.inbox.api_key_sid;
|
||||
},
|
||||
hasExistingCredentials() {
|
||||
return this.hasApiKeySid && !!this.inbox.has_api_key_secret;
|
||||
},
|
||||
needsCredentials() {
|
||||
return (
|
||||
this.voiceEnabled &&
|
||||
!this.isVoiceConfigured &&
|
||||
!this.hasExistingCredentials
|
||||
);
|
||||
},
|
||||
needsApiKeySid() {
|
||||
return this.needsCredentials && !this.hasApiKeySid;
|
||||
},
|
||||
isSubmitDisabled() {
|
||||
if (!this.voiceEnabled) return false;
|
||||
if (this.needsCredentials) {
|
||||
if (this.needsApiKeySid && !this.apiKeySid) return true;
|
||||
return !this.apiKeySecret;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'inbox.voice_enabled'(val) {
|
||||
this.voiceEnabled = val || false;
|
||||
},
|
||||
'inbox.api_key_sid'(val) {
|
||||
this.apiKeySid = val || '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async updateVoiceSettings() {
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
const channelPayload = { voice_enabled: this.voiceEnabled };
|
||||
|
||||
if (this.needsCredentials) {
|
||||
if (this.needsApiKeySid) {
|
||||
channelPayload.api_key_sid = this.apiKeySid;
|
||||
}
|
||||
channelPayload.api_key_secret = this.apiKeySecret;
|
||||
}
|
||||
|
||||
await this.$store.dispatch('inboxes/updateInbox', {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: channelPayload,
|
||||
});
|
||||
this.apiKeySecret = '';
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsToggleSection
|
||||
v-model="voiceEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
|
||||
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
|
||||
</p>
|
||||
<NextInput
|
||||
v-if="needsApiKeySid"
|
||||
v-model="apiKeySid"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
|
||||
/>
|
||||
<NextInput
|
||||
v-model="apiKeySecret"
|
||||
type="password"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
|
||||
"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:disabled="isSubmitDisabled"
|
||||
:is-loading="isUpdating"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
@click="updateVoiceSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import UserBasicDetails from './UserBasicDetails.vue';
|
||||
import MessageSignature from './MessageSignature.vue';
|
||||
import FontSize from './FontSize.vue';
|
||||
import UserLanguageSelect from './UserLanguageSelect.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import HotKeyCard from './HotKeyCard.vue';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import NotificationPreferences from './NotificationPreferences.vue';
|
||||
@@ -43,13 +44,16 @@ export default {
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
BaseSettingsHeader,
|
||||
SettingsToggleSection,
|
||||
},
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
const { uiSettings, isEditorHotKeyEnabled, updateUISettings } =
|
||||
useUISettings();
|
||||
const { currentFontSize, updateFontSize } = useFontSize();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
currentFontSize,
|
||||
updateFontSize,
|
||||
isEditorHotKeyEnabled,
|
||||
@@ -103,6 +107,14 @@ export default {
|
||||
isMfaEnabled() {
|
||||
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
|
||||
},
|
||||
homeDashboardEnabled: {
|
||||
get() {
|
||||
return !!this.uiSettings?.home_dashboard_enabled;
|
||||
},
|
||||
set(value) {
|
||||
this.updateUISettings({ home_dashboard_enabled: value });
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.currentUserId) {
|
||||
@@ -248,6 +260,16 @@ export default {
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
|
||||
"
|
||||
/>
|
||||
<SettingsToggleSection
|
||||
v-model="homeDashboardEnabled"
|
||||
class="w-full"
|
||||
:header="
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.TITLE')
|
||||
"
|
||||
:description="
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.NOTE')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,9 @@ export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
const target = needsOnboarding ? 'onboarding' : 'dashboard';
|
||||
const homeDashboardEnabled = !!user?.ui_settings?.home_dashboard_enabled;
|
||||
const landingTarget = homeDashboardEnabled ? 'home' : 'dashboard';
|
||||
const target = needsOnboarding ? 'onboarding' : landingTarget;
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -59,8 +59,8 @@ export default {
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
voiceCallEnabled() {
|
||||
return isVoiceCallEnabled(this.inbox);
|
||||
},
|
||||
isAnEmailChannel() {
|
||||
return this.channelType === INBOX_TYPES.EMAIL;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_sid :string not null
|
||||
# api_key_secret :string
|
||||
# api_key_sid :string
|
||||
# auth_token :string not null
|
||||
# content_templates :jsonb
|
||||
@@ -11,6 +12,8 @@
|
||||
# medium :integer default("sms")
|
||||
# messaging_service_sid :string
|
||||
# phone_number :string
|
||||
# twiml_app_sid :string
|
||||
# voice_enabled :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
@@ -58,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)
|
||||
@@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_message_from
|
||||
if messaging_service_sid?
|
||||
{ messaging_service_sid: messaging_service_sid }
|
||||
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: platform_banners
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active :boolean default(TRUE)
|
||||
# banner_message :text not null
|
||||
# banner_type :integer default("info"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class PlatformBanner < ApplicationRecord
|
||||
enum :banner_type, { info: 0, warning: 1, error: 2 }
|
||||
|
||||
validates :banner_message, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
end
|
||||
@@ -50,6 +50,8 @@ class Messages::MentionService
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
next if self_mention?(user_id)
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: User.find(user_id),
|
||||
@@ -60,6 +62,10 @@ class Messages::MentionService
|
||||
end
|
||||
end
|
||||
|
||||
def self_mention?(user_id)
|
||||
message.sender_type == 'User' && user_id.to_i == message.sender_id
|
||||
end
|
||||
|
||||
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,7 @@ if resource.twilio?
|
||||
if Current.account_user&.administrator?
|
||||
json.auth_token resource.channel.try(:auth_token)
|
||||
json.account_sid resource.channel.try(:account_sid)
|
||||
json.api_key_sid resource.channel.try(:api_key_sid)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -131,8 +132,13 @@ if resource.whatsapp?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
end
|
||||
|
||||
## Voice Channel Attributes
|
||||
if resource.channel_type == 'Channel::Voice'
|
||||
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
## Voice attributes for TwilioSms
|
||||
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||
json.voice_enabled resource.channel.voice_enabled?
|
||||
json.voice_configured resource.channel.try(:twiml_app_sid).present?
|
||||
json.has_api_key_secret resource.channel.try(:api_key_secret).present?
|
||||
if resource.channel.try(:twiml_app_sid).present?
|
||||
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -379,6 +379,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}'
|
||||
|
||||
+4
-1
@@ -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
|
||||
@@ -651,6 +653,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member, action: :destroy_avatar
|
||||
end
|
||||
resources :platform_apps, only: [:index, :new, :create, :show, :edit, :update, :destroy]
|
||||
resources :platform_banners
|
||||
resource :instance_status, only: [:show]
|
||||
|
||||
resource :settings, only: [:show] do
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false
|
||||
add_column :channel_twilio_sms, :twiml_app_sid, :string
|
||||
add_column :channel_twilio_sms, :api_key_secret, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
class DropChannelVoice < ActiveRecord::Migration[7.0]
|
||||
def up
|
||||
drop_table :channel_voice, if_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :channel_voice do |t|
|
||||
t.string :phone_number, null: false
|
||||
t.string :provider, null: false, default: 'twilio'
|
||||
t.jsonb :provider_config, null: false
|
||||
t.integer :account_id, null: false
|
||||
t.jsonb :additional_attributes, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :channel_voice, :phone_number, unique: true
|
||||
add_index :channel_voice, :account_id
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
+58
-46
@@ -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"
|
||||
@@ -52,6 +52,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.boolean "auto_offline", default: true, null: false
|
||||
t.bigint "custom_role_id"
|
||||
t.bigint "agent_capacity_policy_id"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true
|
||||
t.index ["account_id"], name: "index_account_users_on_account_id"
|
||||
t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id"
|
||||
@@ -71,6 +72,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.jsonb "limits", default: {}
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.integer "status", default: 0
|
||||
t.integer "contactable_contacts_count", default: 0
|
||||
t.jsonb "internal_attributes", default: {}, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
t.index ["status"], name: "index_accounts_on_status"
|
||||
@@ -381,11 +383,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.integer "sync_status"
|
||||
t.datetime "last_synced_at"
|
||||
t.datetime "last_sync_attempted_at"
|
||||
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
|
||||
t.index ["account_id"], name: "index_captain_documents_on_account_id"
|
||||
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
|
||||
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
t.index ["account_id", "sync_status"], name: "index_captain_documents_on_account_id_and_sync_status"
|
||||
end
|
||||
|
||||
create_table "captain_inboxes", force: :cascade do |t|
|
||||
@@ -552,6 +554,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.string "api_key_sid"
|
||||
t.jsonb "content_templates", default: {}
|
||||
t.datetime "content_templates_last_updated"
|
||||
t.boolean "voice_enabled", default: false, null: false
|
||||
t.string "twiml_app_sid"
|
||||
t.string "api_key_secret"
|
||||
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
|
||||
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
|
||||
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
|
||||
@@ -568,18 +573,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_voice", force: :cascade do |t|
|
||||
t.string "phone_number", null: false
|
||||
t.string "provider", default: "twilio", null: false
|
||||
t.jsonb "provider_config", null: false
|
||||
t.integer "account_id", null: false
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_channel_voice_on_account_id"
|
||||
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
|
||||
t.string "website_url"
|
||||
t.integer "account_id"
|
||||
@@ -620,13 +613,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contacts_count"
|
||||
t.integer "contacts_count", default: 0, null: false
|
||||
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||
end
|
||||
|
||||
create_table "contact_inboxes", force: :cascade do |t|
|
||||
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.bigint "contact_id"
|
||||
t.bigint "inbox_id"
|
||||
t.text "source_id", null: false
|
||||
@@ -634,14 +627,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "hmac_verified", default: false
|
||||
t.string "pubsub_token"
|
||||
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
|
||||
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
|
||||
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
|
||||
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
|
||||
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
|
||||
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
|
||||
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
|
||||
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
|
||||
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
|
||||
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
|
||||
end
|
||||
|
||||
create_table "contacts", id: :serial, force: :cascade do |t|
|
||||
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.string "name", default: ""
|
||||
t.string "email"
|
||||
t.string "phone_number"
|
||||
@@ -652,25 +645,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.string "identifier"
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.integer "contact_type", default: 0
|
||||
t.boolean "resolved", default: false, null: false
|
||||
t.integer "contact_type", default: 0, null: false
|
||||
t.string "middle_name", default: ""
|
||||
t.string "last_name", default: ""
|
||||
t.string "location", default: ""
|
||||
t.string "country_code", default: ""
|
||||
t.boolean "blocked", default: false, null: false
|
||||
t.bigint "company_id"
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
|
||||
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
|
||||
t.index ["account_id"], name: "contacts2_account_id_idx"
|
||||
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["blocked"], name: "index_contacts_on_blocked"
|
||||
t.index ["company_id"], name: "index_contacts_on_company_id"
|
||||
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
|
||||
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
|
||||
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
|
||||
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
|
||||
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
|
||||
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
|
||||
end
|
||||
|
||||
create_table "conversation_participants", force: :cascade do |t|
|
||||
@@ -698,7 +693,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.datetime "agent_last_seen_at", precision: nil
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.bigint "contact_inbox_id"
|
||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
||||
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
|
||||
t.string "identifier"
|
||||
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
t.bigint "team_id"
|
||||
@@ -712,6 +707,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.bigint "last_message_id"
|
||||
t.bigint "last_incoming_message_id"
|
||||
t.bigint "last_non_activity_message_id"
|
||||
t.text "cached_summary"
|
||||
t.datetime "cached_summary_at"
|
||||
t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
@@ -723,6 +724,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
|
||||
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
|
||||
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
|
||||
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
|
||||
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
|
||||
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
|
||||
t.index ["priority"], name: "index_conversations_on_priority"
|
||||
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
|
||||
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
|
||||
@@ -789,7 +793,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.string "regex_pattern"
|
||||
t.string "regex_cue"
|
||||
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
|
||||
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
|
||||
end
|
||||
|
||||
create_table "custom_filters", force: :cascade do |t|
|
||||
@@ -972,7 +975,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.integer "visibility", default: 0
|
||||
t.bigint "created_by_id"
|
||||
t.bigint "updated_by_id"
|
||||
t.jsonb "actions", default: {}, null: false
|
||||
t.jsonb "actions", default: "{}", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_macros_on_account_id"
|
||||
@@ -991,7 +994,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.index ["user_id"], name: "index_mentions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "messages", id: :serial, force: :cascade do |t|
|
||||
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.integer "account_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -1010,18 +1013,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.text "processed_message_content"
|
||||
t.jsonb "sentiment", default: {}
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
|
||||
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
|
||||
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
|
||||
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
|
||||
t.index ["account_id"], name: "index_messages_on_account_id"
|
||||
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
|
||||
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
|
||||
t.index ["created_at"], name: "index_messages_on_created_at"
|
||||
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
|
||||
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
|
||||
t.index ["source_id"], name: "index_messages_on_source_id"
|
||||
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
|
||||
t.index ["account_id"], name: "messages2_account_id_idx"
|
||||
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
|
||||
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
|
||||
t.index ["created_at"], name: "messages2_created_at_idx"
|
||||
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
|
||||
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
|
||||
t.index ["source_id"], name: "messages2_source_id_idx"
|
||||
end
|
||||
|
||||
create_table "notes", force: :cascade do |t|
|
||||
@@ -1096,6 +1099,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
|
||||
@@ -1145,6 +1156,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.float "value_in_business_hours"
|
||||
t.datetime "event_start_time", precision: nil
|
||||
t.datetime "event_end_time", precision: nil
|
||||
t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox"
|
||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
@@ -1278,7 +1290,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do
|
||||
t.text "message_signature"
|
||||
t.string "otp_secret"
|
||||
t.integer "consumed_timestep"
|
||||
t.boolean "otp_required_for_login", default: false
|
||||
t.boolean "otp_required_for_login", default: false, null: false
|
||||
t.text "otp_backup_codes"
|
||||
t.index ["email"], name: "index_users_on_email"
|
||||
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
|
||||
|
||||
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
|
||||
private
|
||||
|
||||
def generate_source_id
|
||||
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||
return super unless twilio_voice_inbox?
|
||||
|
||||
phone_source_id
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||
return super unless twilio_voice_inbox?
|
||||
|
||||
return SecureRandom.uuid if @contact.phone_number.blank?
|
||||
|
||||
@@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
super || @inbox.channel_type == 'Channel::Voice'
|
||||
super || twilio_voice_inbox?
|
||||
end
|
||||
|
||||
def twilio_voice_inbox?
|
||||
@inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
|
||||
return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def twilio_voice_inbox?
|
||||
inbox = @conversation.inbox
|
||||
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.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
|
||||
|
||||
@@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
end
|
||||
|
||||
def voice_inbox
|
||||
@voice_inbox ||= Current.user.assigned_inboxes.where(
|
||||
account_id: Current.account.id,
|
||||
channel_type: 'Channel::Voice'
|
||||
).find(params.require(:inbox_id))
|
||||
@voice_inbox ||= begin
|
||||
inbox = Current.user.assigned_inboxes.where(
|
||||
account_id: Current.account.id,
|
||||
channel_type: 'Channel::TwilioSms'
|
||||
).find(params.require(:inbox_id))
|
||||
raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled?
|
||||
|
||||
inbox
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
||||
end
|
||||
|
||||
def channel_type_from_params
|
||||
case permitted_params[:channel][:type]
|
||||
when 'voice'
|
||||
Channel::Voice
|
||||
else
|
||||
super
|
||||
end
|
||||
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def account_channels_method
|
||||
case permitted_params[:channel][:type]
|
||||
when 'voice'
|
||||
Current.account.voice_channels
|
||||
else
|
||||
super
|
||||
end
|
||||
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return create_voice_channel if permitted_params[:channel][:type] == 'voice'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
attrs = super
|
||||
attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms'
|
||||
attrs
|
||||
end
|
||||
|
||||
def create_voice_channel
|
||||
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice')
|
||||
|
||||
voice_params = params.require(:channel).permit(
|
||||
:phone_number, :provider,
|
||||
provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret]
|
||||
)
|
||||
config = voice_params[:provider_config] || {}
|
||||
|
||||
Current.account.twilio_sms.create!(
|
||||
phone_number: voice_params[:phone_number],
|
||||
account_sid: config[:account_sid],
|
||||
auth_token: config[:auth_token],
|
||||
api_key_sid: config[:api_key_sid],
|
||||
api_key_secret: config[:api_key_secret],
|
||||
medium: :sms,
|
||||
voice_enabled: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -169,8 +169,10 @@ class Twilio::VoiceController < ApplicationController
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
channel = Channel::Voice.find_by!(phone_number: e164)
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: channel_voice
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# phone_number :string not null
|
||||
# provider :string default("twilio"), not null
|
||||
# provider_config :jsonb not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_channel_voice_on_account_id (account_id)
|
||||
# index_channel_voice_on_phone_number (phone_number) UNIQUE
|
||||
#
|
||||
class Channel::Voice < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
self.table_name = 'channel_voice'
|
||||
|
||||
validates :phone_number, presence: true, uniqueness: true
|
||||
validates :provider, presence: true
|
||||
validates :provider_config, presence: true
|
||||
|
||||
# Validate phone number format (E.164 format)
|
||||
validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
|
||||
|
||||
# Provider-specific configs stored in JSON
|
||||
validate :validate_provider_config
|
||||
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
|
||||
|
||||
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||
|
||||
def name
|
||||
"Voice (#{phone_number})"
|
||||
end
|
||||
|
||||
def messaging_window_enabled?
|
||||
false
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
case provider
|
||||
when 'twilio'
|
||||
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
|
||||
to: to,
|
||||
conference_sid: conference_sid,
|
||||
agent_id: agent_id
|
||||
)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio?
|
||||
provider == 'twilio'
|
||||
end
|
||||
|
||||
def validate_provider_config
|
||||
return if provider_config.blank?
|
||||
|
||||
case provider
|
||||
when 'twilio'
|
||||
validate_twilio_config
|
||||
end
|
||||
end
|
||||
|
||||
def validate_twilio_config
|
||||
config = provider_config.with_indifferent_access
|
||||
# Require credentials and provisioned TwiML App SID
|
||||
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
|
||||
required_keys.each do |key|
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
provider_config
|
||||
else
|
||||
JSON.parse(provider_config.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def provision_twilio_on_create
|
||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||
app_sid = service.perform
|
||||
return if app_sid.blank?
|
||||
|
||||
cfg = provider_config.with_indifferent_access
|
||||
cfg[:twiml_app_sid] = app_sid
|
||||
self.provider_config = cfg
|
||||
rescue StandardError => e
|
||||
error_details = {
|
||||
error_class: e.class.to_s,
|
||||
message: e.message,
|
||||
phone_number: phone_number,
|
||||
account_id: account_id,
|
||||
backtrace: e.backtrace&.first(5)
|
||||
}
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
||||
errors.add(:base, "Twilio setup failed: #{e.message}")
|
||||
end
|
||||
|
||||
public :provider_config_hash
|
||||
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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
module Enterprise::Channel::TwilioSms
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def self.prepended(base)
|
||||
base.class_eval do
|
||||
encrypts :api_key_secret if Chatwoot.encryption_configured?
|
||||
|
||||
validate :voice_requires_phone_number, if: :voice_enabled?
|
||||
before_validation :provision_twiml_app, on: :create, if: :voice_enabled?
|
||||
before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true?
|
||||
after_commit :teardown_voice, on: :update, if: :voice_disabled?
|
||||
end
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
|
||||
to: to,
|
||||
conference_sid: conference_sid,
|
||||
agent_id: agent_id
|
||||
)
|
||||
end
|
||||
|
||||
def voice_call_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
|
||||
end
|
||||
|
||||
# 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)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def voice_requires_phone_number
|
||||
return if phone_number.present?
|
||||
|
||||
errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID')
|
||||
end
|
||||
|
||||
def voice_enabled_changed_to_true?
|
||||
voice_enabled? && voice_enabled_changed?
|
||||
end
|
||||
|
||||
def voice_disabled?
|
||||
!voice_enabled? && voice_enabled_previously_changed?
|
||||
end
|
||||
|
||||
def teardown_voice
|
||||
Twilio::VoiceTeardownService.new(channel: self).perform
|
||||
end
|
||||
|
||||
def provision_twiml_app
|
||||
return if twiml_app_sid.present?
|
||||
return if phone_number.blank?
|
||||
|
||||
validate_voice_capability!
|
||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||
self.twiml_app_sid = service.perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}")
|
||||
errors.add(:base, "Twilio voice setup failed: #{e.message}")
|
||||
end
|
||||
|
||||
def validate_voice_capability!
|
||||
number = client.incoming_phone_numbers.list(phone_number: phone_number).first
|
||||
raise 'Phone number not found in Twilio account' unless number
|
||||
raise 'This phone number does not support voice calls' unless number.capabilities['voice']
|
||||
end
|
||||
|
||||
alias provision_twiml_app_on_update provision_twiml_app
|
||||
end
|
||||
@@ -16,7 +16,6 @@ module Enterprise::Concerns::Account
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
has_many :calls, dependent: :destroy_async
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
module Enterprise::Contacts::ContactableInboxesService
|
||||
private
|
||||
|
||||
# Extend base selection to include Voice inboxes
|
||||
# Extend base selection to include voice-enabled TwilioSms inboxes
|
||||
def get_contactable_inbox(inbox)
|
||||
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
|
||||
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class Twilio::VoiceTeardownService
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def perform
|
||||
delete_twiml_app if channel.twiml_app_sid.present?
|
||||
clear_number_webhooks
|
||||
ensure
|
||||
clear_voice_credentials
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_twiml_app
|
||||
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 = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
return if numbers.empty?
|
||||
|
||||
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
|
||||
|
||||
def clear_voice_credentials
|
||||
channel.update(twiml_app_sid: nil)
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService
|
||||
private
|
||||
|
||||
def validate_token_credentials!
|
||||
# Only validate Account SID + Auth Token
|
||||
token_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,39 +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 ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
|
||||
end
|
||||
end
|
||||
|
||||
def token_client
|
||||
@token_client ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
details = build_error_details(context, error)
|
||||
add_twilio_specific_details(details, error)
|
||||
@@ -80,11 +65,10 @@ class Twilio::VoiceWebhookSetupService
|
||||
end
|
||||
|
||||
def build_error_details(context, error)
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
{
|
||||
context: context,
|
||||
phone_number: channel.phone_number,
|
||||
account_sid: cfg[:account_sid],
|
||||
account_sid: channel.account_sid,
|
||||
error_class: error.class.to_s,
|
||||
message: error.message
|
||||
}
|
||||
|
||||
@@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= @channel.provider_config_hash
|
||||
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!, { twilio_client: nil }]
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
@@ -19,10 +19,11 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
end
|
||||
|
||||
def end_conference
|
||||
twilio_client
|
||||
client = conversation.inbox.channel.client
|
||||
client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
|
||||
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
@@ -31,16 +32,4 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
current = conversation.additional_attributes || {}
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
|
||||
end
|
||||
|
||||
def account_sid
|
||||
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
|
||||
end
|
||||
|
||||
def auth_token
|
||||
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
|
||||
token: access_token.to_jwt,
|
||||
identity: identity,
|
||||
voice_enabled: true,
|
||||
account_sid: config['account_sid'],
|
||||
account_sid: channel.account_sid,
|
||||
agent_id: user.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
phone_number: inbox.channel.phone_number,
|
||||
phone_number: channel.phone_number,
|
||||
twiml_endpoint: twiml_url,
|
||||
has_twiml_app: config['twiml_app_sid'].present?
|
||||
has_twiml_app: channel.twiml_app_sid.present?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def config
|
||||
@config ||= inbox.channel.provider_config_hash || {}
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def identity
|
||||
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
|
||||
|
||||
def access_token
|
||||
Twilio::JWT::AccessToken.new(
|
||||
config['account_sid'],
|
||||
config['api_key_sid'],
|
||||
config['api_key_secret'],
|
||||
channel.account_sid,
|
||||
channel.api_key_sid,
|
||||
channel.api_key_secret,
|
||||
identity: identity,
|
||||
ttl: 1.hour.to_i
|
||||
).tap { |token| token.add_grant(voice_grant) }
|
||||
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
|
||||
def voice_grant
|
||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||
grant.incoming_allow = true
|
||||
grant.outgoing_application_sid = config['twiml_app_sid']
|
||||
grant.outgoing_application_sid = channel.twiml_app_sid
|
||||
grant.outgoing_application_params = outgoing_params
|
||||
end
|
||||
end
|
||||
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
|
||||
agent_id: user.id,
|
||||
identity: identity,
|
||||
client_name: identity,
|
||||
accountSid: config['account_sid'],
|
||||
accountSid: channel.account_sid,
|
||||
is_agent: 'true'
|
||||
}
|
||||
end
|
||||
|
||||
def twiml_url
|
||||
digits = inbox.channel.phone_number.delete_prefix('+')
|
||||
digits = channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Enterprise-only Sidekiq cron schedule.
|
||||
# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
|
||||
# Add cron entries here when the referenced job class lives under enterprise/.
|
||||
|
||||
# Captain document auto-sync scheduler
|
||||
# Runs hourly, finds due documents based on plan sync intervals
|
||||
captain_documents_schedule_syncs_job:
|
||||
cron: '0 * * * *'
|
||||
class: 'Captain::Documents::ScheduleSyncsJob'
|
||||
queue: scheduled_jobs
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Slack::EmojiFormatter
|
||||
def self.format(text)
|
||||
return text if text.blank?
|
||||
|
||||
text.gsub(/:([a-zA-Z0-9_+-]+):/) do |match|
|
||||
short_code = Regexp.last_match(1)
|
||||
# gemoji exposes find_by_alias; Rails/DynamicFindBy is a false positive because Emoji is not an ActiveRecord model.
|
||||
# rubocop:disable Rails/DynamicFindBy
|
||||
emoji = Emoji.find_by_alias(short_code)
|
||||
# rubocop:enable Rails/DynamicFindBy
|
||||
emoji ? emoji.raw : match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -35,7 +35,7 @@ module Integrations::Slack::SlackMessageHelper
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
|
||||
content: formatted_message_content,
|
||||
external_source_id_slack: params[:event][:ts],
|
||||
private: private_note?,
|
||||
sender: resolved_sender,
|
||||
@@ -104,6 +104,11 @@ module Integrations::Slack::SlackMessageHelper
|
||||
[nil, nil, nil]
|
||||
end
|
||||
|
||||
def formatted_message_content
|
||||
text = Slack::Messages::Formatting.unescape(params[:event][:text] || '')
|
||||
Integrations::Slack::EmojiFormatter.format(text)
|
||||
end
|
||||
|
||||
def private_note?
|
||||
params[:event][:text].strip.downcase.starts_with?('note:', 'private:')
|
||||
end
|
||||
|
||||
+115
-3
@@ -19,7 +19,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
:captain_document,
|
||||
2,
|
||||
assistant: assistant,
|
||||
account: account
|
||||
account: account,
|
||||
status: :available
|
||||
)
|
||||
end
|
||||
|
||||
@@ -115,7 +116,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
}
|
||||
end
|
||||
|
||||
it 'deletes the documents and returns an empty array' do
|
||||
it 'deletes the documents and returns the deleted count' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: document_delete_params,
|
||||
@@ -124,7 +125,118 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
end.to change(Captain::Document, :count).by(-2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq([])
|
||||
expect(json_response).to eq({ count: documents.size })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when syncing documents' do
|
||||
let(:sync_params) do
|
||||
{
|
||||
type: 'AssistantDocument',
|
||||
ids: documents.map(&:id),
|
||||
fields: { status: 'sync' }
|
||||
}
|
||||
end
|
||||
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
it 'queues a sync for each web document and returns the enqueued document ids' do
|
||||
freeze_time do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
|
||||
|
||||
documents.each do |document|
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq({ ids: documents.map(&:id), count: documents.size })
|
||||
end
|
||||
|
||||
it 'skips PDF documents because they are not syncable' do
|
||||
pdf_document = build(:captain_document, assistant: assistant, account: account)
|
||||
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
|
||||
content_type: 'application/pdf')
|
||||
pdf_document.save!
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params.merge(ids: [pdf_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'skips documents that are still being processed' do
|
||||
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params.merge(ids: [in_progress_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'skips documents that already have a sync in progress' do
|
||||
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params.merge(ids: [syncing_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
freeze_time do
|
||||
syncing_document.update!(
|
||||
sync_status: :syncing,
|
||||
last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago
|
||||
)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params.merge(ids: [syncing_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document)
|
||||
|
||||
expect(syncing_document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
|
||||
end
|
||||
|
||||
it 'denies non-administrators' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: sync_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:assistant2) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
|
||||
let(:captain_limits) do
|
||||
{
|
||||
:startups => { :documents => 1, :responses => 100 }
|
||||
@@ -141,6 +141,27 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(json_response[:name]).to eq(document.name)
|
||||
expect(json_response[:external_link]).to eq(document.external_link)
|
||||
end
|
||||
|
||||
it 'returns sync metadata when the document has been synced' do
|
||||
synced_at = 1.hour.ago
|
||||
document.update!(sync_status: :synced, last_synced_at: synced_at)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(json_response[:sync_status]).to eq('synced')
|
||||
expect(json_response[:last_synced_at]).to eq(synced_at.to_i)
|
||||
end
|
||||
|
||||
it 'does not report failed documents without a successful sync as last synced' do
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(json_response[:sync_status]).to eq('failed')
|
||||
expect(json_response[:last_synced_at]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -235,6 +256,98 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/documents/:id/sync' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'denies the request' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'queues a sync and returns accepted' do
|
||||
freeze_time do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:accepted)
|
||||
end
|
||||
|
||||
it 'rejects documents that already have a sync in progress' do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
freeze_time do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: (Captain::Document::SYNC_STALE_TIMEOUT + 1.minute).ago)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:accepted)
|
||||
end
|
||||
|
||||
it 'rejects PDF documents with an explanatory error' do
|
||||
pdf_document = build(:captain_document, assistant: assistant, account: account)
|
||||
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
|
||||
content_type: 'application/pdf')
|
||||
pdf_document.save!
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{pdf_document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'rejects documents that are still being processed' do
|
||||
in_progress_document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{in_progress_document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:voice_channel) { create(:channel_voice, account: account) }
|
||||
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:voice_inbox) { voice_channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
|
||||
@@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates a voice inbox when administrator' do
|
||||
account.enable_features('channel_voice')
|
||||
account.save!
|
||||
stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json})
|
||||
.to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||
perform: "AP#{SecureRandom.hex(16)}"))
|
||||
|
||||
@@ -34,8 +39,7 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
|
||||
api_key_secret: SecureRandom.hex(16) } } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:digits) { channel.phone_number.delete_prefix('+') }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user