Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b759496f2 | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
98fa5f60e9 | ||
|
|
7a8829e79e | ||
|
|
bf397750f3 | ||
|
|
f253ea0abc | ||
|
|
3aea425abc | ||
|
|
eee307b36b |
@@ -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,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');
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -132,8 +132,10 @@ const headers = computed(() => [
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
<div
|
||||
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
|
||||
>
|
||||
<p class="text-body-main text-n-slate-11 mb-0">
|
||||
{{
|
||||
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
|
||||
selected: selectedAgents.length,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
|
||||
* 3. Nested properties in custom_attributes (conversation_type, etc.)
|
||||
*/
|
||||
import jsonLogic from 'json-logic-js';
|
||||
import { coerceToDate } from '@chatwoot/utils';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
|
||||
/**
|
||||
* Gets a value from a conversation based on the attribute key
|
||||
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
|
||||
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
|
||||
*
|
||||
* This function handles various equality scenarios:
|
||||
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
|
||||
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
|
||||
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
|
||||
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
|
||||
* 3. Otherwise: performs strict equality comparison
|
||||
*/
|
||||
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
|
||||
if (filterValue === 'all') return true;
|
||||
|
||||
if (Array.isArray(conversationValue)) {
|
||||
// For array values like labels, check if any of the filter values exist in the array
|
||||
return filterValue.every(val => conversationValue.includes(val));
|
||||
// For array values like labels, match if any filter value is present.
|
||||
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
|
||||
return filterValue.some(val => conversationValue.includes(val));
|
||||
}
|
||||
|
||||
if (!Array.isArray(conversationValue)) {
|
||||
|
||||
+34
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
|
||||
// a conversation matches if ANY of the filter labels is on it.
|
||||
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['support'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['new'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with is_present operator for labels', () => {
|
||||
const conversation = { labels: ['support', 'urgent', 'new'] };
|
||||
const filters = [
|
||||
|
||||
@@ -42,6 +42,19 @@ const updateAuthCookie = (cookieContent, baseDomain = '') =>
|
||||
baseDomain,
|
||||
});
|
||||
|
||||
const getTargetOrigin = () => {
|
||||
const { baseUrl } = window.$chatwoot || {};
|
||||
if (!baseUrl) {
|
||||
return window.location.origin;
|
||||
}
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.origin;
|
||||
} catch {
|
||||
return window.location.origin;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCampaignReadStatus = baseDomain => {
|
||||
const expireBy = addHours(new Date(), 1);
|
||||
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
|
||||
@@ -93,13 +106,19 @@ export const IFrameHelper = {
|
||||
getBubbleHolder: () => document.getElementsByClassName('woot--bubble-holder'),
|
||||
sendMessage: (key, value) => {
|
||||
const element = IFrameHelper.getAppFrame();
|
||||
const targetOrigin = getTargetOrigin();
|
||||
if (!targetOrigin) return;
|
||||
element.contentWindow.postMessage(
|
||||
`chatwoot-widget:${JSON.stringify({ event: key, ...value })}`,
|
||||
'*'
|
||||
targetOrigin
|
||||
);
|
||||
},
|
||||
initPostMessageCommunication: () => {
|
||||
window.onmessage = e => {
|
||||
const expectedOrigin = getTargetOrigin();
|
||||
if (!expectedOrigin || e.origin !== expectedOrigin) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
e.data.indexOf('chatwoot-widget:') !== 0
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+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}'
|
||||
|
||||
+3
-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
|
||||
|
||||
@@ -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
|
||||
+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
|
||||
+4
-13
@@ -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"
|
||||
@@ -552,6 +552,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 +571,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"
|
||||
|
||||
@@ -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
|
||||
+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('+') }
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'business' }) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
account.enable_features!('captain_document_auto_sync')
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
context 'when the account has not enabled auto-sync' do
|
||||
before { account.disable_features!('captain_document_auto_sync') }
|
||||
|
||||
it 'leaves available documents alone' do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account plan has no sync cadence' do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'hacker' }) }
|
||||
|
||||
it 'leaves available documents alone' do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document has backfilled sync metadata' do
|
||||
it 'leaves it alone when last synced within the plan cadence' do
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 1.hour.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
|
||||
it 'queues a sync when last synced before the plan cadence' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
it 'marks the due document as syncing before queueing' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
described_class.new.perform
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not queue the same document again while the reserved sync is fresh' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document was synced within the plan cadence' do
|
||||
it 'leaves it alone' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 1.hour.ago, last_sync_attempted_at: 1.hour.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document was last synced before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when more documents are due than the account cap allows' do
|
||||
before do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
|
||||
end
|
||||
|
||||
it 'queues backfilled and oldest-attempted documents first' do
|
||||
newest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
|
||||
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document failed before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a document is stuck in syncing past the scheduler stale timeout' do
|
||||
it 'requeues a sync to recover the lock' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
last_sync_attempted_at: (described_class::SYNC_STALE_TIMEOUT + 1.minute).ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a document has been queued longer than the worker lock timeout' do
|
||||
it 'leaves it alone so queue lag is not treated as a dead worker' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(
|
||||
sync_status: :syncing,
|
||||
last_sync_attempted_at: (Captain::Documents::PerformSyncJob::LOCK_TIMEOUT + 1.minute).ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a document is currently syncing within the scheduler stale timeout' do
|
||||
it 'leaves it alone so the holder can finish' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the only eligible document is a PDF' do
|
||||
it 'leaves it alone since PDFs are not syncable' do
|
||||
pdf_document = build(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
pdf_document.pdf_file.attach(io: StringIO.new('PDF content'), filename: 'test.pdf',
|
||||
content_type: 'application/pdf')
|
||||
pdf_document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an in-progress (still crawling) document is past the cadence' do
|
||||
it 'leaves it alone since only available documents are eligible' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
document.update!(last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -15,17 +15,22 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end
|
||||
|
||||
it 'creates a new document when one does not exist' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
status: 'available'
|
||||
)
|
||||
document = assistant.documents.last
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
status: 'available',
|
||||
sync_status: 'synced',
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates existing document when one exists' do
|
||||
@@ -37,18 +42,23 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
)
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available',
|
||||
sync_status: 'synced',
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
|
||||
@@ -14,20 +14,28 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
.and_return(crawler)
|
||||
|
||||
allow(crawler).to receive(:page_title).and_return(page_title)
|
||||
allow(crawler).to receive(:body_text_content).and_return(content)
|
||||
allow(crawler).to receive(:body_markdown).and_return(content)
|
||||
allow(crawler).to receive(:success?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when the page is successfully crawled' do
|
||||
it 'creates a new document if one does not exist' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document).to have_attributes(
|
||||
status: 'available',
|
||||
sync_status: 'synced',
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates existing document if one exists' do
|
||||
@@ -37,14 +45,21 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
expect(existing_document.name).to eq(page_title)
|
||||
expect(existing_document.content).to eq(content)
|
||||
expect(existing_document.status).to eq('available')
|
||||
existing_document.reload
|
||||
expect(existing_document.name).to eq(page_title)
|
||||
expect(existing_document.content).to eq(content)
|
||||
expect(existing_document).to have_attributes(
|
||||
status: 'available',
|
||||
sync_status: 'synced',
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when title or content exceed maximum length' do
|
||||
@@ -53,7 +68,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:page_title).and_return(long_title)
|
||||
allow(crawler).to receive(:body_text_content).and_return(long_content)
|
||||
allow(crawler).to receive(:body_markdown).and_return(long_content)
|
||||
end
|
||||
|
||||
it 'truncates the title and content' do
|
||||
@@ -78,10 +93,82 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the page fetch fails' do
|
||||
before do
|
||||
allow(crawler).to receive(:success?).and_return(false)
|
||||
allow(crawler).to receive(:status_code).and_return(500)
|
||||
end
|
||||
|
||||
it 'raises an error without creating an available document' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
|
||||
.and not_change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: 'https://example.com/page',
|
||||
status: :in_progress
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch page: #{page_link}")
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
sync_status: 'failed',
|
||||
last_sync_error_code: 'fetch_failed',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the failure is permanent' do
|
||||
before do
|
||||
allow(crawler).to receive(:status_code).and_return(404)
|
||||
end
|
||||
|
||||
it 'does not retry a discovered link that was never persisted' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed without raising' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: 'https://example.com/page',
|
||||
status: :in_progress
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to raise_error
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
sync_status: 'failed',
|
||||
last_sync_error_code: 'not_found',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when title and content are nil' do
|
||||
before do
|
||||
allow(crawler).to receive(:page_title).and_return(nil)
|
||||
allow(crawler).to receive(:body_text_content).and_return(nil)
|
||||
allow(crawler).to receive(:body_markdown).and_return(nil)
|
||||
end
|
||||
|
||||
it 'creates document with empty strings and updates the status to available' do
|
||||
|
||||
@@ -222,6 +222,51 @@ RSpec.describe Account, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'captain document sync cadence' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'has no cadence on the hacker plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'hacker' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'syncs weekly on the startups plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'startups' })
|
||||
expect(account.captain_document_sync_interval).to eq(7.days)
|
||||
end
|
||||
|
||||
it 'syncs daily on the business plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'business' })
|
||||
expect(account.captain_document_sync_interval).to eq(1.day)
|
||||
end
|
||||
|
||||
it 'syncs every six hours on the enterprise plan' do
|
||||
account.update!(custom_attributes: { plan_name: 'enterprise' })
|
||||
expect(account.captain_document_sync_interval).to eq(6.hours)
|
||||
end
|
||||
|
||||
it 'has no cadence when plan is missing' do
|
||||
account.update!(custom_attributes: {})
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'has no cadence for unknown plans' do
|
||||
account.update!(custom_attributes: { plan_name: 'mystery' })
|
||||
expect(account.captain_document_sync_interval).to be_nil
|
||||
end
|
||||
|
||||
it 'normalizes plan name casing' do
|
||||
account.update!(custom_attributes: { plan_name: 'Business' })
|
||||
expect(account.captain_document_sync_interval).to eq(1.day)
|
||||
end
|
||||
|
||||
it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
|
||||
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
|
||||
account.update!(custom_attributes: {})
|
||||
expect(account.captain_document_sync_interval).to eq(6.hours)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account deletion' do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::TwilioSms do
|
||||
let(:account) { create(:account) }
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'has a valid :with_voice factory' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account)
|
||||
expect(channel).to be_valid
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'requires a phone number when voice is enabled' do
|
||||
channel = build(:channel_twilio_sms, :with_voice, account: account, phone_number: nil)
|
||||
channel.valid?
|
||||
expect(channel.errors[:base]).to include('Voice calling requires a phone number and cannot be used with messaging service SID')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_enabled?' do
|
||||
it 'returns true when voice_enabled is set' do
|
||||
channel = create(:channel_twilio_sms, :with_voice, account: account)
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false by default' do
|
||||
channel = create(:channel_twilio_sms, account: account)
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_call_webhook_url' do
|
||||
it 'returns the webhook URL based on phone number' do
|
||||
channel = create(:channel_twilio_sms, :with_voice)
|
||||
digits = channel.phone_number.delete_prefix('+')
|
||||
expect(channel.voice_call_webhook_url).to include(digits)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_status_webhook_url' do
|
||||
it 'returns the status webhook URL based on phone number' do
|
||||
channel = create(:channel_twilio_sms, :with_voice)
|
||||
digits = channel.phone_number.delete_prefix('+')
|
||||
expect(channel.voice_status_webhook_url).to include(digits)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provisioning on create' do
|
||||
it 'stores twiml_app_sid from the webhook setup service' do
|
||||
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' })
|
||||
channel = create(:channel_twilio_sms, :with_voice, twiml_app_sid: nil)
|
||||
expect(channel.twiml_app_sid).to eq(twiml_app_sid)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'teardown on disable' do
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:app_context) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) }
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) }
|
||||
|
||||
before do
|
||||
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:applications).with(channel.twiml_app_sid).and_return(app_context)
|
||||
allow(app_context).to receive(:delete)
|
||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
|
||||
allow(numbers_list).to receive(:list).with(phone_number: channel.phone_number).and_return([])
|
||||
end
|
||||
|
||||
it 'deletes the TwiML app and clears twiml_app_sid' do
|
||||
original_twiml_sid = channel.twiml_app_sid
|
||||
channel.update!(voice_enabled: false)
|
||||
|
||||
expect(twilio_client).to have_received(:applications).with(original_twiml_sid)
|
||||
expect(app_context).to have_received(:delete)
|
||||
expect(channel.reload.twiml_app_sid).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves api_key_sid and api_key_secret' do
|
||||
channel.update!(voice_enabled: false)
|
||||
expect(channel.reload.api_key_sid).to be_present
|
||||
expect(channel.reload.api_key_secret).to be_present
|
||||
end
|
||||
|
||||
it 'does not fail if Twilio API errors' do
|
||||
allow(app_context).to receive(:delete).and_raise(StandardError.new('Not found'))
|
||||
|
||||
expect { channel.update!(voice_enabled: false) }.not_to raise_error
|
||||
expect(channel.reload.twiml_app_sid).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,79 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::Voice do
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
let(:channel) { create(:channel_voice) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
|
||||
end
|
||||
|
||||
it 'has a valid factory' do
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'validates presence of provider_config' do
|
||||
channel.provider_config = nil
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'validates presence of account_sid in provider_config' do
|
||||
channel.provider_config = { auth_token: 'token' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of auth_token in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of api_key_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of api_key_secret in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of twiml_app_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'is valid with all required provider_config fields' do
|
||||
channel.provider_config = {
|
||||
account_sid: 'test_sid',
|
||||
auth_token: 'test_token',
|
||||
api_key_sid: 'test_key',
|
||||
api_key_secret: 'test_secret',
|
||||
twiml_app_sid: 'test_app_sid'
|
||||
}
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#name' do
|
||||
it 'returns Voice with phone number' do
|
||||
expect(channel.name).to include('Voice')
|
||||
expect(channel.name).to include(channel.phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provisioning on create' do
|
||||
it 'stores twiml_app_sid in provider_config' do
|
||||
ch = create(:channel_voice)
|
||||
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AssistantPolicy, type: :policy do
|
||||
subject(:assistant_policy) { described_class }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
|
||||
|
||||
permissions :index?, :show?, :playground? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
context 'when agent' do
|
||||
it { expect(assistant_policy).to permit(agent_context, assistant) }
|
||||
end
|
||||
end
|
||||
|
||||
permissions :tools?, :create?, :update?, :destroy?, :sync? do
|
||||
context 'when administrator' do
|
||||
it { expect(assistant_policy).to permit(administrator_context, assistant) }
|
||||
end
|
||||
|
||||
context 'when agent' do
|
||||
it { expect(assistant_policy).not_to permit(agent_context, assistant) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
describe '#analyze' do
|
||||
context 'when website content is available and LLM call is successful' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
@@ -56,7 +56,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
|
||||
context 'when website content fetch raises an error' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_raise(StandardError, 'Network error')
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
@@ -69,7 +69,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
|
||||
context 'when website content is empty' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_return('')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('')
|
||||
end
|
||||
@@ -84,7 +84,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
|
||||
context 'when LLM call fails' do
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
@@ -103,7 +103,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
|
||||
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome to Example Corp')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
|
||||
@@ -122,7 +122,7 @@ RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
|
||||
let(:website_url) { 'example.com' }
|
||||
|
||||
before do
|
||||
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
|
||||
allow(mock_crawler).to receive(:body_markdown).and_return('Welcome')
|
||||
allow(mock_crawler).to receive(:page_title).and_return('Example')
|
||||
allow(mock_crawler).to receive(:meta_description).and_return('Description')
|
||||
allow(mock_crawler).to receive(:favicon_url).and_return(nil)
|
||||
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new }.to raise_error(NoMethodError)
|
||||
expect { described_class.new }.to raise_error('Missing API key')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -36,6 +36,31 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#success?' do
|
||||
context 'when the fetch succeeds' do
|
||||
before do
|
||||
stub_request(:get, base_url)
|
||||
.to_return(status: 200, body: '<html><head><title>Example Page</title></head></html>')
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.success?).to be(true)
|
||||
expect(service.status_code).to eq(200)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the fetch returns a non-success response' do
|
||||
before do
|
||||
stub_request(:get, base_url).to_return(status: 404, body: 'Not found')
|
||||
end
|
||||
|
||||
it 'returns false and exposes the status code' do
|
||||
expect(service.success?).to be(false)
|
||||
expect(service.status_code).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#page_links' do
|
||||
context 'with HTML page' do
|
||||
let(:html_content) do
|
||||
@@ -95,7 +120,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#body_text_content' do
|
||||
describe '#body_markdown' do
|
||||
let(:html_content) do
|
||||
<<~HTML
|
||||
<html>
|
||||
@@ -117,7 +142,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlService do
|
||||
end
|
||||
|
||||
it 'converts body content to markdown' do
|
||||
expect(service.body_text_content).to eq("# Main Title\n\nConverted markdown")
|
||||
expect(service.body_markdown).to eq("# Main Title\n\nConverted markdown")
|
||||
expect(ReverseMarkdown).to have_received(:convert).with(
|
||||
kind_of(Nokogiri::XML::Element),
|
||||
unknown_tags: :bypass,
|
||||
|
||||
@@ -9,14 +9,16 @@ RSpec.describe Twilio::VoiceWebhookSetupService do
|
||||
let(:api_key_secret) { 'api_key_secret_123' }
|
||||
let(:phone_number) { '+15551230001' }
|
||||
let(:frontend_url) { 'https://app.chatwoot.test' }
|
||||
let(:account) { create(:account) }
|
||||
|
||||
let(:channel) do
|
||||
build(:channel_voice, phone_number: phone_number, provider_config: {
|
||||
account_sid: account_sid,
|
||||
auth_token: auth_token,
|
||||
api_key_sid: api_key_sid,
|
||||
api_key_secret: api_key_secret
|
||||
})
|
||||
build(:channel_twilio_sms, :with_voice,
|
||||
account: account,
|
||||
phone_number: phone_number,
|
||||
account_sid: account_sid,
|
||||
auth_token: auth_token,
|
||||
api_key_sid: api_key_sid,
|
||||
api_key_secret: api_key_secret)
|
||||
end
|
||||
|
||||
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
|
||||
|
||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::InboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550001111' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::OutboundCallBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
describe Voice::Provider::Twilio::Adapter do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:adapter) { described_class.new(channel) }
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
|
||||
@@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do
|
||||
allow(calls_double).to receive(:create).and_return(call_instance)
|
||||
|
||||
allow(Twilio::REST::Client).to receive(:new)
|
||||
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
|
||||
.with(channel.account_sid, channel.auth_token)
|
||||
.and_return(client_double)
|
||||
|
||||
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
|
||||
|
||||
@@ -2,14 +2,15 @@ require 'rails_helper'
|
||||
|
||||
describe Voice::Provider::Twilio::ConferenceService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||
end
|
||||
|
||||
describe '#ensure_conference_sid' do
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
describe Voice::Provider::Twilio::TokenService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:voice_channel) { create(:channel_voice, account: account) }
|
||||
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:inbox) { voice_channel.inbox }
|
||||
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
|
||||
@@ -27,7 +27,7 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
|
||||
)
|
||||
end
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550002222' }
|
||||
let(:call_sid) { 'CATESTSTATUS123' }
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_voice, class: 'Channel::Voice' do
|
||||
sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
|
||||
provider_config do
|
||||
{
|
||||
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)}"
|
||||
}
|
||||
end
|
||||
account
|
||||
|
||||
after(:create) do |channel_voice|
|
||||
create(:inbox, channel: channel_voice, account: channel_voice.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -17,5 +17,13 @@ FactoryBot.define do
|
||||
trait :whatsapp do
|
||||
medium { :whatsapp }
|
||||
end
|
||||
|
||||
trait :with_voice do
|
||||
with_phone_number
|
||||
voice_enabled { true }
|
||||
api_key_sid { "SK#{SecureRandom.hex(16)}" }
|
||||
api_key_secret { SecureRandom.hex(16) }
|
||||
twiml_app_sid { "AP#{SecureRandom.hex(16)}" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::LiquidTemplateProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, name: 'Agent Smith') }
|
||||
let(:inbox) { create(:inbox, account: account, name: 'Support Inbox') }
|
||||
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
|
||||
let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: 'Test message') }
|
||||
let(:service) { described_class.new(campaign: campaign, contact: contact) }
|
||||
|
||||
describe '#process_template_params' do
|
||||
context 'when template_params is blank' do
|
||||
it 'returns the original template_params' do
|
||||
result = service.process_template_params(nil)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processed_params is blank' do
|
||||
let(:template_params) { { 'name' => 'test_template' } }
|
||||
|
||||
it 'returns the original template_params' do
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to eq(template_params)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with body parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'namespace' => 'test_namespace',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}',
|
||||
'email' => '{{contact.email}}',
|
||||
'static_text' => 'Hello World'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in body parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']['name']).to eq(contact_drop_name)
|
||||
expect(result['processed_params']['body']['email']).to eq(contact.email)
|
||||
expect(result['processed_params']['body']['static_text']).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'does not modify the original template_params' do
|
||||
original_name_value = template_params['processed_params']['body']['name']
|
||||
service.process_template_params(template_params)
|
||||
|
||||
expect(template_params['processed_params']['body']['name']).to eq(original_name_value)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with header parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'header' => {
|
||||
'media_url' => 'https://example.com/{{contact.name}}.jpg',
|
||||
'media_name' => '{{contact.name}}_document.pdf'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in header parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['header']['media_url']).to eq("https://example.com/#{contact_drop_name}.jpg")
|
||||
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_document.pdf")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with button parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'buttons' => [
|
||||
{ 'type' => 'url', 'parameter' => '{{contact.email}}' },
|
||||
{ 'type' => 'copy_code', 'parameter' => 'CODE-{{contact.name}}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in button parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
|
||||
expect(result['processed_params']['buttons'][1]['parameter']).to eq("CODE-#{contact_drop_name}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with footer parameters containing liquid variables' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'footer' => {
|
||||
'text' => 'From {{agent.name}} at {{account.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in footer parameters' do
|
||||
result = service.process_template_params(template_params)
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
|
||||
expect(result['processed_params']['footer']['text']).to eq("From #{agent_drop_name} at #{account.name}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple liquid variables across different sections' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'greeting' => 'Hello {{contact.name}}',
|
||||
'agent' => 'Your agent is {{agent.name}}'
|
||||
},
|
||||
'header' => {
|
||||
'media_name' => '{{contact.name}}_file.pdf'
|
||||
},
|
||||
'buttons' => [
|
||||
{ 'parameter' => '{{contact.email}}' }
|
||||
],
|
||||
'footer' => {
|
||||
'text' => '{{inbox.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes all liquid variables correctly' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
agent_drop_name = UserDrop.new(agent).name
|
||||
|
||||
expect(result['processed_params']['body']['greeting']).to eq("Hello #{contact_drop_name}")
|
||||
expect(result['processed_params']['body']['agent']).to eq("Your agent is #{agent_drop_name}")
|
||||
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_file.pdf")
|
||||
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
|
||||
expect(result['processed_params']['footer']['text']).to eq(inbox.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank or nil values' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => nil,
|
||||
'email' => '',
|
||||
'valid' => '{{contact.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'handles blank values gracefully' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']['name']).to be_nil
|
||||
expect(result['processed_params']['body']['email']).to eq('')
|
||||
expect(result['processed_params']['body']['valid']).to eq(contact_drop_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when liquid variable resolves to blank' do
|
||||
let(:contact) { create(:contact, account: account, name: 'John', email: nil, phone_number: '+1234567890') }
|
||||
|
||||
it 'returns nil for enhanced params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'email' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for legacy hash params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'1' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for legacy array params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => ['{{contact.email}}']
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for button params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'buttons' => [
|
||||
{ 'type' => 'url', 'parameter' => '{{contact.email}}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns processed params when all variables resolve to non-blank values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).not_to be_nil
|
||||
expect(result['processed_params']['body']['name']).to eq(ContactDrop.new(contact).name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with custom attributes' do
|
||||
let(:contact) do
|
||||
create(:contact, account: account, name: 'John Doe',
|
||||
custom_attributes: { 'company' => 'Acme Inc', 'plan' => 'Premium' })
|
||||
end
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'company' => '{{contact.custom_attribute.company}}',
|
||||
'plan' => '{{contact.custom_attribute.plan}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes custom attribute liquid variables' do
|
||||
result = service.process_template_params(template_params)
|
||||
|
||||
expect(result['processed_params']['body']['company']).to eq('Acme Inc')
|
||||
expect(result['processed_params']['body']['plan']).to eq('Premium')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid liquid syntax' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'invalid' => '{{contact.name missing braces'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns original value when liquid parsing fails' do
|
||||
result = service.process_template_params(template_params)
|
||||
|
||||
expect(result['processed_params']['body']['invalid']).to eq('{{contact.name missing braces')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy flat hash processed_params' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => {
|
||||
'1' => '{{contact.name}}',
|
||||
'2' => '{{contact.email}}',
|
||||
'3' => 'Hello World'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in legacy hash values' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['1']).to eq(contact_drop_name)
|
||||
expect(result['processed_params']['2']).to eq(contact.email)
|
||||
expect(result['processed_params']['3']).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'treats component-named string values as legacy params' do
|
||||
params_with_component_named_key = {
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => { 'body' => '{{contact.name}}' }
|
||||
}
|
||||
|
||||
result = service.process_template_params(params_with_component_named_key)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']).to eq(contact_drop_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy array processed_params' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => ['{{contact.name}}', '{{contact.email}}', 'Hello World']
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in legacy array values' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']).to eq([contact_drop_name, contact.email, 'Hello World'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -139,6 +139,73 @@ describe Whatsapp::OneoffCampaignService do
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
|
||||
it 'processes liquid variables in template parameters' do
|
||||
contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com')
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
campaign_with_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }],
|
||||
template_params: {
|
||||
'name' => 'ticket_status_updated',
|
||||
'namespace' => '23423423_2342423_324234234_2343224',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}',
|
||||
'ticket_id' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template).with(
|
||||
contact.phone_number,
|
||||
hash_including(
|
||||
name: 'ticket_status_updated',
|
||||
namespace: '23423423_2342423_324234234_2343224',
|
||||
lang_code: 'en',
|
||||
parameters: array_including(
|
||||
hash_including(
|
||||
type: 'body',
|
||||
parameters: array_including(
|
||||
hash_including(type: 'text', parameter_name: 'name', text: contact_drop_name),
|
||||
hash_including(type: 'text', parameter_name: 'ticket_id', text: contact.email)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
nil
|
||||
)
|
||||
|
||||
described_class.new(campaign: campaign_with_liquid).perform
|
||||
end
|
||||
|
||||
it 'skips contacts when liquid variables resolve to blank values' do
|
||||
contact = create(:contact, :with_phone_number, account: account, name: 'Jane', email: nil)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
campaign_with_blank_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }],
|
||||
template_params: {
|
||||
'name' => 'test_template',
|
||||
'namespace' => 'test_namespace',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'email' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(whatsapp_channel).not_to receive(:send_template)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping contact #{contact.name} - liquid variables resolved to blank values")
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
described_class.new(campaign: campaign_with_blank_liquid).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template_params is missing' do
|
||||
|
||||
Reference in New Issue
Block a user